CI #239
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: 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 }} |