// Computes the next package version and prepends the CHANGELOG entry for an upstream sync. // Idempotent: when the changelog already names the target upstream commit, nothing changes. // // node scripts/version.ts [--dry] // // Env/inputs: upstream/UPSTREAM.json (target revision), CHANGELOG.md, .opencode/state/upstream.json. // Rule (decision D4): upstream content change => minor bump; humans write their own entries for // behavior changes; majors are never automatic. import { readFileSync, writeFileSync } from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") export function latestVersion(changelog: string): string { const match = changelog.match(/^## \[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]/m) if (!match) throw new Error("no release heading like `## [1.0.0]` found in CHANGELOG.md") return match[1] } export function bumpMinor(version: string): string { if (version.includes("-")) return version // prerelease: keep as-is const [major, minor] = version.split(".") return `${major}.${Number(minor) + 1}.0` } export type SyncEntry = { version: string; date: string; body: string } export function buildEntry(opts: { changelog: string fromCommit: string | null toCommit: string toVersion: string date: string diff: { added: string[]; removed: string[]; changed: string[] } }): SyncEntry | null { const short = opts.toCommit.slice(0, 9) if (opts.changelog.includes(short)) return null // already released this upstream revision const version = bumpMinor(latestVersion(opts.changelog)) const lines = [ `## [${version}] - ${opts.date}`, "", "### Changed", "", `- Upstream sync: mattpocock/skills \`${opts.fromCommit ? opts.fromCommit.slice(0, 9) : "initial"}\` -> \`${short}\` (upstream v${opts.toVersion}).`, ] if (opts.diff.added.length) lines.push(` - added: ${opts.diff.added.join(", ")}`) if (opts.diff.removed.length) lines.push(` - removed: ${opts.diff.removed.join(", ")}`) if (opts.diff.changed.length) lines.push(` - changed: ${opts.diff.changed.join(", ")}`) return { version, date: opts.date, body: lines.join("\n") } } /** Inserts the entry directly below the `## [Unreleased]` block heading area. */ export function insertEntry(changelog: string, entry: SyncEntry): string { const marker = "## [Unreleased]" const i = changelog.indexOf(marker) if (i === -1) throw new Error("CHANGELOG.md has no `## [Unreleased]` heading") const after = i + marker.length const rest = changelog.slice(after).replace(/^\n+/, "") return `${changelog.slice(0, after)}\n\n${entry.body}\n\n${rest}` } function main() { const changelogPath = path.join(root, "CHANGELOG.md") const changelog = readFileSync(changelogPath, "utf8") const target = JSON.parse(readFileSync(path.join(root, "upstream/UPSTREAM.json"), "utf8")) const state = JSON.parse(readFileSync(path.join(root, ".opencode/state/upstream.json"), "utf8")) // The diff for the entry comes from the last released sync recorded in state, if any. const entry = buildEntry({ changelog, fromCommit: state.previous_commit ?? null, toCommit: target.commit, toVersion: target.upstream_version, date: new Date().toISOString().slice(0, 10), diff: state.last_diff ?? { added: [], removed: [], changed: [] }, }) if (!entry) { console.log(`changelog already covers upstream ${target.commit.slice(0, 9)}; no version change`) return } if (process.argv.includes("--dry")) { console.log(`would release ${entry.version}:\n\n${entry.body}`) return } writeFileSync(changelogPath, insertEntry(changelog, entry)) console.log(`prepared release ${entry.version} for upstream ${target.commit.slice(0, 9)}`) } if (process.argv[1] === fileURLToPath(import.meta.url)) { try { main() } catch (err) { console.error((err as Error).message) process.exit(1) } }