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: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,17 @@ 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
node --check plugins/opencode/scripts/lib/git.mjs
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
96 changes: 96 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Release

# Manually-triggered release workflow.
# Runs `npm run bump-version <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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
239 changes: 239 additions & 0 deletions scripts/bump-version.mjs
Original file line number Diff line number Diff line change
@@ -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 <version>",
" node scripts/bump-version.mjs --check [version]",
"",
"Options:",
" --check Verify manifest versions match. Uses package.json when version is omitted.",
" --root <dir> 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 ?? "<missing>"}`
);
}
}
}

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;
}
}
Loading