98 lines
4.0 KiB
TypeScript
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)
|
|
}
|
|
}
|