diff --git a/.changeset/fix-cli-version-mismatch.md b/.changeset/fix-cli-version-mismatch.md new file mode 100644 index 00000000..d52de0a0 --- /dev/null +++ b/.changeset/fix-cli-version-mismatch.md @@ -0,0 +1,6 @@ +--- +"@fission-ai/openspec": patch +--- + +Fix CLI version mismatch and add a release guard that validates the packed tarball prints the same version as package.json via `openspec --version`. + diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 56a221d4..2dc30d1d 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -41,7 +41,9 @@ jobs: with: title: 'chore(release): version packages' createGithubReleases: true - publish: pnpm run release + # Use CI-specific release script: relies on version PR having been merged + # so package.json already contains the bumped version. + publish: pnpm run release:ci env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index 0adaf92e..cfbced9b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,10 @@ "test:coverage": "vitest --coverage", "prepare": "pnpm run build", "prepublishOnly": "pnpm run build", - "release": "pnpm run build && pnpm exec changeset publish", + "check:pack-version": "node scripts/pack-version-check.mjs", + "release": "pnpm run release:ci", + "release:ci": "pnpm run check:pack-version && pnpm exec changeset publish", + "release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish", "changeset": "changeset" }, "engines": { diff --git a/scripts/pack-version-check.mjs b/scripts/pack-version-check.mjs new file mode 100644 index 00000000..43cf8050 --- /dev/null +++ b/scripts/pack-version-check.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// Guard: Ensure the packed tarball's CLI `--version` matches package.json. +// +// Notes: +// - We intentionally use `npm pack` (not pnpm) because `npm pack --json` is +// consistently supported and returns the tarball metadata we need. The +// project uses pnpm for install/publish, but this guard only needs to pack +// locally and verify the installed CLI output. +// - `npm pack` triggers the package's `prepare` script (build), and +// `changeset publish` triggers `prepublishOnly` (also builds here). This +// means an explicit build is not strictly necessary for the guard. + +import { execFileSync } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; + +function log(msg) { + if (process.env.CI) return; // keep CI logs quiet by default + console.log(msg); +} + +function run(cmd, args, opts = {}) { + return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }); +} + +function npmPack() { + try { + const jsonOut = run('npm', ['pack', '--json', '--silent']); + const arr = JSON.parse(jsonOut); + if (Array.isArray(arr) && arr.length > 0) { + const last = arr[arr.length - 1]; + const file = (last && typeof last === 'object' && last.filename) || (typeof last === 'string' ? last : null); + if (file) return String(file).trim(); + } + // Unexpected JSON shape or empty array; fallback to plain output + const out = run('npm', ['pack', '--silent']).trim(); + const lines = out.split(/\r?\n/); + return lines[lines.length - 1].trim(); + } catch (e) { + // Fallback for environments not supporting --json + const out = run('npm', ['pack', '--silent']).trim(); + const lines = out.split(/\r?\n/); + return lines[lines.length - 1].trim(); + } +} + +function main() { + const pkg = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8')); + const expected = pkg.version; + + let work; + let tgzPath; + + try { + log(`Packing @fission-ai/openspec@${expected}...`); + const filename = npmPack(); + tgzPath = path.resolve(filename); + log(`Created: ${tgzPath}`); + + work = mkdtempSync(path.join(tmpdir(), 'openspec-pack-check-')); + log(`Temp dir: ${work}`); + + // Make a tiny project + writeFileSync( + path.join(work, 'package.json'), + JSON.stringify({ name: 'pack-check', private: true }, null, 2) + ); + + // Try to avoid noisy output and speed up + const env = { + ...process.env, + npm_config_loglevel: 'silent', + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_progress: 'false', + }; + + // Install the tarball + run('npm', ['install', tgzPath, '--silent', '--no-audit', '--no-fund'], { cwd: work, env }); + + // Run the installed CLI via Node to avoid bin resolution/platform issues + const binRel = path.join('node_modules', '@fission-ai', 'openspec', 'bin', 'openspec.js'); + const actual = run(process.execPath, [binRel, '--version'], { cwd: work }).trim(); + + if (actual !== expected) { + throw new Error( + `Packed CLI version mismatch: expected ${expected}, got ${actual}. ` + + 'Ensure the dist is built and the CLI reads version from package.json.' + ); + } + + log('Version check passed.'); + } finally { + // Always attempt cleanup + if (work) { + try { rmSync(work, { recursive: true, force: true }); } catch {} + } + if (tgzPath) { + try { rmSync(tgzPath, { force: true }); } catch {} + } + } +} + +try { + main(); + console.log('✅ pack-version-check: OK'); +} catch (err) { + console.error(`❌ pack-version-check: ${err.message}`); + process.exit(1); +}