Files
mattpocock-skills-opencode/scripts/version.ts
T

98 lines
4.0 KiB
TypeScript

// 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 {
const [major, minor, patch] = version.split(".")
if (patch.includes("-")) return `${major}.${minor}.${patch}` // prerelease: keep as-is
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
return `${changelog.slice(0, after)}\n\n${entry.body}\n${changelog.slice(after)}`
}
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))
state.released_commit = target.commit
writeFileSync(path.join(root, ".opencode/state/upstream.json"), JSON.stringify(state, null, 2) + "\n")
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)
}
}