Files
LLeMbas-CLI/src/git/release.ts
T
HomerandClaude Opus 5.5 f9bad01ed7
ci / check (push) Canceled after 0s
LLeMbas CLI 1.0.0
The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:59:03 +00:00

229 lines
12 KiB
TypeScript

// /release: bump the version where the project keeps it, move CHANGELOG's Unreleased section
// under the new version, commit both files by name, and make an annotated tag — signed when git
// is set up to sign — whose message is that section. Nothing is pushed unless asked.
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { basename, join } from "node:path"
import { git } from "./run.ts"
export interface VersionFile {
file: string
version: string
/** The file's text with the version replaced. */
with(next: string): string
/** Other files that repeat the version and must move with it (package-lock.json). */
also?: { file: string; with(next: string): string }[]
}
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
const VERSIONISH = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`
/** Replace the first match of `re` (whose group 2 is the version) in `text`. */
const swap = (text: string, re: RegExp, next: string) => text.replace(re, (_m, a: string, _v: string, b: string) => `${a}${next}${b}`)
function tomlVersion(text: string, table: string): string | undefined {
const sec = new RegExp(`^\\[${table.replace(".", "\\.")}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, "m").exec(text)
return sec ? /^version\s*=\s*["']([^"']+)["']/m.exec(sec[1]!)?.[1] : undefined
}
function tomlWith(text: string, table: string, next: string): string {
const re = new RegExp(`(^\\[${table.replace(".", "\\.")}\\]\\s*$[\\s\\S]*?^version\\s*=\\s*["'])([^"']+)(["'])`, "m")
return swap(text, re, next)
}
/** package.json-style: the top-level "version" only (a nested one is someone else's), rewritten
* with the file's own indentation. */
function jsonVersion(text: string): { version: string; with(next: string): string } | undefined {
let j: any
try {
j = JSON.parse(text)
} catch {
return undefined
}
if (typeof j?.version !== "string") return undefined
const indent = /^\{\s*\n([ \t]+)"/.exec(text)?.[1] ?? " "
return {
version: j.version,
with: (next) => {
const o = JSON.parse(text)
o.version = next
// package-lock.json repeats it for the root package
if (o.packages?.[""] && typeof o.packages[""].version === "string") o.packages[""].version = next
return JSON.stringify(o, null, indent) + (text.endsWith("\n") ? "\n" : "")
},
}
}
/** Where the project's version lives: the first of package.json, pyproject.toml, Cargo.toml, a
* package's __init__.py, VERSION. The tree rule: one source of the version. */
export function findVersion(root: string): VersionFile | undefined {
const read = (f: string) => (existsSync(join(root, f)) ? readFileSync(join(root, f), "utf8") : undefined)
const pkg = read("package.json")
const pv = pkg ? jsonVersion(pkg) : undefined
if (pv) {
const lock = read("package-lock.json")
const lv = lock ? jsonVersion(lock) : undefined
return { file: "package.json", version: pv.version, with: pv.with, also: lv && lv.version === pv.version ? [{ file: "package-lock.json", with: lv.with }] : [] }
}
const py = read("pyproject.toml")
if (py) {
for (const t of ["project", "tool.poetry"]) {
const v = tomlVersion(py, t)
if (v) return { file: "pyproject.toml", version: v, with: (n) => tomlWith(py, t, n) }
}
}
const cargo = read("Cargo.toml")
if (cargo) {
for (const t of ["package", "workspace.package"]) {
const v = tomlVersion(cargo, t)
if (v) return { file: "Cargo.toml", version: v, with: (n) => tomlWith(cargo, t, n) }
}
}
for (const base of [".", "src"]) {
const dir = join(root, base)
if (!existsSync(dir)) continue
for (const d of readdirSync(dir, { withFileTypes: true })) {
if (!d.isDirectory() || d.name.startsWith(".")) continue
const rel = join(base === "." ? "" : base, d.name, "__init__.py")
const text = read(rel)
const v = text && /^__version__\s*=\s*["']([^"']+)["']/m.exec(text)?.[1]
if (text && v) return { file: rel, version: v, with: (n) => swap(text, /^(__version__\s*=\s*["'])([^"']+)(["'])/m, n) }
}
}
const plain = read("VERSION")?.trim()
if (plain) return { file: "VERSION", version: plain, with: (n) => `${n}\n` }
return undefined
}
/** The next version: patch, minor, major, or one given outright. */
export function nextVersion(current: string, how: string): string {
if (/^v\d/.test(how)) how = how.slice(1)
if (SEMVER.test(how)) return how
const m = SEMVER.exec(current)
if (!m) throw new Error(`the current version "${current}" is not x.y.z; give the new one outright`)
const [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])]
// A pre-release goes to its own release: 1.2.0-beta.3 → 1.2.0.
if (m[4] && (how === "" || how === "patch")) return `${maj}.${min}.${pat}`
if (how === "major") return `${maj + 1}.0.0`
if (how === "minor") return `${maj}.${min + 1}.0`
if (how === "patch" || how === "") return `${maj}.${min}.${pat + 1}`
throw new Error(`"${how}" is not patch, minor, major or a version`)
}
export interface ChangelogRelease {
/** CHANGELOG.md with the Unreleased entries under the new version. */
text: string
/** The entries: the tag message's body. */
notes: string
}
/** Keep a Changelog: `## [Unreleased]` keeps its place, empty, and its entries move under
* `## [X.Y.Z] — date`. undefined when there is nothing under Unreleased. */
export function releaseChangelog(text: string, version: string, date: string): ChangelogRelease | undefined {
const head = /^## \[?Unreleased\]?[^\n]*\n/im.exec(text)
if (!head) return undefined
const start = head.index + head[0].length
const next = /^## /m.exec(text.slice(start))
const end = next ? start + next.index : text.length
const notes = text.slice(start, end).trim()
if (!notes) return undefined
const out = `${text.slice(0, start)}\n## [${version}] — ${date}\n\n${notes}\n\n${text.slice(end)}`
// Link references at the bottom, if the file keeps them: Unreleased compares from the new tag.
const withLinks = out.replace(new RegExp(`^\\[Unreleased\\]:\\s*(\\S+?)\\/compare\\/(v?)(${VERSIONISH})\\.\\.\\.HEAD\\s*$`, "im"), (_m, base: string, v: string, prev: string) => `[Unreleased]: ${base}/compare/${v}${version}...HEAD\n[${version}]: ${base}/compare/${v}${prev}...${v}${version}`)
return { text: withLinks.replace(/\n{3,}/g, "\n\n"), notes }
}
export interface ReleasePlan {
/** Only the tag is missing: HEAD is already "Release <version>" (a tag that failed last time). */
tagOnly?: boolean
/** The tag message's body when there is no changelog move to take it from. */
notes?: string
root: string
name: string
from: string
to: string
tag: string
versionFile: VersionFile
changelog?: ChangelogRelease
sign: boolean
/** Why it cannot go ahead, if it cannot. */
blocked?: string
}
const today = () => new Date().toISOString().slice(0, 10)
/** v-prefixed tags unless the repository's tags are not. */
const tagPrefix = (root: string) => (git(root, ["tag", "--list", "v[0-9]*"]).out || !git(root, ["tag", "--list"]).out ? "v" : "")
const signs = (root: string) => git(root, ["config", "--bool", "tag.gpgsign"]).out === "true" || git(root, ["config", "user.signingkey"]).out !== ""
function projectName(root: string): string {
try {
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name?.replace(/^@[^/]+\//, "") || basename(root)
} catch {
return basename(root)
}
}
export function planRelease(root: string, how: string): ReleasePlan | { blocked: string } {
const vf = findVersion(root)
if (!vf) return { blocked: "no version found (package.json, pyproject.toml, Cargo.toml, __init__.py __version__ or VERSION)" }
const prefix = tagPrefix(root)
// The last /release committed but could not tag (a locked key): finish that one, do not skip it.
if (!how.trim() && git(root, ["log", "-1", "--format=%s"]).out === `Release ${vf.version}` && git(root, ["rev-parse", "-q", "--verify", `refs/tags/${prefix}${vf.version}`]).code !== 0) {
const cl = existsSync(join(root, "CHANGELOG.md")) ? readFileSync(join(root, "CHANGELOG.md"), "utf8") : ""
const sec = new RegExp(`^## \\[?${vf.version.replace(/\./g, "\\.")}\\]?[^\\n]*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "m").exec(cl)?.[1]?.trim()
return { root, name: projectName(root), from: vf.version, to: vf.version, tag: `${prefix}${vf.version}`, versionFile: vf, sign: signs(root), tagOnly: true, notes: sec }
}
let to: string
try {
to = nextVersion(vf.version, how.trim())
} catch (e) {
return { blocked: (e as Error).message }
}
const tag = `${prefix}${to}`
if (git(root, ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]).code === 0) return { blocked: `the tag ${tag} already exists` }
const dirty = git(root, ["status", "--porcelain", "--untracked-files=no"]).out
if (dirty) return { blocked: `the working tree has uncommitted changes — commit them first:\n${dirty}` }
const cl = existsSync(join(root, "CHANGELOG.md")) ? releaseChangelog(readFileSync(join(root, "CHANGELOG.md"), "utf8"), to, today()) : undefined
return { root, name: projectName(root), from: vf.version, to, tag, versionFile: vf, changelog: cl, sign: signs(root) }
}
/** Do it: write, commit by name, tag. Returns what happened, line by line. */
export function doRelease(p: ReleasePlan, push = false): { ok: boolean; lines: string[] } {
const lines: string[] = []
// The plan may be minutes old: the tree and the version must still be what it saw.
const dirty = git(p.root, ["status", "--porcelain", "--untracked-files=no"]).out
if (dirty) return { ok: false, lines: [`the working tree changed since the plan — nothing done:\n${dirty}`] }
if (findVersion(p.root)?.version !== p.from) return { ok: false, lines: ["the version changed since the plan — nothing done; run /release again"] }
const files = [p.versionFile.file]
if (p.from !== p.to) {
writeFileSync(join(p.root, p.versionFile.file), p.versionFile.with(p.to))
for (const a of p.versionFile.also ?? []) {
writeFileSync(join(p.root, a.file), a.with(p.to))
files.push(a.file)
}
}
if (p.changelog) {
writeFileSync(join(p.root, "CHANGELOG.md"), p.changelog.text)
files.push("CHANGELOG.md")
}
const add = git(p.root, ["add", "--", ...files])
if (add.code) return { ok: false, lines: [`git add: ${add.err}`] }
if (git(p.root, ["diff", "--cached", "--quiet"]).code !== 0) {
const c = git(p.root, ["commit", "-q", "-m", `Release ${p.to}`])
if (c.code) return { ok: false, lines: [`commit failed: ${c.err || c.out}`] }
lines.push(`committed ${git(p.root, ["log", "-1", "--format=%h %s"]).out}`)
}
const message = `${p.name} ${p.to}\n\n${p.changelog?.notes ?? p.notes ?? ""}`.trim() + "\n"
// verbatim: by default git strips every line starting with "#" — the "### Added" headings.
const t = git(p.root, ["tag", p.sign ? "-s" : "-a", "--cleanup=verbatim", p.tag, "-F", "-"], { input: message })
if (t.code) return { ok: false, lines: [...lines, `tag failed: ${t.err || t.out}`] }
lines.push(`tagged ${p.tag}${p.sign ? " (signed)" : ""}`)
if (push) {
// The tag goes where the branch goes.
const remote = git(p.root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).out.split("/")[0] || "origin"
const branch = git(p.root, ["push", "-q"])
const tag = branch.code ? branch : git(p.root, ["push", "-q", remote, p.tag])
if (tag.code) return { ok: false, lines: [...lines, `push failed: ${tag.err || tag.out}`] }
lines.push(`pushed the branch and ${p.tag}`)
} else lines.push(`nothing pushed — when ready: git push && git push ${git(p.root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).out.split("/")[0] || "origin"} ${p.tag}`)
return { ok: true, lines }
}