diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81156e0..6a9e52e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,8 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - node: [20, 22] + # 20 is the engines floor; 24 is the active LTS and what publish.yml runs. + node: [20, 22, 24] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -42,3 +43,102 @@ jobs: cache: npm - run: npm ci - run: npm test + + # The test suite imports ./src straight from the checkout, so it cannot see a + # broken PUBLISHED package: a file left out of `files`, an import reaching + # outside src/, a bin that no longer resolves. This installs the tarball + # `npm pack` produces (the bytes npm publish would ship) into an empty project + # and drives it from there, on the engines floor. + package: + name: packed tarball (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + env: + CANON_NO_KEYCHAIN: '1' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + - name: Pack and install into an empty project + run: | + set -euo pipefail + # Git Bash on Windows: work in /d/a/_temp form, not D:\a\_temp. + if command -v cygpath >/dev/null 2>&1; then RUNNER_TEMP="$(cygpath -u "$RUNNER_TEMP")"; fi + consumer="$RUNNER_TEMP/consumer" + mkdir -p "$consumer" + tarball="$(npm pack --silent --pack-destination "$consumer")" + echo "packed: ${tarball}" + cd "$consumer" + # Bare name, after the cd: GNU tar reads any `X:` prefix as host:path. + tar -tzf "$tarball" | sed 's/^/ /' + npm init -y >/dev/null + npm install --no-fund --no-audit "./${tarball}" + - name: Drive the installed CLI, MCP server and library + run: | + set -euo pipefail + # Git Bash on Windows: work in /d/a/_temp form, not D:\a\_temp. + if command -v cygpath >/dev/null 2>&1; then RUNNER_TEMP="$(cygpath -u "$RUNNER_TEMP")"; fi + bin="$RUNNER_TEMP/consumer/node_modules/.bin" + want="$(node -p "require('./package.json').version")" + got="$("$bin/truecopy" --version)" + [ "$got" = "$want" ] || { echo "::error::installed truecopy --version is '$got', package.json is '$want'"; exit 1; } + + "$bin/truecopy" scan demo/clean-mcp.json + status=0; "$bin/truecopy" scan demo/poisoned-mcp.json || status=$? + [ "$status" -eq 1 ] || { echo "::error::the poisoned demo manifest must exit 1 from the packed CLI (got $status)"; exit 1; } + "$bin/truecopy" verify # this repo's own lock, verified by the shipped build + "$bin/canon" --version # legacy alias still resolves + + # truecopy-mcp standalone: a real stdio handshake must list its own tools. + printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + | timeout 60 "$bin/truecopy-mcp" --lock truecopy.lock > "$RUNNER_TEMP/mcp.out" || true + node -e ' + const msgs = require("fs").readFileSync(process.argv[1], "utf8").split("\n") + .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); + const list = msgs.find((m) => m.id === 2 && m.result && Array.isArray(m.result.tools)); + if (!list) { console.error("packed truecopy-mcp answered no tools/list"); process.exit(1); } + console.log("truecopy-mcp tools:", list.result.tools.map((t) => t.name).join(", ")); + ' "$RUNNER_TEMP/mcp.out" + + # The library entry point resolves from the installed package, not ./src. + cd "$RUNNER_TEMP/consumer" + node --input-type=module -e ' + const m = await import("@askalf/truecopy"); + for (const fn of ["scan", "pin", "verify", "scanSkill"]) if (typeof m[fn] !== "function") { console.error("missing export: " + fn); process.exit(1); } + console.log(Object.keys(m).length + " exports"); + ' + + # Lints every workflow (expression types, contexts, permissions, runner + # labels) and, through the shellcheck already on ubuntu-latest, every `run:` + # script. The binary is pinned by version AND its release sha256. + actionlint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: actionlint + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + run: | + set -euo pipefail + tgz="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${tgz}" + echo "${ACTIONLINT_SHA256} $RUNNER_TEMP/$tgz" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/$tgz" -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" -color diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e1ec6bc..6ff535f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,7 +31,9 @@ jobs: strategy: fail-fast: false matrix: - language: [javascript-typescript] + # `actions` scans the workflows themselves: expression injection, untrusted + # checkouts under pull_request_target, over-broad token permissions. + language: [javascript-typescript, actions] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 98dbce3..96fa88b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,6 +15,8 @@ on: - 'Dockerfile' - 'Dockerfile.dockerignore' - 'docker/**' + - 'truecopy.lock' # COPY'd into the image; the smoke test verifies it + - 'demo/clean-mcp.json' - '.github/workflows/docker.yml' push: branches: [master] @@ -22,6 +24,8 @@ on: - 'Dockerfile' - 'Dockerfile.dockerignore' - 'docker/**' + - 'truecopy.lock' # COPY'd into the image; the smoke test verifies it + - 'demo/clean-mcp.json' - '.github/workflows/docker.yml' workflow_dispatch: {} diff --git a/.github/workflows/marketplace-watch.yml b/.github/workflows/marketplace-watch.yml index 836dde3..fc5752c 100644 --- a/.github/workflows/marketplace-watch.yml +++ b/.github/workflows/marketplace-watch.yml @@ -15,10 +15,30 @@ on: schedule: - cron: '17 6 * * *' # daily 06:17 UTC (staggered off the :00 grid) workflow_dispatch: {} + # Re-scan as soon as an acceptance or a detector change lands. Without this, a + # merged triage PR left the badge and WATCH.md red until the next daily run + # (2026-09-24: #207 merged 40 minutes after the run that flagged its skill). + push: + branches: [master] + paths: + - 'support/watch-accepted.json' + - 'support/marketplace-*.mjs' + - 'support/watch-issues.mjs' + - 'support/evidence.mjs' + - 'support/offset-map.mjs' + - 'src/**' + - 'package-lock.json' # a redstamp bump changes detection + - '.github/workflows/marketplace-watch.yml' permissions: contents: read +# Every run force-pushes the watch branch and files/closes issues, so two must +# never overlap. Queue instead of cancelling: a half-run watch publishes nothing. +concurrency: + group: marketplace-watch + cancel-in-progress: false + jobs: watch: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5c5fced..86af943 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,3 +84,32 @@ jobs: - name: npm publish if: steps.gate.outputs.skip != 'true' run: npm publish --access public + + # A green publish step proves npm accepted the upload, not that users can + # install it. Install the exact version back from the registry into an + # empty project, check it runs and reports that version, and let + # `npm audit signatures` check the registry signature and the provenance + # attestation OIDC publishing is supposed to attach. + - name: Verify the published package + if: steps.gate.outputs.skip != 'true' + env: + CANON_NO_KEYCHAIN: '1' + run: | + set -euo pipefail + v=$(node -p "require('./package.json').version") + for i in $(seq 1 12); do + if npm view "@askalf/truecopy@${v}" version >/dev/null 2>&1; then break; fi + echo "waiting for @askalf/truecopy@${v} to appear on the registry (${i}/12)…" + sleep 10 + done + consumer="$RUNNER_TEMP/consumer" + mkdir -p "$consumer" + cp demo/poisoned-mcp.json "$consumer/" + cd "$consumer" + npm init -y >/dev/null + npm install --no-fund --no-audit "@askalf/truecopy@${v}" + got="$(npx --no-install truecopy --version)" + [ "$got" = "$v" ] || { echo "::error::registry @askalf/truecopy@${v} reports version '$got'"; exit 1; } + status=0; npx --no-install truecopy scan poisoned-mcp.json || status=$? + [ "$status" -eq 1 ] || { echo "::error::published build did not flag the poisoned demo manifest (exit $status)"; exit 1; } + npm audit signatures diff --git a/.github/workflows/truecopy-gate.yml b/.github/workflows/truecopy-gate.yml index f10660e..6861032 100644 --- a/.github/workflows/truecopy-gate.yml +++ b/.github/workflows/truecopy-gate.yml @@ -26,3 +26,7 @@ jobs: node-version: 20 - name: truecopy verify uses: askalf/truecopy-action@3983d2503f60cbca040f20f0be82347ce6f41f5e # v1.1.0 + with: + # Check the release tarball's Sigstore provenance before installing it: + # the gate should hold itself to what it asks of every skill it pins. + verify-attestation: 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f351c..12a8ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- **`truecopy --version` prints the version.** It fell through to the usage + text and exited 2, although the bug and false-positive issue templates ask + reporters for its output. `--version`, `-v` and `version` now print the + package version and exit 0. + ## [0.10.4] - 2026-09-25 ### Fixed diff --git a/src/cli.mjs b/src/cli.mjs index 0e4282c..e33a111 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -499,6 +499,9 @@ function runHook() { return 2; } +// The issue templates ask reporters for `truecopy --version`, so it must print the version, not usage. +if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(PKG_VERSION || 'unknown'); process.exit(0); } + const table = { scan: runScan, add: runAdd, remove: runRemove, unpin: runRemove, verify: runVerify, diff: runDiff, list: runList, 'check-manifest': runCheckManifest, guard: runGuard, key: runKey, trust: runTrust, hook: runHook }; if (!cmd || cmd === '-h' || cmd === '--help' || !table[cmd]) { usage(); process.exit(cmd && cmd !== '-h' && cmd !== '--help' ? 2 : 0); } try { process.exit(table[cmd]()); } diff --git a/test/cli-robustness.test.mjs b/test/cli-robustness.test.mjs index b0e05a6..e8b781d 100644 --- a/test/cli-robustness.test.mjs +++ b/test/cli-robustness.test.mjs @@ -25,6 +25,14 @@ test('hook install: default command targets truecopy and is PINNED to this versi assert.doesNotMatch(cmd, /askalf\/canon/, 'no longer the legacy repo name'); }); +test('--version / -v / version print the package version (exit 0), not usage', () => { + for (const flag of ['--version', '-v', 'version']) { + const r = cli([flag]); + assert.equal(r.status, 0, flag); + assert.equal(r.stdout.trim(), pkgVersion, flag); + } +}); + test('hook claude --strict: an UNREADABLE stdin payload fails closed (exit 2); default allows (exit 0)', () => { const proj = tmp('hookp'); fs.mkdirSync(proj, { recursive: true }); const env = { ...process.env, CLAUDE_PROJECT_DIR: proj }; diff --git a/test/release-hygiene.test.mjs b/test/release-hygiene.test.mjs new file mode 100644 index 0000000..91c9fad --- /dev/null +++ b/test/release-hygiene.test.mjs @@ -0,0 +1,37 @@ +// A release is cut by bumping package.json alone (auto-release.yml), so nothing +// else would notice a bump that forgot its CHANGELOG section (the GitHub release +// silently falls back to "Release vX") or left the documented pins on the old +// version (users copy those lines verbatim). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const read = (p) => fs.readFileSync(new URL(`../${p}`, import.meta.url), 'utf8'); +const version = JSON.parse(read('package.json')).version; + +test('CHANGELOG has a section for the package.json version', () => { + const released = read('CHANGELOG.md').split(/\r?\n/) + .map((l) => /^## \[([^\]]+)\] - \d{4}-\d{2}-\d{2}$/.exec(l)?.[1]).filter(Boolean); + assert.ok(released.includes(version), + `add "## [${version}] - YYYY-MM-DD" to CHANGELOG.md — auto-release cuts the GitHub release notes from it`); +}); + +test('package-lock.json carries the package.json version', () => { + const lock = JSON.parse(read('package-lock.json')); + assert.equal(lock.version, version); + assert.equal(lock.packages[''].version, version); +}); + +test('documented pins name the package.json version', () => { + const pins = [ + ['docs/commands.md', /@askalf\/truecopy@(\d+\.\d+\.\d+)/g], + ['docs/claude-code.md', /github:askalf\/truecopy#v(\d+\.\d+\.\d+)/g], + ['.github/ISSUE_TEMPLATE/bug.yml', /placeholder: '(\d+\.\d+\.\d+) /g], + ['.github/ISSUE_TEMPLATE/false-positive.yml', /placeholder: '(\d+\.\d+\.\d+) /g], + ]; + for (const [file, re] of pins) { + const found = [...read(file).matchAll(re)].map((m) => m[1]); + assert.ok(found.length > 0, `${file}: expected a version pin matching ${re}`); + for (const v of found) assert.equal(v, version, `${file} pins ${v}, package.json is ${version}`); + } +});