Skip to content

CI

CI #239

Workflow file for this run

name: CI
# Runs on pushes to main and on every PR. `push:` is filtered to main on
# purpose: unfiltered, a same-repo PR branch fires BOTH push and pull_request,
# running all three jobs twice per commit. The README badge is unaffected — it
# pins neither branch nor event, so it resolves the latest run on the default
# branch, which pushes to main still produce. The badge does resolve against
# this exact file name (.github/workflows/ci.yml), so do not rename it.
on:
push:
branches: [main]
pull_request:
# Daily sweep purely for the audit job below. `npm audit` queries the registry
# advisory DB live, so a newly published advisory is invisible until something
# runs — without this, a quiet week leaves main silently unaudited.
schedule:
- cron: "17 6 * * *"
# Least privilege by default: jobs get a read-only GITHUB_TOKEN. Jobs that need
# more (codeql re-declares security-events + actions below) override this.
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }}, node ${{ matrix.node-version }})
# The schedule trigger exists only to re-run `audit` against a moving
# advisory DB; the code is unchanged between crons, so skip the rest.
if: github.event_name != 'schedule'
runs-on: ${{ matrix.os }}
# Node versions fan out on Linux only; Windows and macOS get one pinned
# version each via `include`. A full 3x3 cross-product would be nine jobs to
# re-answer a question the Linux column already answers — what these two add
# is the PLATFORM, not another Node.
#
# They are here because the code has real per-platform branches (win32 paths
# in skills/install.mjs, path separators through the resolver, git behaviour
# differences) and the docs target Windows, yet every one of the 477 tests
# had only ever run on Linux. macOS is the maintainer's own platform and is
# exercised locally; Windows genuinely was not covered anywhere.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node-version: [20, 22, 24]
include:
- os: windows-latest
node-version: 22
- os: macos-latest
node-version: 22
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
# Deterministic install from the lockfile (ts-morph is the only dep).
- name: Install dependencies
run: npm ci
# checkJs over ~250KB of hand-written JS — the only static analysis that
# reads this codebase, since none of it is TypeScript. Deliberately runs on
# every OS: the two errors it caught when introduced were a stale JSDoc type
# and an unfollowable dynamic import, and path-shaped mistakes are exactly
# the kind that differ per platform.
- name: Typecheck
run: npm run typecheck
# Black-box suite — drives the real CLI as a subprocess against temp repos.
# Runs via npm so CI uses the same glob set as `npm test` locally
# (test/*.test.mjs alone silently skipped the test/vue-sfc/ suite).
- name: Run tests
run: npm test
# Smoke: the binary builds + prints a hub map against agentmap's own repo.
- name: Smoke (--hubs on self)
run: node agentmap.mjs --hubs
# Validate the published file set without actually packing.
- name: Validate pack manifest
run: npm pack --dry-run
# The shipped command, exercised the way users actually invoke it.
#
# This job exists because 356 tests and a green CI said nothing about the bug
# that broke 12 consecutive releases (0.10.0-0.16.0): npm links the bin as
# node_modules/.bin/agentmap -> ../@raymondchins/agentmap/agentmap.mjs, so
# argv[1] is the SYMLINK and import.meta.url is the TARGET. The entry guard
# compared them as strings, read "imported", skipped main(), and the CLI exited
# 0 having printed nothing. Every test invoked `node <abs path>/agentmap.mjs`,
# the one form that happened to work, and the job above never touched the bin —
# `npm pack --dry-run` executes nothing at all.
#
# So: pack, install from the tarball, and drive the real binary. Exit status is
# NOT sufficient evidence here — exit 0 with empty stdout was the bug's exact
# signature, so every step below asserts on OUTPUT.
#
# Deliberately Linux-only, unlike the `test` job above. Every step here is a
# bash script with `set -euo pipefail`, absolute /tmp paths and $GITHUB_ENV
# export syntax; on windows-latest the default shell is PowerShell, so porting
# this means `shell: bash` plus rewriting the paths, and a half-ported version
# that silently skips a step is worse than an honest gap. The Windows install
# path is therefore NOT covered — recorded here rather than left to be inferred
# from the matrix.
install-smoke:
name: install smoke (node ${{ matrix.node-version }})
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [20, 22, 24]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Pack the tarball
run: echo "TARBALL=$PWD/$(npm pack --silent)" >> "$GITHUB_ENV"
# A scratch consumer project, so the bin resolves through a real
# node_modules/.bin symlink rather than the checkout.
- name: Install into a scratch project
run: |
set -euo pipefail
mkdir -p /tmp/consumer/src && cd /tmp/consumer
git init -q .
git config user.email ci@example.com && git config user.name ci
printf 'export const a = 1;\n' > src/a.ts
printf "import { a } from './a';\nexport const b = a + 1;\n" > src/b.ts
npm init -y >/dev/null
npm i -D "$TARBALL" >/dev/null
git add -A && git commit -qm init
- name: bin symlink prints a version
run: |
set -euo pipefail
cd /tmp/consumer
OUT="$(./node_modules/.bin/agentmap --version)"
echo "got: [$OUT]"
test -n "$OUT" || { echo "::error::bin symlink printed NOTHING (entry guard skipped main())"; exit 1; }
- name: npx runs the CLI
run: |
set -euo pipefail
cd /tmp/consumer
OUT="$(npx --no-install agentmap --hubs)"
echo "$OUT"
echo "$OUT" | grep -qi hubs || { echo "::error::npx produced no map"; exit 1; }
- name: global install exposes a working binary
run: |
set -euo pipefail
npm i -g "$TARBALL" >/dev/null
cd /tmp/consumer
OUT="$(agentmap --any a)"
echo "$OUT"
test -n "$OUT" || { echo "::error::globally installed agentmap printed nothing"; exit 1; }
- name: --mcp answers the MCP Registry launch contract
run: |
set -euo pipefail
cd /tmp/consumer
REQ='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}'
OUT="$(printf '%s\n' "$REQ" | npx --no-install agentmap --mcp)"
echo "$OUT"
echo "$OUT" | grep -q '"serverInfo"' || { echo "::error::--mcp returned no JSON-RPC response (server.json tells registry clients to run exactly this)"; exit 1; }
# The gate the obvious test plan misses, and the one that would have caught
# the dead auto-refresh: --install-hooks reporting success is worthless
# unless a real commit measurably rewrites the map.
- name: --install-hooks survives a real commit
run: |
set -euo pipefail
cd /tmp/consumer
agentmap --install-hooks
test -f .git/hooks/post-commit || { echo "::error::--install-hooks wrote no post-commit hook"; exit 1; }
agentmap --hubs >/dev/null
BEFORE="$(node -p "require('./.claude/agentmap/map.json').generatedSha")"
printf 'export const c = 3;\n' > src/c.ts
git add -A && git commit -qm "second"
for _ in $(seq 1 30); do
AFTER="$(node -p "require('./.claude/agentmap/map.json').generatedSha" 2>/dev/null || echo "$BEFORE")"
[ "$AFTER" != "$BEFORE" ] && break
sleep 1
done
echo "generatedSha $BEFORE -> $AFTER"
test "$AFTER" != "$BEFORE" || { echo "::error::post-commit hook did not refresh the map — auto-refresh is dead"; exit 1; }
# Same gate, one install shape deeper. `git rev-parse --git-dir` inside a
# linked worktree returns <main>/.git/worktrees/<name>, but git runs hooks
# from the COMMON dir — so --install-hooks used to write a hook git never
# executes while printing "Done" and --doctor reported "installed". Only an
# end-to-end commit catches that; a path assertion would have passed.
- name: --install-hooks works inside a git worktree
run: |
set -euo pipefail
cd /tmp/consumer
git worktree add -q /tmp/consumer-wt -b wt-ci
cd /tmp/consumer-wt
agentmap --install-hooks
test -f /tmp/consumer/.git/hooks/post-commit || { echo "::error::hook did not land in the common dir — git will never run it"; exit 1; }
# No commit needed here: the step above already committed the files
# --install-hooks creates, so this worktree branches from a clean tree.
# (An earlier version committed anyway and died on "nothing to commit"
# under `set -e`.)
agentmap --hubs >/dev/null
BEFORE="$(node -p "require('./.claude/agentmap/map.json').generatedSha")"
printf 'export const d = 4;\n' > src/d.ts
git add -A && git commit -qm "in worktree"
for _ in $(seq 1 30); do
AFTER="$(node -p "require('./.claude/agentmap/map.json').generatedSha" 2>/dev/null || echo "$BEFORE")"
[ "$AFTER" != "$BEFORE" ] && break
sleep 1
done
echo "worktree generatedSha $BEFORE -> $AFTER"
test "$AFTER" != "$BEFORE" || { echo "::error::auto-refresh is dead inside a git worktree"; exit 1; }
# Coverage floor, so a shipped file that nothing executes stays visible.
#
# Uses node --test's own coverage rather than c8: the thresholds land natively
# from Node 22, and the near-zero-deps rule is easier to keep than to argue with.
# That is also why this is its own job pinned to one version — the flags do not
# exist on Node 20, which the test matrix still supports.
#
# Floors sit BELOW the measured 93.77% lines / 75.92% branches on purpose. They
# are a regression alarm, not a target: a gate set at the current number reddens
# on ordinary work and gets raised until someone stops reading it.
coverage:
name: Coverage floor
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- name: Install dependencies
run: npm ci
- name: Run tests under coverage
run: npm run coverage
# Audit for high-severity vulnerabilities in the dependency tree. Deliberately
# its own job rather than a step in the matrix above: it depends only on the
# lockfile, so running it per Node version was three identical checks, and a
# failure there also skipped the pack-manifest step behind it.
audit:
name: Audit dependencies
# Not on pull_request. `npm audit` resolves advisories live, so a third-party
# advisory published mid-review reddens PRs that never touched dependencies —
# this fired twice in four days over a transitive brace-expansion pulled in by
# ts-morph, blocking unrelated work both times. Pushes to main and the daily
# cron still fail loudly, which is where the signal is actually actionable.
# Trade-off accepted: a PR that *introduces* a vulnerable dep is caught on
# merge rather than pre-merge.
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Audit dependencies
run: npm audit --audit-level=high
codeql:
name: CodeQL analysis
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: javascript-typescript
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
secret-scan:
name: Secret scan (Gitleaks)
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
env:
# v3 hard-requires the token to scan pull_request events (v2 made it
# optional). Personal repo — no GITLEAKS_LICENSE needed.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}