allexcd_obsidian-mcp/scripts/release.cjs

274 lines
8.7 KiB
JavaScript
Executable file

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const readline = require('readline');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const PKG_PATH = path.join(ROOT, 'package.json');
const LOCK_PATH = path.join(ROOT, 'package-lock.json');
const ROOT_MANIFEST_PATH = path.join(ROOT, 'manifest.json');
const PLUGIN_MANIFEST_PATH = path.join(ROOT, 'packages', 'plugin', 'manifest.json');
const VERSIONS_PATH = path.join(ROOT, 'versions.json');
const WORKSPACE_PACKAGE_PATHS = [
path.join(ROOT, 'packages', 'shared', 'package.json'),
path.join(ROOT, 'packages', 'mcp-server', 'package.json'),
path.join(ROOT, 'packages', 'plugin', 'package.json')
];
const BUMP_TYPES = new Set(['patch', 'minor', 'major']);
function run(command, args = [], opts = {}) {
return execFileSync(command, args, {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
...opts,
}).trim();
}
function runInherit(command, args = []) {
execFileSync(command, args, { cwd: ROOT, stdio: 'inherit' });
}
function printHelp() {
console.log(`
obsidian-mcp release
Usage:
npm run release
Steps:
1. Require a clean main branch
2. Fetch tags and choose patch/minor/major
3. Create release/X.Y.Z (no v prefix; matches Obsidian tag convention)
4. Bump package.json, package-lock.json, manifest.json,
packages/plugin/manifest.json, and append to versions.json
5. Run npm run build to confirm the bundle still produces a clean zip
6. Commit, push, and open a PR
After the PR is merged, GitHub Actions tags X.Y.Z, creates a GitHub Release,
and uploads main.js, manifest.json, styles.css, and the plugin zip.
`);
}
function fail(message, detail = '') {
console.error(`\n Error: ${message}\n`);
if (detail) {
console.error(detail);
console.error('');
}
process.exit(1);
}
function checkDependencies() {
try {
run('gh', ['--version']);
} catch {
fail('gh CLI is not installed or not in PATH.', ' Install it from https://cli.github.com and run "gh auth login" first.');
}
try {
run('gh', ['auth', 'status']);
} catch {
fail('gh CLI is not authenticated. Run "gh auth login" first.');
}
}
function assertSemver(version, label) {
if (!/^\d+\.\d+\.\d+$/.test(version)) {
fail(`${label} must be a plain semver version like 1.2.3. Found "${version}".`);
}
}
function bumpVersion(current, type) {
assertSemver(current, 'Current version');
const [major, minor, patch] = current.split('.').map(Number);
if (type === 'major') {
return `${major + 1}.0.0`;
}
if (type === 'minor') {
return `${major}.${minor + 1}.0`;
}
return `${major}.${minor}.${patch + 1}`;
}
function latestVersionTag() {
const tags = run('git', ['tag', '--sort=-v:refname'])
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean);
return tags.find((tag) => /^\d+\.\d+\.\d+$/.test(tag)) || null;
}
function prompt(question) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
}));
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, data) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
async function main() {
if (process.argv.includes('--help') || process.argv.includes('-h')) {
printHelp();
return;
}
const status = run('git', ['status', '--porcelain']);
if (status) {
fail('Working tree has uncommitted changes. Commit or stash them first.', status);
}
const currentBranch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
if (currentBranch !== 'main') {
fail(`Must be on main branch to create a release. Currently on "${currentBranch}".`);
}
checkDependencies();
run('git', ['fetch', '--tags']);
const pkg = readJson(PKG_PATH);
const rootManifest = readJson(ROOT_MANIFEST_PATH);
const pluginManifest = readJson(PLUGIN_MANIFEST_PATH);
assertSemver(pkg.version, 'package.json version');
assertSemver(rootManifest.version, 'manifest.json version');
assertSemver(pluginManifest.version, 'packages/plugin/manifest.json version');
if (rootManifest.version !== pkg.version || pluginManifest.version !== pkg.version) {
fail(
'Versions are out of sync. Align package.json, manifest.json, and packages/plugin/manifest.json before releasing.',
` package.json: ${pkg.version}\n manifest.json: ${rootManifest.version}\n packages/plugin/manifest.json: ${pluginManifest.version}`
);
}
let current = pkg.version;
const latestTag = latestVersionTag();
if (latestTag) {
if (latestTag !== pkg.version) {
console.log(`\n package.json (${pkg.version}) is behind latest tag (${latestTag}); using tag as base.`);
}
current = latestTag;
}
console.log(`\n Current version: ${current}`);
console.log(' Bump type: patch | minor | major\n');
const input = await prompt(' Bump type (patch): ');
const bumpType = input || 'patch';
if (!BUMP_TYPES.has(bumpType)) {
fail(`Invalid bump type "${bumpType}". Must be patch, minor, or major.`);
}
const next = bumpVersion(current, bumpType);
const tag = next; // No v prefix — Obsidian convention
const branch = `release/${tag}`;
const minAppVersion = rootManifest.minAppVersion;
console.log(`\n ${current} -> ${next}`);
console.log(` Tag: ${tag} (no v prefix, Obsidian convention)`);
console.log(` minAppVersion: ${minAppVersion}`);
console.log(` Branch: ${branch}\n`);
run('git', ['checkout', '-b', branch]);
pkg.version = next;
writeJson(PKG_PATH, pkg);
rootManifest.version = next;
writeJson(ROOT_MANIFEST_PATH, rootManifest);
pluginManifest.version = next;
writeJson(PLUGIN_MANIFEST_PATH, pluginManifest);
for (const workspacePackagePath of WORKSPACE_PACKAGE_PATHS) {
const workspacePackage = readJson(workspacePackagePath);
workspacePackage.version = next;
if (workspacePackage.dependencies && workspacePackage.dependencies['@obsidian-mcp/shared']) {
workspacePackage.dependencies['@obsidian-mcp/shared'] = next;
}
writeJson(workspacePackagePath, workspacePackage);
}
const versions = fs.existsSync(VERSIONS_PATH) ? readJson(VERSIONS_PATH) : {};
versions[next] = minAppVersion;
writeJson(VERSIONS_PATH, versions);
if (fs.existsSync(LOCK_PATH)) {
run('npm', ['install', '--package-lock-only', '--ignore-scripts']);
console.log(` OK package.json + package-lock.json + manifests + workspace packages + versions.json -> ${next}`);
} else {
console.log(` OK package.json + manifests + workspace packages + versions.json -> ${next}`);
}
console.log('\n Running npm run build to verify the bundle is clean...');
try {
runInherit('npm', ['run', 'build']);
} catch {
fail('Build failed. Fix build errors before releasing.');
}
// Build leaves files under build/ — they're gitignored, but verify just in case.
const dirty = run('git', ['status', '--porcelain']);
if (dirty.split('\n').some((line) => line && !line.match(/(package\.json|package-lock\.json|manifest\.json|versions\.json)$/))) {
fail(
'Unexpected files would be committed (anything beyond version files).',
dirty
);
}
const filesToCommit = [
'package.json',
'manifest.json',
'packages/plugin/manifest.json',
'packages/shared/package.json',
'packages/mcp-server/package.json',
'packages/plugin/package.json',
'versions.json'
];
if (fs.existsSync(LOCK_PATH)) {
filesToCommit.push('package-lock.json');
}
run('git', ['add', ...filesToCommit]);
run('git', ['commit', '-m', `chore: release ${tag}`]);
console.log(` OK commit: chore: release ${tag}`);
run('git', ['push', '-u', 'origin', branch]);
console.log(` OK pushed branch: ${branch}`);
const prBody = [
`Bump version to ${next}.`,
'',
`Once merged, CI will tag \`${tag}\` (no v prefix, Obsidian convention),`,
`create a GitHub Release with auto-generated notes, and upload the plugin`,
`assets (\`main.js\`, \`manifest.json\`, \`styles.css\`, and the zip archive).`
].join('\n');
const prBodyPath = path.join(os.tmpdir(), `obsidian-mcp-release-${next}.md`);
fs.writeFileSync(prBodyPath, prBody, 'utf8');
try {
run('gh', ['pr', 'create', '--title', `chore: release ${tag}`, '--body-file', prBodyPath, '--base', 'main']);
} finally {
fs.unlinkSync(prBodyPath);
}
console.log(' OK PR opened against main');
console.log('\n Done. Merge the PR to trigger tagging and release asset upload.\n');
}
main().catch((err) => {
fail(`Release failed: ${err.message}`);
});