Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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<version>` 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.
11 changes: 11 additions & 0 deletions scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand All @@ -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 });
Expand Down
21 changes: 21 additions & 0 deletions scripts/verify-cli-version.mjs
Original file line number Diff line number Diff line change
@@ -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 <cli> [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.`);
7 changes: 6 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +16,10 @@ interface ParsedArgs {
flags: Map<string, string | boolean>;
}

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<string, string | boolean>();
Expand Down Expand Up @@ -168,7 +173,7 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
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') {
Expand Down
58 changes: 57 additions & 1 deletion tests/release.test.mjs
Original file line number Diff line number Diff line change
@@ -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"]) {
Expand All @@ -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");
Expand Down