Install where Claude Code actually looks, and for people who use bash #51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: verify | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| jobs: | |
| verify: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # The gate needs jq for the JSON checks and the install dry run. plutil does not exist | |
| # on Linux, so the launchd check skips itself and says so. | |
| - name: Install jq | |
| run: sudo apt-get update -qq && sudo apt-get install -y -qq jq | |
| # Check 19 runs `claude plugin validate --strict`; without a working CLI it skips, and the | |
| # falsifiability suite skips its case 19 with it. Install it so CI proves the real thing. | |
| # | |
| # `npm install -g` alone was not enough and produced a false green for as long as the step | |
| # has existed. The npm package is a shim that fetches a platform-native binary in its | |
| # postinstall; that did not land here, so every `claude plugin validate` exited non-zero | |
| # with "claude native binary not installed" — and check 19's old output parsing read that | |
| # error as no findings and printed ok. CI has never once validated these manifests. | |
| # | |
| # So install it, then prove the binary answers before trusting the step. `claude | |
| # --version` failing here is a setup failure and should stop the run, not be discovered | |
| # later as a check that silently measures nothing. | |
| - name: Install Claude Code CLI | |
| run: | | |
| npm install -g @anthropic-ai/claude-code || true | |
| root="$(npm root -g)/@anthropic-ai/claude-code" | |
| node "$root/install.cjs" || true | |
| # The native package resolves as an optional dependency and does not always arrive on | |
| # a runner. The package ships a documented pure-node fallback for exactly this, so | |
| # prefer it over failing the job: put a shim ahead of the shim. | |
| if ! claude --version >/dev/null 2>&1 && [ -f "$root/cli-wrapper.cjs" ]; then | |
| printf '#!/bin/sh\nexec node "%s/cli-wrapper.cjs" "$@"\n' "$root" | sudo tee /usr/local/bin/claude >/dev/null | |
| sudo chmod +x /usr/local/bin/claude | |
| fi | |
| # Report, do not fail. Check 19 runs its own control and skips with a reason when the | |
| # validator is unusable, so an unavailable CLI degrades to a visible skip in the gate | |
| # output rather than a red run over something this repo does not control. | |
| claude --version || echo "::warning::claude CLI unusable on this runner; check 19 will skip" | |
| - name: Run the verification gate | |
| run: ./.claude/verify.sh | |
| # A gate that cannot fail proves nothing. This used to break one skill description and | |
| # call it done, which left the other checks unproven — and two of them were not enforcing | |
| # anything at the time. Every declared check now gets its own mutation, and check 16 of | |
| # the gate fails if a check is added without one. | |
| - name: Prove every check is falsifiable | |
| run: ./tests/gate-falsifiability.sh | |
| - name: Confirm the tree is restored | |
| run: git diff --exit-code | |
| # Everything above runs the gate. This installs for real, on Linux, into a disposable HOME. | |
| # It exists because the worst bug this repo has shipped was Linux-only and invisible on the | |
| # machine it was written on: hooks called /usr/bin/jq by absolute path, which is a macOS | |
| # path, so off macOS the verify gate silently stopped blocking and the session hook injected | |
| # nothing at all. A dry run cannot catch that. Only installing and firing the hooks can. | |
| install-linux: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Install jq | |
| run: sudo apt-get update -qq && sudo apt-get install -y -qq jq | |
| # Runs install.sh into eight throwaway HOMEs and asserts the resulting tree each time: | |
| # a plain install, one with CLAUDE_CONFIG_DIR pointed elsewhere, one whose home path | |
| # contains a space, one with jq removed from PATH, a bash user, a second run for | |
| # idempotency, and the two uninstall paths. This runner is the environment the matrix | |
| # exists for — bash by default, no Homebrew, the shape of a cloud VM or a container. | |
| - name: Install matrix (config dir, bash, spaces, no jq, idempotency, uninstall) | |
| run: ./tests/install-matrix.sh | |
| - name: install.sh, for real | |
| run: ./install.sh | |
| - name: The installed tree matches the repo | |
| run: ./bin/doctor --drift | |
| # Expected counts are derived from the repo at run time, not hardcoded — a literal here | |
| # went stale the first time a skill was added and failed a green tree. | |
| - name: Everything landed | |
| run: | | |
| set -e | |
| count() { ls -d $1 2>/dev/null | wc -l | tr -d ' '; } | |
| for spec in "$HOME/.claude/skills/*/:claude/skills/*/" \ | |
| "$HOME/.claude/agents/*.md:claude/agents/*.md" \ | |
| "$HOME/.claude/commands/*.md:claude/commands/*.md" \ | |
| "$HOME/.claude/hooks/*.sh:claude/hooks/*.sh"; do | |
| pat="${spec%:*}"; src="${spec##*:}" | |
| got=$(count "$pat"); want=$(count "$src") | |
| [ "$got" = "$want" ] && [ "$want" != 0 ] || { echo "$pat: got $got, repo has $want"; exit 1; } | |
| echo "ok $pat -> $got (repo: $want)" | |
| done | |
| test -x "$HOME/.claude/hooks/verify-gate.sh" | |
| - name: The Stop gate blocks on Linux, with and without jq on PATH | |
| run: | | |
| set -e | |
| mkdir -p /tmp/g/repo/.claude /tmp/g/home/.config/agents /tmp/g/tmp /tmp/g/bin | |
| printf '#!/usr/bin/env bash\necho "seeded failure"\nexit 1\n' > /tmp/g/repo/.claude/verify.sh | |
| chmod +x /tmp/g/repo/.claude/verify.sh | |
| printf '%s %s\n' "$(sha256sum /tmp/g/repo/.claude/verify.sh | cut -d' ' -f1)" \ | |
| /tmp/g/repo/.claude/verify.sh > /tmp/g/home/.config/agents/verify-trust | |
| for t in bash sh cat cut grep sed awk tr rm mkdir env dirname basename sha256sum; do | |
| ln -sf "$(command -v $t)" /tmp/g/bin/$t | |
| done | |
| out=$(printf '{"session_id":"ci"}' | env HOME=/tmp/g/home TMPDIR=/tmp/g/tmp \ | |
| CLAUDE_PROJECT_DIR=/tmp/g/repo bash claude/hooks/verify-gate.sh) | |
| echo "$out" | jq -e '.decision=="block"' >/dev/null || { echo "did not block with jq"; exit 1; } | |
| echo "ok blocks with jq" | |
| sed 's#/usr/bin/jq#/nonexistent/jq#' claude/hooks/verify-gate.sh > /tmp/g/nojq.sh | |
| out=$(printf '{"session_id":"ci2"}' | env PATH=/tmp/g/bin HOME=/tmp/g/home \ | |
| TMPDIR=/tmp/g/tmp CLAUDE_PROJECT_DIR=/tmp/g/repo bash /tmp/g/nojq.sh) | |
| echo "$out" | jq -e '.decision=="block"' >/dev/null || { echo "did not block without jq"; exit 1; } | |
| echo "ok blocks with no jq reachable" | |
| - name: The session hook injects the routing block on Linux | |
| run: | | |
| set -e | |
| n=$(printf '{"hook_event_name":"SessionStart"}' | bash claude/hooks/inject-session-context.sh | wc -c) | |
| [ "$n" -gt 2000 ] || { echo "session hook injected only $n bytes"; exit 1; } | |
| echo "ok session hook injected $n bytes" | |
| - name: uninstall.sh --dry-run | |
| run: ./uninstall.sh --dry-run |