Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 198 additions & 3 deletions .github/workflows/install-checks.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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.
# Installs the backend on clean macOS, Linux, and Windows hosts, checks the
# running service, then uninstalls; on Linux it also runs a real update. Tray
# companions need a desktop session that hosted runners do not reliably
# provide, so they are not covered here.
on:
push:
branches: [main]
Expand Down Expand Up @@ -95,6 +96,200 @@ jobs:
if: failure()
run: journalctl --user -u token-meter-server.service -n 200 --no-pager || true

# Installs from a local clone whose origin is a local bare remote, publishes a
# newer commit there, then drives Install through the dashboard API. The
# installer restarts the server mid-update, so this proves the helper
# outlives that restart and the server comes back on the new revision.
linux-update:
name: Linux update
runs-on: ubuntu-latest
timeout-minutes: 25
env:
TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- 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: Publish this revision to a local remote
run: |
remote="$RUNNER_TEMP/update-remote.git"
source="$RUNNER_TEMP/update-source"
git init --quiet --bare --initial-branch=main "$remote"
git push --quiet "$remote" HEAD:refs/heads/main
git clone --quiet "$remote" "$source"
echo "UPDATE_SOURCE=$source" >> "$GITHUB_ENV"

- name: Add a sample session and turn off automatic installs
run: |
python3 .github/scripts/write_sample_trace.py
mkdir -p "$HOME/.token-meter"
printf '%s\n' '{"updates": {"enabled": true, "auto_install": false}}' \
> "$HOME/.token-meter/settings.json"

- name: Install
run: |
cd "$UPDATE_SOURCE"
./scripts/install --backend-only
systemctl --user show -p MainPID --value token-meter-server.service \
> "$RUNNER_TEMP/server-pid-before"

- name: Publish a newer revision
run: |
cd "$UPDATE_SOURCE"
git -c user.name="Token Meter CI" -c user.email="ci@token-meter.invalid" \
commit --quiet --allow-empty -m "ci: update bump"
git push --quiet origin main
echo "UPDATE_TARGET=$(git rev-parse HEAD)" >> "$GITHUB_ENV"

- name: Check for and install the update
run: |
python3 - <<'PY'
import json, os, time, urllib.request
base = "http://127.0.0.1:8722"
target = os.environ["UPDATE_TARGET"]

def get(path):
with urllib.request.urlopen(base + path, timeout=10) as response:
return json.load(response)

def post(path, token):
request = urllib.request.Request(
base + path, data=b"{}", method="POST",
headers={"Content-Type": "application/json", "X-Token-Meter-Action": token},
)
with urllib.request.urlopen(request, timeout=10) as response:
return response.status, json.load(response)

token = get("/updates/status")["actions"]["token"]
deadline = time.monotonic() + 180
# A check already running from server startup ignores a new request,
# so keep asking until one sees the published revision.
while True:
status = get("/updates/status")
if status.get("can_update") and target.startswith(status.get("latest_revision") or "-"):
break
assert time.monotonic() < deadline, status
if not status.get("checking"):
post("/updates/check", token)
time.sleep(2)
code, result = post("/updates/install", token)
assert code == 202 and result.get("ok"), (code, result)
print("update started for", target[:12])
PY

- name: Wait for the update to finish
run: |
status_file="$HOME/.token-meter/update-status.json"
deadline=$((SECONDS + 720))
while (( SECONDS < deadline )); do
phase="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("phase",""))' "$status_file" 2>/dev/null || true)"
case "$phase" in
complete) echo "update complete"; break ;;
failed) cat "$status_file"; exit 1 ;;
esac
sleep 2
done
test "$phase" = complete || { echo "update stuck in phase: $phase" >&2; cat "$status_file"; exit 1; }

- name: Check the updated installation
run: |
runtime="${XDG_DATA_HOME:-$HOME/.local/share}/token-meter/runtime"
installed="$(cat "$runtime/INSTALLED_REVISION")"
case "$UPDATE_TARGET" in
"$installed"*) echo "installed revision ok: $installed" ;;
*) echo "installed $installed, expected $UPDATE_TARGET" >&2; exit 1 ;;
esac
systemctl --user is-active --quiet token-meter-server.service
before="$(cat "$RUNNER_TEMP/server-pid-before")"
after="$(systemctl --user show -p MainPID --value token-meter-server.service)"
test "$before" != "$after" || { echo "the server was not restarted" >&2; exit 1; }
curl -fsS --max-time 10 http://127.0.0.1:8722/health | python3 -c 'import json, sys; h = json.load(sys.stdin); assert h.get("ok"), h; print("health ok")'

- name: Show update and server logs
if: failure()
run: |
cat "$HOME/.token-meter/update-status.json" || true
journalctl --user -u token-meter-update.service -n 200 --no-pager || true
journalctl --user -u token-meter-server.service -n 200 --no-pager || true

macos:
name: macOS install
runs-on: macos-latest
timeout-minutes: 20
env:
TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- 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="$HOME/Library/Application Support/Token Meter/runtime"
launchctl print "gui/$UID/com.token-meter.server" >/dev/null
test "$(cat "$runtime/INSTALL_MODE")" = "backend-only"
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("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
run: |
runtime="$HOME/Library/Application Support/Token Meter/runtime"
"$runtime/scripts/uninstall-launch-agent"
if launchctl print "gui/$UID/com.token-meter.server" >/dev/null 2>&1; then
echo "com.token-meter.server is still loaded" >&2
exit 1
fi
for _ in $(seq 1 10); do
curl -fsS --max-time 3 http://127.0.0.1:8722/health >/dev/null 2>&1 || { echo "uninstall ok"; exit 0; }
sleep 1
done
echo "the server still answers after uninstall" >&2
exit 1

- name: Show server logs
if: failure()
run: |
runtime="$HOME/Library/Application Support/Token Meter/runtime"
for name in meter.err.log meter.log; do
[[ -f "$runtime/$name" ]] && { echo "== $name"; tail -n 200 "$runtime/$name"; }
done
true

windows:
name: Windows install
runs-on: windows-latest
Expand Down
129 changes: 129 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Tests

# Runs the unit and contract suites on every supported host, so a change made
# for one platform is checked on the others, plus the static gates from
# specs/AGENTS.md and the native companion build.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: tests-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
unit:
name: Unit tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
env:
PYTHONUTF8: "1"
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

# Pull-request checkouts are detached; installer tests clone this
# repository and expect a local main branch, as a developer clone has.
- name: Name the checked-out commit main
run: git switch --quiet -C main

- name: Run the test suite
if: runner.os != 'Windows'
run: python -m unittest discover -s tests

# The full suite is not yet Windows-clean (SQLite handles held across
# temp-dir cleanup, mktime range limits). Run every module that is, so
# a regression in them still fails here.
- name: Run the Windows-clean test modules
if: runner.os == 'Windows'
run: >-
python -m unittest
tests.contracts.test_agent_workflow tests.contracts.test_architecture_guards
tests.contracts.test_compat tests.contracts.test_contracts tests.contracts.test_design_system
tests.contracts.test_kiro_extension_budget tests.contracts.test_model_pricing
tests.contracts.test_path_cache tests.contracts.test_platform_services
tests.contracts.test_public_projections tests.contracts.test_quota_privacy
tests.contracts.test_quota_registry tests.contracts.test_runtime_catalog
tests.contracts.test_runtime_manifest tests.contracts.test_runtime_registry
tests.contracts.test_telemetry_mapping tests.contracts.test_windows_packaging
tests.domain.test_aggregates tests.domain.test_compare tests.domain.test_matched_pace_parity
tests.domain.test_timing_tools_insights tests.domain.test_usage
tests.integration.test_application_composition tests.integration.test_discovery_performance
tests.integration.test_model_pricing_compat
tests.integration.test_runtime_dispatch_characterization
tests.integration.test_session_selection
tests.runtimes.test_claude_adapter tests.runtimes.test_codex_adapter
tests.runtimes.test_kiro_adapter tests.runtimes.test_pi_adapter
tests.test_claude_cost_correctness tests.test_codex_model_identity
tests.test_current_model_catalog tests.test_goals tests.test_installer_modes tests.test_linux_update
tests.test_mcp_queries tests.test_mcp_server tests.test_menubar_settings

static:
name: Static checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Compile Python
run: |
PYTHONPYCACHEPREFIX="$RUNNER_TEMP/pycache" python3 -m py_compile \
meter.py token_meter_mcp.py $(find token_meter -type f -name '*.py' -print)

- name: Parse the dashboard JavaScript
run: |
node -e "const fs=require('fs');const h=fs.readFileSync('page.html','utf8');const m=h.match(/<script>([\s\S]*)<\/script>/);new Function(m[1]);console.log('js ok')"

- name: Check shell syntax
run: |
for script in scripts/*; do
if head -n 1 "$script" | grep -q bash; then
bash -n "$script"
fi
done

- name: Reject whitespace errors
if: github.event_name == 'pull_request'
run: |
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
git diff --check FETCH_HEAD HEAD

native:
name: macOS companion build
runs-on: macos-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Compile the menu-bar companion
run: swiftc menubar/TokenMeterMenuBar.swift -o "$RUNNER_TEMP/token-meter-menubar"

# The smoke check reads the live /menubar payload, as it does after install.
- name: Start the local server
run: |
python3 .github/scripts/write_sample_trace.py
nohup python3 meter.py > "$RUNNER_TEMP/meter.log" 2>&1 &
for _ in $(seq 1 120); do
curl -fsS --max-time 3 http://127.0.0.1:8722/menubar >/dev/null 2>&1 && exit 0
sleep 1
done
cat "$RUNNER_TEMP/meter.log"
exit 1

- name: Run the smoke check
run: TOKEN_METER_MENUBAR_SMOKE=1 "$RUNNER_TEMP/token-meter-menubar"
Loading
Loading