diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80541e3..784617a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - name: Install OpenCode CLI run: npm install -g opencode-ai + - name: Check version metadata is in sync + run: npm run check-version + - name: Syntax-check companion scripts run: | node --check plugins/opencode/scripts/opencode-companion.mjs @@ -45,6 +48,7 @@ jobs: node --check plugins/opencode/scripts/lib/prompts.mjs node --check plugins/opencode/scripts/lib/process.mjs node --check plugins/opencode/scripts/lib/opencode-server.mjs + node --check scripts/bump-version.mjs - name: Run tests run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..264536d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +# Manually-triggered release workflow. +# Runs `npm run bump-version ` to update every manifest, commits +# the bump on `main`, tags it, pushes, and creates a GitHub release with +# auto-generated notes assembled from PRs since the previous tag. +# +# Usage: GitHub web UI → Actions → "Release" → "Run workflow" → enter the +# new semver (e.g. `1.0.1`). + +on: + workflow_dispatch: + inputs: + version: + description: "New semver (e.g. 1.0.1) — must be greater than the current package.json version" + required: true + type: string + +permissions: + contents: write # commit + tag + release + +jobs: + release: + name: Bump, tag, and release + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Need full history so `gh release create --generate-notes` can + # diff against the previous tag. + fetch-depth: 0 + # Use the default GITHUB_TOKEN to push the bump commit + tag. + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Validate the requested version is newer than the current one + run: | + set -euo pipefail + requested="${{ inputs.version }}" + current=$(node -p "require('./package.json').version") + echo "Current: $current" + echo "Requested: $requested" + if [ "$current" = "$requested" ]; then + echo "::error::Requested version $requested is the same as the current version. Pick a higher one." + exit 1 + fi + # Lexicographic vs numeric: use sort -V (version sort) to make + # sure the requested version is strictly greater. + highest=$(printf '%s\n%s\n' "$current" "$requested" | sort -V | tail -n1) + if [ "$highest" != "$requested" ]; then + echo "::error::Requested version $requested is not greater than current $current." + exit 1 + fi + + - name: Bump version metadata + run: npm run bump-version -- "${{ inputs.version }}" + + - name: Verify the bump landed in every manifest + run: npm run check-version + + - name: Run tests against the bumped tree + run: npm test + + - name: Configure git committer + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit, tag, and push + run: | + set -euo pipefail + version="${{ inputs.version }}" + git add package.json package-lock.json plugins/opencode/.claude-plugin/plugin.json .claude-plugin/marketplace.json + git commit -m "chore(release): v${version}" + git tag -a "v${version}" -m "Release v${version}" + git push origin HEAD:main + git push origin "v${version}" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ inputs.version }}" \ + --title "v${{ inputs.version }}" \ + --generate-notes diff --git a/package.json b/package.json index 94d3d69..2c636a9 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "node": ">=18.18.0" }, "scripts": { - "test": "node --test tests/*.test.mjs" + "test": "node --test tests/*.test.mjs", + "bump-version": "node scripts/bump-version.mjs", + "check-version": "node scripts/bump-version.mjs --check" }, "devDependencies": { "@types/node": "^22.0.0" diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs new file mode 100644 index 0000000..b8d2d1b --- /dev/null +++ b/scripts/bump-version.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +// +// Bump (or --check) the plugin version across every manifest that +// records it. Adapted from openai/codex-plugin-cc's scripts/bump-version.mjs, +// with the target list rewritten for our manifest layout (marketplace.json +// has `version` at root rather than under `metadata`, and the marketplace +// plugin entry is keyed by name `"opencode"`, not `"codex"`). +// +// Apache License 2.0 §4(b) modification notice — see NOTICE. + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +const TARGETS = [ + { + file: "package.json", + values: [ + { + label: "version", + get: (json) => json.version, + set: (json, version) => { json.version = version; }, + }, + ], + }, + { + file: "package-lock.json", + values: [ + { + label: "version", + get: (json) => json.version, + set: (json, version) => { json.version = version; }, + }, + { + label: 'packages[""].version', + get: (json) => json.packages?.[""]?.version, + set: (json, version) => { + requireObject(json.packages?.[""], 'package-lock.json packages[""]'); + json.packages[""].version = version; + }, + }, + ], + }, + { + file: "plugins/opencode/.claude-plugin/plugin.json", + values: [ + { + label: "version", + get: (json) => json.version, + set: (json, version) => { json.version = version; }, + }, + ], + }, + { + file: ".claude-plugin/marketplace.json", + values: [ + { + label: "version", + get: (json) => json.version, + set: (json, version) => { json.version = version; }, + }, + { + label: "plugins[opencode].version", + get: (json) => findMarketplacePlugin(json).version, + set: (json, version) => { + findMarketplacePlugin(json).version = version; + }, + }, + ], + }, +]; + +function usage() { + return [ + "Usage:", + " node scripts/bump-version.mjs ", + " node scripts/bump-version.mjs --check [version]", + "", + "Options:", + " --check Verify manifest versions match. Uses package.json when version is omitted.", + " --root Run against a different repository root (useful for tests).", + " --help, -h Print this help.", + "", + "Examples:", + " node scripts/bump-version.mjs 1.0.1 # bump every manifest to 1.0.1", + " node scripts/bump-version.mjs --check # verify all manifests match package.json", + ].join("\n"); +} + +function parseArgs(argv) { + const options = { + check: false, + root: process.cwd(), + version: null, + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + + if (arg === "--check") { + options.check = true; + } else if (arg === "--root") { + const root = argv[i + 1]; + if (!root) { + throw new Error("--root requires a directory."); + } + options.root = root; + i += 1; + } else if (arg === "--help" || arg === "-h") { + options.help = true; + } else if (arg.startsWith("-")) { + throw new Error(`Unknown option: ${arg}`); + } else if (options.version) { + throw new Error(`Unexpected extra argument: ${arg}`); + } else { + options.version = arg; + } + } + + options.root = path.resolve(options.root); + return options; +} + +function validateVersion(version) { + if (!VERSION_PATTERN.test(version)) { + throw new Error(`Expected a semver-like version such as 1.0.3, got: ${version}`); + } +} + +function requireObject(value, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`Expected ${label} to be an object.`); + } +} + +function findMarketplacePlugin(json) { + const plugin = json.plugins?.find((entry) => entry?.name === "opencode"); + requireObject(plugin, '.claude-plugin/marketplace.json plugins[name="opencode"]'); + return plugin; +} + +function readJson(root, file) { + const filePath = path.join(root, file); + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJson(root, file, json) { + const filePath = path.join(root, file); + fs.writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`); +} + +export function readPackageVersion(root) { + const packageJson = readJson(root, "package.json"); + if (typeof packageJson.version !== "string") { + throw new Error("package.json version must be a string."); + } + validateVersion(packageJson.version); + return packageJson.version; +} + +export function checkVersions(root, expectedVersion) { + const mismatches = []; + + for (const target of TARGETS) { + const json = readJson(root, target.file); + for (const value of target.values) { + const actual = value.get(json); + if (actual !== expectedVersion) { + mismatches.push( + `${target.file} ${value.label}: expected ${expectedVersion}, found ${actual ?? ""}` + ); + } + } + } + + return mismatches; +} + +export function bumpVersion(root, version) { + validateVersion(version); + const changedFiles = []; + + for (const target of TARGETS) { + const json = readJson(root, target.file); + const before = JSON.stringify(json); + + for (const value of target.values) { + value.set(json, version); + } + + if (JSON.stringify(json) !== before) { + writeJson(root, target.file, json); + changedFiles.push(target.file); + } + } + + return changedFiles; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const version = options.version ?? (options.check ? readPackageVersion(options.root) : null); + if (!version) { + throw new Error(`Missing version.\n\n${usage()}`); + } + validateVersion(version); + + if (options.check) { + const mismatches = checkVersions(options.root, version); + if (mismatches.length > 0) { + throw new Error(`Version metadata is out of sync:\n${mismatches.join("\n")}`); + } + console.log(`All version metadata matches ${version}.`); + return; + } + + const changedFiles = bumpVersion(options.root, version); + const touched = changedFiles.length > 0 ? changedFiles.join(", ") : "no files changed"; + console.log(`Set version metadata to ${version}: ${touched}.`); +} + +// Only run main when invoked as a CLI, not when imported by tests. +if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/tests/bump-version.test.mjs b/tests/bump-version.test.mjs new file mode 100644 index 0000000..a098632 --- /dev/null +++ b/tests/bump-version.test.mjs @@ -0,0 +1,171 @@ +// Integration tests for scripts/bump-version.mjs. +// +// Each test writes a complete fixture set of the four version-bearing +// manifests into a tmp directory and exercises bumpVersion / checkVersions +// against it. The script exports those functions so we can call them +// directly without spawning a node subprocess. + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { createTmpDir, cleanupTmpDir } from "./helpers.mjs"; +import { + bumpVersion, + checkVersions, + readPackageVersion, +} from "../scripts/bump-version.mjs"; + +let tmpDir; + +beforeEach(() => { + tmpDir = createTmpDir("bump-version"); + writeFixture(tmpDir, "1.0.0"); +}); + +afterEach(() => { + cleanupTmpDir(tmpDir); +}); + +function writeFixture(root, version) { + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "@johnnyvicious/opencode-plugin-cc", version }, null, 2) + "\n" + ); + + fs.writeFileSync( + path.join(root, "package-lock.json"), + JSON.stringify( + { + name: "@johnnyvicious/opencode-plugin-cc", + version, + lockfileVersion: 3, + packages: { + "": { name: "@johnnyvicious/opencode-plugin-cc", version }, + }, + }, + null, + 2 + ) + "\n" + ); + + const pluginDir = path.join(root, "plugins", "opencode", ".claude-plugin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "plugin.json"), + JSON.stringify({ name: "opencode", version }, null, 2) + "\n" + ); + + const marketplaceDir = path.join(root, ".claude-plugin"); + fs.mkdirSync(marketplaceDir, { recursive: true }); + fs.writeFileSync( + path.join(marketplaceDir, "marketplace.json"), + JSON.stringify( + { + name: "johnnyvicious-opencode-plugin-cc", + version, + plugins: [{ name: "opencode", version }], + }, + null, + 2 + ) + "\n" + ); +} + +function readVersionField(root, file, jsonPath) { + const data = JSON.parse(fs.readFileSync(path.join(root, file), "utf8")); + return jsonPath.reduce((acc, key) => acc?.[key], data); +} + +describe("bump-version: bumpVersion", () => { + it("updates every version field across all four manifests", () => { + const changed = bumpVersion(tmpDir, "1.0.1"); + + assert.deepEqual(changed.sort(), [ + ".claude-plugin/marketplace.json", + "package-lock.json", + "package.json", + "plugins/opencode/.claude-plugin/plugin.json", + ]); + + assert.equal(readVersionField(tmpDir, "package.json", ["version"]), "1.0.1"); + assert.equal(readVersionField(tmpDir, "package-lock.json", ["version"]), "1.0.1"); + assert.equal( + readVersionField(tmpDir, "package-lock.json", ["packages", "", "version"]), + "1.0.1" + ); + assert.equal( + readVersionField(tmpDir, "plugins/opencode/.claude-plugin/plugin.json", ["version"]), + "1.0.1" + ); + assert.equal( + readVersionField(tmpDir, ".claude-plugin/marketplace.json", ["version"]), + "1.0.1" + ); + // Marketplace plugin entry version + const marketplaceJson = JSON.parse( + fs.readFileSync(path.join(tmpDir, ".claude-plugin/marketplace.json"), "utf8") + ); + assert.equal(marketplaceJson.plugins[0].version, "1.0.1"); + }); + + it("supports prerelease and build metadata semver", () => { + bumpVersion(tmpDir, "2.0.0-rc.1"); + assert.equal(readVersionField(tmpDir, "package.json", ["version"]), "2.0.0-rc.1"); + }); + + it("rejects malformed version strings", () => { + assert.throws(() => bumpVersion(tmpDir, "v1.0.1"), /semver-like/); + assert.throws(() => bumpVersion(tmpDir, "1.0"), /semver-like/); + assert.throws(() => bumpVersion(tmpDir, "latest"), /semver-like/); + }); +}); + +describe("bump-version: checkVersions", () => { + it("returns no mismatches when all manifests agree with the expected version", () => { + const mismatches = checkVersions(tmpDir, "1.0.0"); + assert.deepEqual(mismatches, []); + }); + + it("reports mismatches when a manifest is out of sync", () => { + // Hand-corrupt plugin.json so it lags + fs.writeFileSync( + path.join(tmpDir, "plugins/opencode/.claude-plugin/plugin.json"), + JSON.stringify({ name: "opencode", version: "0.9.9" }, null, 2) + "\n" + ); + const mismatches = checkVersions(tmpDir, "1.0.0"); + assert.equal(mismatches.length, 1); + assert.match(mismatches[0], /plugin\.json/); + assert.match(mismatches[0], /expected 1\.0\.0, found 0\.9\.9/); + }); + + it("reports mismatches in marketplace plugin entry", () => { + const file = path.join(tmpDir, ".claude-plugin/marketplace.json"); + const json = JSON.parse(fs.readFileSync(file, "utf8")); + json.plugins[0].version = "0.5.0"; + fs.writeFileSync(file, JSON.stringify(json, null, 2) + "\n"); + + const mismatches = checkVersions(tmpDir, "1.0.0"); + assert.equal(mismatches.length, 1); + assert.match(mismatches[0], /marketplace\.json plugins\[opencode\]\.version/); + }); + + it("returns no mismatches after a clean bump", () => { + bumpVersion(tmpDir, "1.2.3"); + assert.deepEqual(checkVersions(tmpDir, "1.2.3"), []); + }); +}); + +describe("bump-version: readPackageVersion", () => { + it("returns the version recorded in package.json", () => { + assert.equal(readPackageVersion(tmpDir), "1.0.0"); + }); + + it("throws if package.json's version field is malformed", () => { + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "x", version: "not-semver" }, null, 2) + ); + assert.throws(() => readPackageVersion(tmpDir), /semver-like/); + }); +});