|
| 1 | +#!/usr/bin/env node |
| 2 | +// Version-consistency guard. Ponytail declares its version in six files across |
| 3 | +// four host ecosystems, and every release bumps all of them by hand. |
| 4 | +// |
| 5 | +// tests/gemini-extension.test.js already checks the four plugin manifests agree |
| 6 | +// with each other, but that can't catch the failure mode that shipped in v4.8.0: |
| 7 | +// every manifest stayed stale at 4.7.0 *together* while the release moved on, so |
| 8 | +// they "agreed" and the test passed (#260, #262). It also ignores the two |
| 9 | +// package.json files. This check closes both gaps: |
| 10 | +// 1. every version-bearing file must share one pinned X.Y.Z version, and |
| 11 | +// 2. on a release-tag CI run, that shared version must equal the tag. |
| 12 | + |
| 13 | +const fs = require('fs'); |
| 14 | +const path = require('path'); |
| 15 | + |
| 16 | +const root = path.join(__dirname, '..'); |
| 17 | +const PINNED_SEMVER = /^\d+\.\d+\.\d+$/; |
| 18 | + |
| 19 | +// Every file that declares the project version, and who reads it. Add new host |
| 20 | +// manifests here so a future ecosystem can't drift unnoticed. |
| 21 | +const VERSION_FILES = [ |
| 22 | + '.claude-plugin/plugin.json', // Claude Code plugin — what users install |
| 23 | + '.codex-plugin/plugin.json', // Codex plugin |
| 24 | + '.github/plugin/plugin.json', // Copilot plugin |
| 25 | + 'gemini-extension.json', // Gemini CLI extension |
| 26 | + 'package.json', // pi-package / repo root |
| 27 | + 'ponytail-mcp/package.json', // MCP server (private, internal-only) |
| 28 | +]; |
| 29 | + |
| 30 | +function readVersion(relPath) { |
| 31 | + try { |
| 32 | + // Strip a UTF-8 BOM some Windows editors prepend (breaks JSON.parse). |
| 33 | + const raw = fs.readFileSync(path.join(root, relPath), 'utf8').replace(/^\uFEFF/, ''); |
| 34 | + return JSON.parse(raw).version; |
| 35 | + } catch (e) { |
| 36 | + throw new Error(`${relPath}: ${e.message}`); |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +let failed = false; |
| 41 | +const versions = VERSION_FILES.map((relPath) => { |
| 42 | + const version = readVersion(relPath); |
| 43 | + if (typeof version !== 'string' || !PINNED_SEMVER.test(version)) { |
| 44 | + console.error(`${relPath}: version must be a pinned X.Y.Z semver, got ${JSON.stringify(version)}`); |
| 45 | + failed = true; |
| 46 | + } |
| 47 | + return [relPath, version]; |
| 48 | +}); |
| 49 | + |
| 50 | +// Every file must declare the same version. |
| 51 | +const distinct = [...new Set(versions.map(([, v]) => v))]; |
| 52 | +if (distinct.length > 1) { |
| 53 | + console.error('Version mismatch — every manifest must share one version:'); |
| 54 | + for (const [relPath, version] of versions) console.error(` ${version}\t${relPath}`); |
| 55 | + failed = true; |
| 56 | +} |
| 57 | +const shared = distinct.length === 1 ? distinct[0] : null; |
| 58 | + |
| 59 | +// On a release-tag push CI sets GITHUB_REF_TYPE=tag and GITHUB_REF_NAME=vX.Y.Z. |
| 60 | +// The shared version must equal the tag — this catches tagging a release whose |
| 61 | +// version files were never bumped, which mutual agreement alone cannot. |
| 62 | +if (shared && process.env.GITHUB_REF_TYPE === 'tag') { |
| 63 | + const tag = process.env.GITHUB_REF_NAME || ''; |
| 64 | + const tagVersion = tag.replace(/^v/, ''); |
| 65 | + if (PINNED_SEMVER.test(tagVersion) && tagVersion !== shared) { |
| 66 | + console.error(`release tag ${tag} does not match version ${shared}; bump the version files before tagging`); |
| 67 | + failed = true; |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +if (failed) { |
| 72 | + console.error('Align the version fields (see issue #260) so every manifest shares one version.'); |
| 73 | + process.exit(1); |
| 74 | +} |
| 75 | + |
| 76 | +console.log(`All ${VERSION_FILES.length} version files pinned at ${shared}.`); |
0 commit comments