diff --git a/README.md b/README.md index 7862646..616acd5 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,8 @@ npm run package:smoke npm run release:check ``` -The package smoke checks the tarball contents, installs that tarball into a clean temporary consumer, and invokes the installed CLI. +The package smoke checks the tarball contents, verifies the built CLI reports the package version, installs that tarball into a clean temporary consumer, and verifies the installed CLI reports the same version. Continuous integration runs the full `npm run release:check` suite on Node.js 20, 22, and 24. Node.js 20 is the package's declared minimum supported runtime, while 22 and 24 cover the current supported major releases. -Version releases are distributed through npm. Set `package.json` to the intended version, run `npm run release:tag -- v` and `npm run release:check`, then push that exact tag. The tag workflow validates the tag again, publishes the public package to npm with provenance, and creates the GitHub release only after npm publication succeeds. +Version releases are distributed through npm. Set `package.json` to the intended version; the CLI reads that value directly, so no source literal needs a separate update. Run `npm run release:tag -- v` and `npm run release:check`, then push that exact tag. The tag workflow validates the tag again, publishes the public package to npm with provenance, and creates the GitHub release only after npm publication succeeds. diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index cf3b16a..8a376b2 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -3,6 +3,9 @@ import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const verifyCliVersion = fileURLToPath(new URL("./verify-cli-version.mjs", import.meta.url)); const requiredFiles = [ "dist/cli.js", @@ -30,6 +33,10 @@ if (missing.length > 0) { console.log(`Package smoke passed with ${pack.files.length} files.`); +execFileSync(process.execPath, [verifyCliVersion, join(process.cwd(), "dist", "cli.js")], { + stdio: "inherit", +}); + const consumer = mkdtempSync(join(tmpdir(), "promptdiff-package-smoke-")); try { const tarball = execFileSync("npm", ["pack", "--silent"], { encoding: "utf8" }).trim(); @@ -38,6 +45,10 @@ try { const cli = join(consumer, "node_modules", ".bin", "promptdiff"); const help = execFileSync(cli, ["--help"], { cwd: consumer, encoding: "utf8" }); if (!help.includes("promptdiff compare")) throw new Error("Installed CLI help was not usable"); + execFileSync(process.execPath, [verifyCliVersion, cli, join(process.cwd(), "package.json")], { + cwd: consumer, + stdio: "inherit", + }); rmSync(join(process.cwd(), tarball)); } finally { rmSync(consumer, { recursive: true, force: true }); diff --git a/scripts/verify-cli-version.mjs b/scripts/verify-cli-version.mjs new file mode 100644 index 0000000..65661cd --- /dev/null +++ b/scripts/verify-cli-version.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +const [cli, manifest = "package.json"] = process.argv.slice(2); +if (!cli) { + console.error("Usage: verify-cli-version [package.json]"); + process.exit(1); +} + +const expected = JSON.parse(readFileSync(manifest, "utf8")).version; +const command = cli.endsWith(".js") ? process.execPath : cli; +const args = cli.endsWith(".js") ? [cli, "--version"] : ["--version"]; +const actual = execFileSync(command, args, { encoding: "utf8" }).trim(); + +if (actual !== expected) { + console.error(`CLI version mismatch: package.json=${expected}, CLI=${actual}`); + process.exit(1); +} + +console.log(`CLI version ${actual} matches package.json.`); diff --git a/src/cli.ts b/src/cli.ts index 0bbcb59..1ea7621 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { readFileSync } from 'node:fs'; import { mkdir, writeFile, readdir, stat } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { analyzePromptDiff } from './analyzer.js'; @@ -15,6 +16,10 @@ interface ParsedArgs { flags: Map; } +const packageVersion = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as { version: string }; + function parseArgs(argv: string[]): ParsedArgs { const [command, ...rest] = argv; const flags = new Map(); @@ -168,7 +173,7 @@ export async function main(argv = process.argv.slice(2)): Promise { return 0; } if (args.command === '--version' || args.command === '-v') { - process.stdout.write('0.1.0\n'); + process.stdout.write(`${packageVersion.version}\n`); return 0; } if (args.command === 'examples') { diff --git a/tests/release.test.mjs b/tests/release.test.mjs index 751d65e..c33d62d 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { chmodSync, cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; test("release tag must exactly match package version", () => { for (const tag of [undefined, "0.1.0", "v0.1", "v9.9.9"]) { @@ -12,6 +14,60 @@ test("release tag must exactly match package version", () => { assert.equal(spawnSync(process.execPath, ["scripts/validate-release-tag.mjs", "v0.1.0"]).status, 0); }); +test("CLI version verification rejects package mismatches", () => { + const directory = mkdtempSync(join(tmpdir(), "promptdiff-version-check-")); + const cli = join(directory, "promptdiff"); + const manifest = join(directory, "package.json"); + try { + writeFileSync(manifest, JSON.stringify({ version: "9.8.7" })); + writeFileSync(cli, "#!/bin/sh\nprintf '9.8.7\\n'\n"); + chmodSync(cli, 0o755); + let run = spawnSync(process.execPath, ["scripts/verify-cli-version.mjs", cli, manifest], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + + writeFileSync(cli, "#!/bin/sh\nprintf '0.1.0\\n'\n"); + run = spawnSync(process.execPath, ["scripts/verify-cli-version.mjs", cli, manifest], { encoding: "utf8" }); + assert.equal(run.status, 1); + assert.match(run.stderr, /package\.json=9\.8\.7, CLI=0\.1\.0/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("built and installed CLIs report a supplied package version", () => { + const directory = mkdtempSync(join(tmpdir(), "promptdiff-version-package-")); + const stage = join(directory, "stage"); + const consumer = join(directory, "consumer"); + try { + mkdirSync(stage); + cpSync("dist", join(stage, "dist"), { recursive: true }); + writeFileSync(join(stage, "package.json"), JSON.stringify({ + name: "promptdiff-version-fixture", + version: "9.8.7", + type: "module", + bin: { promptdiff: "./dist/cli.js" }, + files: ["dist"], + })); + + let run = spawnSync(process.execPath, [join(stage, "dist", "cli.js"), "--version"], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + assert.equal(run.stdout, "9.8.7\n"); + + const packed = spawnSync("npm", ["pack", "--silent"], { cwd: stage, encoding: "utf8" }); + assert.equal(packed.status, 0, packed.stderr); + mkdirSync(consumer); + assert.equal(spawnSync("npm", ["init", "--yes"], { cwd: consumer, encoding: "utf8" }).status, 0); + const tarball = join(stage, packed.stdout.trim()); + const install = spawnSync("npm", ["install", tarball], { cwd: consumer, encoding: "utf8" }); + assert.equal(install.status, 0, install.stderr); + run = spawnSync(join(consumer, "node_modules", ".bin", "promptdiff"), ["--version"], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + assert.equal(run.stdout, "9.8.7\n"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("release workflow validates before publishing and publishes before GitHub release", () => { const workflow = readFileSync(".github/workflows/release.yml", "utf8"); const validate = workflow.indexOf("npm run release:tag");