Skip to content
Closed
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
102 changes: 101 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
4 changes: 3 additions & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ 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]
paths:
- '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: {}

Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/marketplace-watch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .github/workflows/truecopy-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]()); }
Expand Down
8 changes: 8 additions & 0 deletions test/cli-robustness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
37 changes: 37 additions & 0 deletions test/release-hygiene.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
});
Loading