From 319f0d0ce78b64490a9124c34bdb6dd2a3c895a8 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 16 Oct 2026 14:37:50 +0530 Subject: [PATCH 1/6] ci: check Linux and Windows installs on every push to main Neither installer had automated evidence on its target host. Each job writes one synthetic content-free session, installs the backend, checks the service, /health (ready, at least one indexed session, no adapter failures) and /menubar, then uninstalls and confirms nothing is left serving. Tray companions need a desktop session hosted runners do not reliably provide, so they are out of scope. Co-Authored-By: Claude Code --- .github/scripts/write_sample_trace.py | 31 ++++++ .github/workflows/install-checks.yml | 154 ++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 .github/scripts/write_sample_trace.py create mode 100644 .github/workflows/install-checks.yml diff --git a/.github/scripts/write_sample_trace.py b/.github/scripts/write_sample_trace.py new file mode 100644 index 00000000..47030f7d --- /dev/null +++ b/.github/scripts/write_sample_trace.py @@ -0,0 +1,31 @@ +"""Write one synthetic, content-free Codex session so CI installs index real data.""" + +import json +from pathlib import Path + + +ROWS = [ + {"timestamp": "2026-01-02T10:00:00Z", "type": "session_meta", + "payload": {"id": "ci-sample-session", "cwd": "/ci/project", "model_provider": "openai"}}, + {"timestamp": "2026-01-02T10:00:01Z", "type": "turn_context", + "payload": {"model": "gpt-5.5", "cwd": "/ci/project"}}, + {"timestamp": "2026-01-02T10:00:02Z", "type": "event_msg", + "payload": {"type": "task_started"}}, + {"timestamp": "2026-01-02T10:00:03Z", "type": "event_msg", + "payload": {"type": "token_count", "info": {"last_token_usage": { + "input_tokens": 1200, "output_tokens": 80, "cached_input_tokens": 400}}}}, + {"timestamp": "2026-01-02T10:00:05Z", "type": "event_msg", + "payload": {"type": "task_complete", "duration_ms": 3000}}, +] + + +def main(): + folder = Path.home() / ".codex" / "sessions" / "2026" / "01" / "02" + folder.mkdir(parents=True, exist_ok=True) + trace = folder / "rollout-ci-sample-session.jsonl" + trace.write_text("".join(json.dumps(row) + "\n" for row in ROWS), encoding="utf-8") + print(f"Wrote {trace}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/install-checks.yml b/.github/workflows/install-checks.yml new file mode 100644 index 00000000..037654a8 --- /dev/null +++ b/.github/workflows/install-checks.yml @@ -0,0 +1,154 @@ +name: Install checks + +# Installs the backend on clean Linux and Windows hosts, checks the running +# service, then uninstalls. Tray companions need a desktop session that hosted +# runners do not reliably provide, so they are not covered here. +on: + push: + branches: [main] + pull_request: + paths: + - ".github/workflows/install-checks.yml" + - ".github/scripts/write_sample_trace.py" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: install-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + name: Linux install + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600" + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Hosted runners have no logged-in user session; lingering starts the + # systemd user manager the installer expects from a desktop login. + - name: Start a systemd user session + run: | + sudo loginctl enable-linger "$USER" + runtime_dir="/run/user/$(id -u)" + export XDG_RUNTIME_DIR="$runtime_dir" + export DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime_dir/bus" + for _ in $(seq 1 30); do + systemctl --user show-environment >/dev/null 2>&1 && break + sleep 1 + done + systemctl --user show-environment >/dev/null + echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV" + echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> "$GITHUB_ENV" + + - name: Add a sample session + run: python3 .github/scripts/write_sample_trace.py + + - name: Install + run: ./scripts/install --backend-only + + - name: Check the installation + run: | + runtime="${XDG_DATA_HOME:-$HOME/.local/share}/token-meter/runtime" + systemctl --user is-active --quiet token-meter-server.service + test "$(cat "$runtime/INSTALL_MODE")" = "backend-only" + curl -fsS --max-time 10 http://127.0.0.1:8722/health | python3 -c ' + import json, sys + health = json.load(sys.stdin) + assert health.get("ok") and health.get("state_ready"), health + assert health.get("sources", 0) >= 1, health + assert not health.get("runtime_adapter_failures"), health + print("health ok:", health["sources"], "session(s) indexed") + ' + curl -fsS --max-time 10 http://127.0.0.1:8722/menubar | python3 -c 'import json, sys; json.load(sys.stdin); print("menubar ok")' + + - name: Uninstall + run: | + runtime="${XDG_DATA_HOME:-$HOME/.local/share}/token-meter/runtime" + "$runtime/scripts/uninstall-systemd-user" + if systemctl --user is-active --quiet token-meter-server.service; then + echo "token-meter-server.service is still running" >&2 + exit 1 + fi + if curl -fsS --max-time 3 http://127.0.0.1:8722/health >/dev/null 2>&1; then + echo "the server still answers after uninstall" >&2 + exit 1 + fi + echo "uninstall ok" + + - name: Show server logs + if: failure() + run: journalctl --user -u token-meter-server.service -n 200 --no-pager || true + + windows: + name: Windows install + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Add a sample session + run: python .github/scripts/write_sample_trace.py + + - name: Install + shell: powershell + run: | + & powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass ` + -File scripts\install-windows.ps1 -BackendOnly -ReadinessTimeoutSeconds 600 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Check the installation + shell: powershell + run: | + $Runtime = Join-Path $env:LOCALAPPDATA "Token Meter\runtime" + $Health = Invoke-RestMethod -Uri "http://127.0.0.1:8722/health" -TimeoutSec 10 + if (-not $Health.ok -or -not $Health.state_ready) { throw "server is not ready: $($Health | ConvertTo-Json -Compress)" } + if ($Health.sources -lt 1) { throw "no sessions were indexed: $($Health | ConvertTo-Json -Compress)" } + if (@($Health.runtime_adapter_failures).Count -gt 0) { throw "runtime adapters failed: $($Health | ConvertTo-Json -Compress)" } + Write-Host "health ok: $($Health.sources) session(s) indexed" + $null = Invoke-RestMethod -Uri "http://127.0.0.1:8722/menubar" -TimeoutSec 10 + Write-Host "menubar ok" + $Mode = (Get-Content -LiteralPath (Join-Path $Runtime "INSTALL_MODE") -Raw).Trim() + if ($Mode -ne "backend-only") { throw "unexpected install mode: $Mode" } + $RunKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" + if (-not (Get-ItemProperty -LiteralPath $RunKey -Name "Token Meter" -ErrorAction SilentlyContinue)) { + throw "the login startup entry is missing" + } + Write-Host "startup entry ok" + + - name: Uninstall + shell: powershell + run: | + $Runtime = Join-Path $env:LOCALAPPDATA "Token Meter\runtime" + & powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Runtime "scripts\uninstall-windows.ps1") + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $RunKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" + if (Get-ItemProperty -LiteralPath $RunKey -Name "Token Meter" -ErrorAction SilentlyContinue) { + throw "the login startup entry is still present" + } + $StillServing = $true + try { $null = Invoke-RestMethod -Uri "http://127.0.0.1:8722/health" -TimeoutSec 3 } catch { $StillServing = $false } + if ($StillServing) { throw "the server still answers after uninstall" } + Write-Host "uninstall ok" + + - name: Show server logs + if: failure() + shell: powershell + run: | + $Runtime = Join-Path $env:LOCALAPPDATA "Token Meter\runtime" + foreach ($Name in @("meter.err.log", "meter.log")) { + $Path = Join-Path $Runtime $Name + if (Test-Path -LiteralPath $Path) { Write-Host "== $Name"; Get-Content -LiteralPath $Path -Tail 200 } + } From d15ab00d1c94387368013933745ead4d00f5efc5 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 16 Oct 2026 14:48:16 +0530 Subject: [PATCH 2/6] fix(install): stop the Linux server on uninstall; survive detached Windows checkouts The first CI run on real hosts found two installer bugs: - Linux uninstall passed both units to one `systemctl disable --now`; systemd refuses the whole command when a unit file is missing, which every backend-only install hits, so the server kept running after its unit was deleted. Each unit is now stopped on its own. - The Windows installer aborted on a detached HEAD or a branch without an upstream: Windows PowerShell 5.1 turns git's stderr into a terminating error under ErrorActionPreference=Stop even when redirected. Git probes now go through Get-GitText, which tolerates that and returns "". Install checks now also run on every pull request to main. Co-Authored-By: Claude Code --- .github/workflows/install-checks.yml | 4 +-- scripts/install-windows.ps1 | 32 ++++++++++++++---- scripts/uninstall-systemd-user | 7 +++- tests/contracts/test_windows_packaging.py | 12 +++++++ tests/test_installer_modes.py | 40 +++++++++++++++++++++++ 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/.github/workflows/install-checks.yml b/.github/workflows/install-checks.yml index 037654a8..d8abe2d6 100644 --- a/.github/workflows/install-checks.yml +++ b/.github/workflows/install-checks.yml @@ -7,9 +7,7 @@ on: push: branches: [main] pull_request: - paths: - - ".github/workflows/install-checks.yml" - - ".github/scripts/write_sample_trace.py" + branches: [main] workflow_dispatch: permissions: diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index 82b4cb1b..b5b0538b 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -12,6 +12,24 @@ function Fail([string]$Message) { throw "Token Meter installation failed: $Message" } +# Windows PowerShell 5.1 turns a native command's stderr into a terminating +# error under ErrorActionPreference=Stop, even when it is redirected, so git +# probes that may legitimately fail (detached HEAD, no upstream) run here. +function Get-GitText([string[]]$Arguments) { + $PreviousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $Output = @(& $Git.Source @Arguments 2>$null) + $ExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $PreviousPreference + } + if ($ExitCode -ne 0) { + return "" + } + return (($Output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine).Trim() +} + function Write-Utf8File([string]$Path, [string]$Value) { [System.IO.File]::WriteAllText( $Path, @@ -366,12 +384,12 @@ $ManagedSourceRoot = if ($env:TOKEN_METER_SOURCE_ROOT) { Join-Path $InstallParent "source" } $UpdateSourceRoot = $SourceRoot -$SourceUpstream = (& $Git.Source -C $SourceRoot rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>$null | Out-String).Trim() +$SourceUpstream = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", '@{upstream}')) if ($SourceUpstream -like "*/*" -and $SourceRoot -ne $ManagedSourceRoot) { $Slash = $SourceUpstream.IndexOf('/') $SourceRemote = $SourceUpstream.Substring(0, $Slash) $SourceBranch = $SourceUpstream.Substring($Slash + 1) - $SourceRemoteUrl = (& $Git.Source -C $SourceRoot remote get-url $SourceRemote 2>$null | Out-String).Trim() + $SourceRemoteUrl = (Get-GitText @("-C", $SourceRoot, "remote", "get-url", $SourceRemote)) if ($SourceRemoteUrl -and $SourceBranch) { if (-not (Test-Path -LiteralPath $ManagedSourceRoot)) { & $Git.Source clone --quiet --no-local $SourceRoot $ManagedSourceRoot @@ -382,11 +400,11 @@ if ($SourceUpstream -like "*/*" -and $SourceRoot -ne $ManagedSourceRoot) { if (-not (Test-Path -LiteralPath (Join-Path $ManagedSourceRoot ".git") -PathType Container)) { Fail "the managed update checkout is not a Git repository." } - $ManagedDirty = (& $Git.Source -C $ManagedSourceRoot status --porcelain 2>$null | Out-String).Trim() + $ManagedDirty = (Get-GitText @("-C", $ManagedSourceRoot, "status", "--porcelain")) if ($ManagedDirty) { Fail "the managed update checkout has local changes." } - $SourceRevision = (& $Git.Source -C $SourceRoot rev-parse HEAD 2>$null | Out-String).Trim() + $SourceRevision = (Get-GitText @("-C", $SourceRoot, "rev-parse", "HEAD")) if ($SourceRevision) { & $Git.Source -C $ManagedSourceRoot fetch --quiet --no-tags $SourceRoot $SourceRevision if ($LASTEXITCODE -ne 0) { @@ -397,7 +415,7 @@ if ($SourceUpstream -like "*/*" -and $SourceRoot -ne $ManagedSourceRoot) { Fail "the managed update checkout has diverged from the installed source." } } - $ManagedRemoteUrl = (& $Git.Source -C $ManagedSourceRoot remote get-url origin 2>$null | Out-String).Trim() + $ManagedRemoteUrl = (Get-GitText @("-C", $ManagedSourceRoot, "remote", "get-url", "origin")) if ($ManagedRemoteUrl -ne $SourceRemoteUrl) { $ManagedRemoteIsSource = $false try { @@ -409,7 +427,7 @@ if ($SourceUpstream -like "*/*" -and $SourceRoot -ne $ManagedSourceRoot) { Fail "the managed update checkout belongs to a different upstream." } } - $ManagedBranch = (& $Git.Source -C $ManagedSourceRoot rev-parse --abbrev-ref HEAD 2>$null | Out-String).Trim() + $ManagedBranch = (Get-GitText @("-C", $ManagedSourceRoot, "rev-parse", "--abbrev-ref", "HEAD")) if ($ManagedBranch) { & $Git.Source -C $ManagedSourceRoot config "branch.$ManagedBranch.remote" origin & $Git.Source -C $ManagedSourceRoot config "branch.$ManagedBranch.merge" "refs/heads/$SourceBranch" @@ -492,7 +510,7 @@ Write-Utf8File (Join-Path $StagingRoot "PYTHON_WINDOWED_EXECUTABLE") ($PythonwEx $InstallMode = if ($BackendOnly) { "backend-only" } else { "full" } Write-Utf8File (Join-Path $StagingRoot "INSTALL_MODE") ($InstallMode + [Environment]::NewLine) -$Commit = (& $Git.Source -C $SourceRoot rev-parse --short HEAD 2>$null | Out-String).Trim() +$Commit = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--short", "HEAD")) if ($Commit) { $DirtyPaths = @( & $Git.Source -C $SourceRoot status --short --untracked-files=all 2>$null | diff --git a/scripts/uninstall-systemd-user b/scripts/uninstall-systemd-user index cb7a0ed8..53a9a20f 100755 --- a/scripts/uninstall-systemd-user +++ b/scripts/uninstall-systemd-user @@ -5,7 +5,12 @@ SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" SERVER_UNIT="$SYSTEMD_USER_DIR/token-meter-server.service" TRAY_UNIT="$SYSTEMD_USER_DIR/token-meter-tray.service" -systemctl --user disable --now token-meter-tray.service token-meter-server.service >/dev/null 2>&1 || true +# One unit at a time: systemctl refuses the whole command when any listed unit +# file is missing (backend-only installs have no tray unit), which would leave +# the server running after its unit file is removed. +for unit in token-meter-tray.service token-meter-server.service; do + systemctl --user disable --now "$unit" >/dev/null 2>&1 || true +done rm -f "$TRAY_UNIT" "$SERVER_UNIT" systemctl --user daemon-reload systemctl --user reset-failed token-meter-tray.service token-meter-server.service >/dev/null 2>&1 || true diff --git a/tests/contracts/test_windows_packaging.py b/tests/contracts/test_windows_packaging.py index b304f403..0df691dd 100644 --- a/tests/contracts/test_windows_packaging.py +++ b/tests/contracts/test_windows_packaging.py @@ -177,6 +177,18 @@ def test_windows_installer_acquires_only_missing_exact_prerequisites(self): installer, ) + def test_installer_git_probes_cannot_abort_on_a_detached_or_untracked_checkout(self): + # Under ErrorActionPreference=Stop, Windows PowerShell 5.1 turns a native + # command's stderr into a terminating error even when redirected, so a + # detached HEAD or missing upstream must not reach a bare git probe. + script = (ROOT / "scripts" / "install-windows.ps1").read_text(encoding="utf-8") + helper = script[script.index("function Get-GitText"):] + helper = helper[:helper.index("\n}\n") + 3] + self.assertIn('$ErrorActionPreference = "Continue"', helper) + self.assertIn("$ErrorActionPreference = $PreviousPreference", helper) + self.assertNotRegex(script, r"\(& \$Git\.Source [^\n]*2>\$null \| Out-String\)") + self.assertIn("$SourceUpstream = (Get-GitText @(", script) + def test_windows_scripts_accept_only_one_git_executable(self): for relative_path in WINDOWS_GIT_CONSUMERS: script = (ROOT / relative_path).read_text(encoding="utf-8") diff --git a/tests/test_installer_modes.py b/tests/test_installer_modes.py index 53352b71..688d0b1f 100644 --- a/tests/test_installer_modes.py +++ b/tests/test_installer_modes.py @@ -476,6 +476,46 @@ def test_failed_menubar_build_replays_its_output_and_removes_temp_files(self): self.assertNotIn("Menu bar companion ready", result.stdout) self.assertEqual(sorted(temp_dir.glob("token-meter-menubar-check.*")), []) + def test_linux_uninstall_stops_the_server_without_a_tray_unit(self): + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) + fake_bin = workspace / "bin" + fake_bin.mkdir() + config = workspace / "config" + units = config / "systemd" / "user" + units.mkdir(parents=True) + (units / "token-meter-server.service").write_text("[Unit]\n") + calls = workspace / "systemctl.log" + # Like systemd, refuse the whole command when any listed unit + # file is missing (backend-only installs have no tray unit). + self.write_executable( + fake_bin / "systemctl", + f""" + #!/usr/bin/env bash + printf '%s\\n' "$*" >> {str(calls)!r} + for arg in "$@"; do + if [[ "$arg" == *.service && ! -e {str(units)!r}/"$arg" ]]; then + exit 1 + fi + done + exit 0 + """, + ) + env = { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "XDG_CONFIG_HOME": str(config), + } + + result = subprocess.run( + ["bash", str(ROOT / "scripts" / "uninstall-systemd-user")], + capture_output=True, text=True, env=env, timeout=30, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--user disable --now token-meter-server.service", calls.read_text()) + self.assertFalse((units / "token-meter-server.service").exists()) + def git(self, *arguments, cwd): return subprocess.run( ["git", *arguments], From 88c93e047a7d75f072a2a44675db09d7837778f3 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 16 Oct 2026 14:54:07 +0530 Subject: [PATCH 3/6] fix(update): keep Windows update probes from aborting the helper update-windows.ps1 had the same bare git probes as the installer. On a detached HEAD or a branch without an upstream, Windows PowerShell 5.1 turned git's redirected stderr into a terminating error, so the helper aborted instead of recording upstream_unavailable or inspect_failed for the dashboard. Its probes now use the same Get-GitText helper. Co-Authored-By: Claude Code --- scripts/update-windows.ps1 | 31 ++++++++++++++++++----- tests/contracts/test_windows_packaging.py | 20 ++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/scripts/update-windows.ps1 b/scripts/update-windows.ps1 index ea2a00dd..016b212d 100644 --- a/scripts/update-windows.ps1 +++ b/scripts/update-windows.ps1 @@ -22,6 +22,25 @@ try { } } catch { } +# Windows PowerShell 5.1 turns a native command's stderr into a terminating +# error under ErrorActionPreference=Stop, even when it is redirected, so git +# probes that may legitimately fail (detached HEAD, no upstream) run here and +# reach the matching Fail-Update status instead of aborting the helper. +function Get-GitText([string[]]$Arguments) { + $PreviousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $Output = @(& $Git.Source @Arguments 2>$null) + $ExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $PreviousPreference + } + if ($ExitCode -ne 0) { + return "" + } + return (($Output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine).Trim() +} + function Write-UpdateStatus( [string]$Phase, [string]$ErrorCode, @@ -94,11 +113,11 @@ if (-not $Git) { Fail-Update "git_unavailable" } -$Upstream = (& $Git.Source -C $SourceRoot rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>$null | Out-String).Trim() +$Upstream = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", '@{upstream}')) if ($LASTEXITCODE -ne 0 -or $Upstream -notlike "*/*") { Fail-Update "upstream_unavailable" } -$Branch = (& $Git.Source -C $SourceRoot rev-parse --abbrev-ref HEAD 2>$null | Out-String).Trim() +$Branch = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--abbrev-ref", "HEAD")) if ($Branch -ne "main" -or $Upstream.Substring($Upstream.LastIndexOf('/') + 1) -ne "main") { Fail-Update "unsupported_update_branch" } @@ -112,15 +131,15 @@ if ($LASTEXITCODE -ne 0) { Fail-Update "fetch_failed" } -$script:CurrentRevision = (& $Git.Source -C $SourceRoot rev-parse HEAD 2>$null | Out-String).Trim() -$script:LatestRevision = (& $Git.Source -C $SourceRoot rev-parse '@{upstream}' 2>$null | Out-String).Trim() +$script:CurrentRevision = (Get-GitText @("-C", $SourceRoot, "rev-parse", "HEAD")) +$script:LatestRevision = (Get-GitText @("-C", $SourceRoot, "rev-parse", '@{upstream}')) if (-not $script:PreviousRevision) { $script:PreviousRevision = $script:CurrentRevision } if (-not $script:CurrentRevision -or -not $script:LatestRevision) { Fail-Update "inspect_failed" } -$Dirty = (& $Git.Source -C $SourceRoot status --porcelain 2>$null | Out-String).Trim() +$Dirty = (Get-GitText @("-C", $SourceRoot, "status", "--porcelain")) if ($Dirty) { Fail-Update "dirty_checkout" } @@ -135,7 +154,7 @@ if ($script:CurrentRevision -ne $script:LatestRevision) { if ($LASTEXITCODE -ne 0) { Fail-Update "diverged_checkout" } - $script:CurrentRevision = (& $Git.Source -C $SourceRoot rev-parse HEAD 2>$null | Out-String).Trim() + $script:CurrentRevision = (Get-GitText @("-C", $SourceRoot, "rev-parse", "HEAD")) } Write-UpdateStatus "installing" "" $script:CurrentRevision $script:LatestRevision $script:PreviousRevision try { diff --git a/tests/contracts/test_windows_packaging.py b/tests/contracts/test_windows_packaging.py index 0df691dd..668df774 100644 --- a/tests/contracts/test_windows_packaging.py +++ b/tests/contracts/test_windows_packaging.py @@ -181,13 +181,19 @@ def test_installer_git_probes_cannot_abort_on_a_detached_or_untracked_checkout(s # Under ErrorActionPreference=Stop, Windows PowerShell 5.1 turns a native # command's stderr into a terminating error even when redirected, so a # detached HEAD or missing upstream must not reach a bare git probe. - script = (ROOT / "scripts" / "install-windows.ps1").read_text(encoding="utf-8") - helper = script[script.index("function Get-GitText"):] - helper = helper[:helper.index("\n}\n") + 3] - self.assertIn('$ErrorActionPreference = "Continue"', helper) - self.assertIn("$ErrorActionPreference = $PreviousPreference", helper) - self.assertNotRegex(script, r"\(& \$Git\.Source [^\n]*2>\$null \| Out-String\)") - self.assertIn("$SourceUpstream = (Get-GitText @(", script) + expected_probe = { + "scripts/install-windows.ps1": "$SourceUpstream = (Get-GitText @(", + "scripts/update-windows.ps1": "$Upstream = (Get-GitText @(", + } + for relative_path, probe in expected_probe.items(): + with self.subTest(script=relative_path): + script = (ROOT / relative_path).read_text(encoding="utf-8") + helper = script[script.index("function Get-GitText"):] + helper = helper[:helper.index("\n}\n") + 3] + self.assertIn('$ErrorActionPreference = "Continue"', helper) + self.assertIn("$ErrorActionPreference = $PreviousPreference", helper) + self.assertNotRegex(script, r"\(& \$Git\.Source [^\n]*2>\$null \| Out-String\)") + self.assertIn(probe, script) def test_windows_scripts_accept_only_one_git_executable(self): for relative_path in WINDOWS_GIT_CONSUMERS: From a217eb5adf9a7d17e9de910a838bd0d4a0301021 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 16 Oct 2026 14:57:50 +0530 Subject: [PATCH 4/6] test(update): expect the Get-GitText branch probe in update-windows.ps1 Co-Authored-By: Claude Code --- tests/test_meter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_meter.py b/tests/test_meter.py index 2d203173..2a650892 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -16055,7 +16055,7 @@ def test_update_entrypoint_dispatches_linux_to_linux_helper(self): def test_windows_update_helper_requires_main_and_supports_failed_retry(self): root = Path(__file__).resolve().parents[1] script = (root / "scripts" / "update-windows.ps1").read_text() - self.assertIn('$Branch = (& $Git.Source -C $SourceRoot rev-parse --abbrev-ref HEAD', script) + self.assertIn('$Branch = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--abbrev-ref", "HEAD"))', script) self.assertIn('$Branch -ne "main"', script) self.assertIn('$env:TOKEN_METER_UPDATE_RETRY_REVISION', script) self.assertIn('failed_revision =', script) From 1dacc51e003770db7cac3ccc5a1b3bd1aa18453f Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 16 Oct 2026 15:04:29 +0530 Subject: [PATCH 5/6] fix(install): route every discarded-stderr probe through one helper Review found two more Windows PowerShell 5.1 abort paths: the dirty-tree `git status` probe and the Git delivery bootstrap's Python call, both of which redirected stderr outside the helper. Get-GitText now wraps a general Invoke-NativeText, and both calls use it. The contract test rejects any git or Python call with discarded stderr outside the helper. CI: wait for inventory_ready before counting indexed sessions, finish every run on main (cancel only superseded pull-request runs), and pin actions to the commits of the tested v4/v5 tags. Co-Authored-By: Claude Code --- .github/workflows/install-checks.yml | 40 ++++++++++++++++------- scripts/install-windows.ps1 | 24 ++++++++------ tests/contracts/test_windows_packaging.py | 11 +++++-- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/.github/workflows/install-checks.yml b/.github/workflows/install-checks.yml index d8abe2d6..41793003 100644 --- a/.github/workflows/install-checks.yml +++ b/.github/workflows/install-checks.yml @@ -13,9 +13,10 @@ on: permissions: contents: read +# Superseded pull-request runs are cancelled; every commit on main finishes. concurrency: group: install-checks-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: linux: @@ -25,7 +26,7 @@ jobs: env: TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 @@ -56,14 +57,23 @@ jobs: runtime="${XDG_DATA_HOME:-$HOME/.local/share}/token-meter/runtime" systemctl --user is-active --quiet token-meter-server.service test "$(cat "$runtime/INSTALL_MODE")" = "backend-only" - curl -fsS --max-time 10 http://127.0.0.1:8722/health | python3 -c ' - import json, sys - health = json.load(sys.stdin) + # The installer waits for state_ready; the session inventory can + # finish a moment later, so allow it up to a minute. + python3 - <<'PY' + import json, time, urllib.request + deadline = time.monotonic() + 60 + while True: + with urllib.request.urlopen("http://127.0.0.1:8722/health", timeout=10) as response: + health = json.load(response) + if health.get("inventory_ready") or time.monotonic() > deadline: + break + time.sleep(1) assert health.get("ok") and health.get("state_ready"), health - assert health.get("sources", 0) >= 1, health + assert health.get("inventory_ready"), health + assert (health.get("sources") or 0) >= 1, health assert not health.get("runtime_adapter_failures"), health print("health ok:", health["sources"], "session(s) indexed") - ' + PY curl -fsS --max-time 10 http://127.0.0.1:8722/menubar | python3 -c 'import json, sys; json.load(sys.stdin); print("menubar ok")' - name: Uninstall @@ -89,11 +99,11 @@ jobs: runs-on: windows-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" @@ -111,9 +121,15 @@ jobs: shell: powershell run: | $Runtime = Join-Path $env:LOCALAPPDATA "Token Meter\runtime" - $Health = Invoke-RestMethod -Uri "http://127.0.0.1:8722/health" -TimeoutSec 10 - if (-not $Health.ok -or -not $Health.state_ready) { throw "server is not ready: $($Health | ConvertTo-Json -Compress)" } - if ($Health.sources -lt 1) { throw "no sessions were indexed: $($Health | ConvertTo-Json -Compress)" } + # The session inventory can finish a moment after state_ready. + $Deadline = (Get-Date).AddSeconds(60) + do { + $Health = Invoke-RestMethod -Uri "http://127.0.0.1:8722/health" -TimeoutSec 10 + if ($Health.inventory_ready) { break } + Start-Sleep -Seconds 1 + } while ((Get-Date) -lt $Deadline) + if (-not $Health.ok -or -not $Health.state_ready -or -not $Health.inventory_ready) { throw "server is not ready: $($Health | ConvertTo-Json -Compress)" } + if ([int]$Health.sources -lt 1) { throw "no sessions were indexed: $($Health | ConvertTo-Json -Compress)" } if (@($Health.runtime_adapter_failures).Count -gt 0) { throw "runtime adapters failed: $($Health | ConvertTo-Json -Compress)" } Write-Host "health ok: $($Health.sources) session(s) indexed" $null = Invoke-RestMethod -Uri "http://127.0.0.1:8722/menubar" -TimeoutSec 10 diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index b5b0538b..3af2c1f5 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -13,13 +13,14 @@ function Fail([string]$Message) { } # Windows PowerShell 5.1 turns a native command's stderr into a terminating -# error under ErrorActionPreference=Stop, even when it is redirected, so git -# probes that may legitimately fail (detached HEAD, no upstream) run here. -function Get-GitText([string[]]$Arguments) { +# error under ErrorActionPreference=Stop, even when it is redirected, so native +# probes whose stderr is discarded (git on a detached HEAD or without an +# upstream, Python warnings) run here. Failure returns "". +function Invoke-NativeText([string]$Command, [string[]]$Arguments) { $PreviousPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { - $Output = @(& $Git.Source @Arguments 2>$null) + $Output = @(& $Command @Arguments 2>$null) $ExitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $PreviousPreference @@ -30,6 +31,10 @@ function Get-GitText([string[]]$Arguments) { return (($Output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine).Trim() } +function Get-GitText([string[]]$Arguments) { + return Invoke-NativeText $Git.Source $Arguments +} + function Write-Utf8File([string]$Path, [string]$Value) { [System.IO.File]::WriteAllText( $Path, @@ -513,7 +518,7 @@ Write-Utf8File (Join-Path $StagingRoot "INSTALL_MODE") ($InstallMode + [Environm $Commit = (Get-GitText @("-C", $SourceRoot, "rev-parse", "--short", "HEAD")) if ($Commit) { $DirtyPaths = @( - & $Git.Source -C $SourceRoot status --short --untracked-files=all 2>$null | + (Get-GitText @("-C", $SourceRoot, "status", "--short", "--untracked-files=all")) -split "\r?\n" | Where-Object { $_ -and $_ -notmatch '^\?\? plan\.md$' } ) if ($DirtyPaths.Count -gt 0) { @@ -545,11 +550,10 @@ try { $DeliveryBootstrapJson = "" Push-Location $InstallRoot try { - $DeliveryBootstrapJson = ( - & $PythonExe -X utf8 -c ` - 'import json; from token_meter.app import bootstrap_git_delivery; print(json.dumps(bootstrap_git_delivery()))' ` - 2>$null | Out-String - ).Trim() + $DeliveryBootstrapJson = Invoke-NativeText $PythonExe @( + "-X", "utf8", "-c", + 'import json; from token_meter.app import bootstrap_git_delivery; print(json.dumps(bootstrap_git_delivery()))' + ) } finally { Pop-Location } diff --git a/tests/contracts/test_windows_packaging.py b/tests/contracts/test_windows_packaging.py index 668df774..cd71fa33 100644 --- a/tests/contracts/test_windows_packaging.py +++ b/tests/contracts/test_windows_packaging.py @@ -188,12 +188,19 @@ def test_installer_git_probes_cannot_abort_on_a_detached_or_untracked_checkout(s for relative_path, probe in expected_probe.items(): with self.subTest(script=relative_path): script = (ROOT / relative_path).read_text(encoding="utf-8") - helper = script[script.index("function Get-GitText"):] + start = script.index( + "function Invoke-NativeText" if "Invoke-NativeText" in script else "function Get-GitText" + ) + helper = script[start:] helper = helper[:helper.index("\n}\n") + 3] self.assertIn('$ErrorActionPreference = "Continue"', helper) self.assertIn("$ErrorActionPreference = $PreviousPreference", helper) - self.assertNotRegex(script, r"\(& \$Git\.Source [^\n]*2>\$null \| Out-String\)") self.assertIn(probe, script) + # Discarded stderr outside the helpers is what aborts PowerShell 5.1. + outside = script.replace(helper, "") + for line in outside.splitlines(): + if "2>$null" in line and ("$Git.Source" in line or "$PythonExe" in line): + self.fail(f"{relative_path}: native probe outside the helper: {line.strip()}") def test_windows_scripts_accept_only_one_git_executable(self): for relative_path in WINDOWS_GIT_CONSUMERS: From 8e805639fed86616022feb198ea2486148efc8f9 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Fri, 16 Oct 2026 15:11:45 +0530 Subject: [PATCH 6/6] test(windows): reject any discarded stderr outside the helpers The contract only caught single-line git or Python probes, so a multi-line `& $PythonExe ... 2>$null` slipped through. It now removes the helper and the two try/catch-guarded Python discovery probes, then rejects any other `2>$null`. CI concurrency keys push runs by commit so every main commit is checked to completion, as the comment promises. Co-Authored-By: Claude Code --- .github/workflows/install-checks.yml | 5 +++-- tests/contracts/test_windows_packaging.py | 13 +++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/install-checks.yml b/.github/workflows/install-checks.yml index 41793003..6fdeb68a 100644 --- a/.github/workflows/install-checks.yml +++ b/.github/workflows/install-checks.yml @@ -13,9 +13,10 @@ on: permissions: contents: read -# Superseded pull-request runs are cancelled; every commit on main finishes. +# Superseded pull-request runs are cancelled. Every other run gets its own +# group (keyed by commit), so each commit on main is checked to completion. concurrency: - group: install-checks-${{ github.ref }} + group: install-checks-${{ github.event_name == 'pull_request' && github.ref || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: diff --git a/tests/contracts/test_windows_packaging.py b/tests/contracts/test_windows_packaging.py index cd71fa33..ca4d26b8 100644 --- a/tests/contracts/test_windows_packaging.py +++ b/tests/contracts/test_windows_packaging.py @@ -196,11 +196,16 @@ def test_installer_git_probes_cannot_abort_on_a_detached_or_untracked_checkout(s self.assertIn('$ErrorActionPreference = "Continue"', helper) self.assertIn("$ErrorActionPreference = $PreviousPreference", helper) self.assertIn(probe, script) - # Discarded stderr outside the helpers is what aborts PowerShell 5.1. + # Discarded stderr is what aborts PowerShell 5.1, so it may only + # appear in the helper or inside probes that catch the error. outside = script.replace(helper, "") - for line in outside.splitlines(): - if "2>$null" in line and ("$Git.Source" in line or "$PythonExe" in line): - self.fail(f"{relative_path}: native probe outside the helper: {line.strip()}") + for guarded in ("function Test-Python", "function Get-PythonFromLauncher"): + if guarded in outside: + body = outside[outside.index(guarded):] + body = body[:body.index("\n}\n") + 3] + self.assertIn("} catch {", body) + outside = outside.replace(body, "") + self.assertNotIn("2>$null", outside, f"{relative_path} discards stderr outside the helper") def test_windows_scripts_accept_only_one_git_executable(self): for relative_path in WINDOWS_GIT_CONSUMERS: