ludic/tools/ludic-cli/release.ludic
Orkuncakilkaya f92d7f89c6 feat(tooling): JetBrains IDE support, LSP navigation, barrel imports, package scripts and hooks
- JetBrains plugin 1.4.0: semantic colours (builtin / vendor / own), template strings,
  brace handling, run configurations and a test console, package.ludic and
  package.lock.ludic editing (completion, docs, app preview, colour previews, asset
  navigation), External Libraries for the runtime and packages, doc pages for built-ins
- ludic-lsp: go to definition for imports, document links, hover with inferred types,
  type definition, signature help with parameters, docs from docs/language
- `import "dir"` resolves a barrel `dir/index.ludic`
- package.ludic `entry`, `script` and `hook before|after <command>`; `ludic <script>`,
  `ludic script`, `ludic scripts`
- `ludic test --verbose` and `--test NAME`; the test runner filters by name

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:13:31 +03:00

443 lines
18 KiB
Text

# release.ludic — versioning + release cutting for the toolchain.
#
# ludic version print the toolchain version (from the VERSION file)
# ludic-dev release [level] cut a release: aggregate changes/ into CHANGELOG.md,
# bump VERSION, commit, and tag vX.Y.Z. `level` is
# major|minor|patch; omitted, it is derived from the
# highest `bump:` among the pending changesets.
# ludic-dev release [level] --dry-run render the changelog section to stdout and
# stop: nothing is written, committed, tagged or pushed.
# ludic-dev release [level] --publish ...then push main + the tag and create a
# Forgejo release with source + toolchain tarballs.
# Needs FORGEJO_TOKEN in the environment.
#
# The scheme is SemVer. VERSION is the single source of truth (ludicc --version
# reads it at runtime), so a bump touches one file and never reseeds the
# compiler. Changesets live as one small Markdown file per change under changes/
# (see changes/README.md); a release consumes them into a CHANGELOG.md section.
# the current version string, or a fallback when VERSION is absent
function read_version_or(dflt: pointer) -> pointer {
let v = capture_line("cat VERSION 2>/dev/null")
if (v == "") { return dflt }
return v
}
# true when there is at least one pending changeset (changes/*.md, minus README)
function has_changesets() -> bool {
return capture_line("ls changes/*.md 2>/dev/null | grep -v '/README.md' | head -1") != ""
}
# the highest bump level requested across the pending changesets ("" if none).
# README.md is excluded — its format example carries a literal `bump:` line that
# must not count as a real changeset.
function highest_bump() -> pointer {
if shq("grep -rhqE '^bump:[[:space:]]*major' --include='*.md' --exclude='README.md' changes 2>/dev/null") { return "major" }
if shq("grep -rhqE '^bump:[[:space:]]*minor' --include='*.md' --exclude='README.md' changes 2>/dev/null") { return "minor" }
if shq("grep -rhqE '^bump:[[:space:]]*patch' --include='*.md' --exclude='README.md' changes 2>/dev/null") { return "patch" }
return ""
}
# cur + a SemVer bump of `level` -> the next version string
function compute_next(cur: pointer, level: pointer) -> pointer {
let maj = capture_line(`printf '%s' '{cur}' | cut -d. -f1`)
let min = capture_line(`printf '%s' '{cur}' | cut -d. -f2`)
let pat = capture_line(`printf '%s' '{cur}' | cut -d. -f3`)
if (level == "major") { let m2 = capture_line(`expr {maj} + 1`); return `{m2}.0.0` }
if (level == "minor") { let n2 = capture_line(`expr {min} + 1`); return `{maj}.{n2}.0` }
let p2 = capture_line(`expr {pat} + 1`)
return `{maj}.{min}.{p2}`
}
# ---- changelog rendering ----------------------------------------------------
#
# One changeset becomes one bullet. The body is markdown and is kept as markdown:
# the previous shell pipeline ran it through `tr '\n' ' '`, which collapsed every
# multi-line changeset into a single paragraph — a nested list came out as a run
# of inline " - " fragments, and a release with a dozen changesets read as one
# unbroken wall. Continuation lines are indented two spaces instead, so nested
# bullets and paragraphs stay inside their bullet.
property Changeset { typ: pointer = "", body: pointer = "" }
# The conventional-commit types, in the order a reader wants them: what is new,
# what is fixed, what got faster, then the housekeeping. A type not listed here
# still gets a group, appended after these in first-seen order.
function type_heading(t: pointer) -> pointer {
if t == "feat" { return "Features" }
if t == "fix" { return "Fixes" }
if t == "perf" { return "Performance" }
if t == "refactor" { return "Refactoring" }
if t == "docs" { return "Documentation" }
if t == "build" { return "Build" }
if t == "ci" { return "CI" }
if t == "test" { return "Tests" }
if t == "style" { return "Style" }
if t == "revert" { return "Reverts" }
if t == "chore" { return "Chores" }
return title_case(t)
}
function type_rank(t: pointer) -> int {
if t == "feat" { return 0 }
if t == "fix" { return 1 }
if t == "perf" { return 2 }
if t == "refactor" { return 3 }
if t == "docs" { return 4 }
if t == "build" { return 5 }
if t == "ci" { return 6 }
if t == "test" { return 7 }
if t == "style" { return 8 }
if t == "revert" { return 9 }
if t == "chore" { return 10 }
return 50
}
# strip trailing whitespace from a line
function rstrip(s: pointer) -> pointer {
var n = slen(s)
while n > 0 and is_space_all(s[n - 1]) { n -= 1 }
return sslice(s, 0, n)
}
# Parse one changeset file: the `type:`/`bump:` headers, then everything else as
# the body. Returns a Changeset with an empty body when the file is unreadable.
function read_changeset(path: pointer) -> Changeset {
let cs = new Changeset
cs.typ = ""
cs.body = ""
let text = read_file(path)
if text == null { return cs }
let b = sb_new()
let n = slen(text)
var i = 0
var seen_body = false
while i < n {
let ln = line_at(text, i)
i = i + slen(ln) + 1
if not seen_body and s_starts(ln, "type:") {
cs.typ = s_trim(sslice(ln, 5, slen(ln)))
continue
}
if not seen_body and s_starts(ln, "bump:") { continue }
# a leading blank line between the headers and the body is not body content
if not seen_body and slen(s_trim(ln)) == 0 { continue }
seen_body = true
sb_puts(b, rstrip(ln))
sb_putc(b, '\n')
}
# drop trailing blank lines
var body = sb_str(b)
var m = slen(body)
while m > 0 and is_space_all(body[m - 1]) { m -= 1 }
cs.body = sslice(body, 0, m)
if cs.typ == "" { cs.typ = "chore" }
return cs
}
# Render one changeset as a markdown list item: the first line after "- ", every
# following line indented two spaces so it stays within the item. Blank lines
# stay blank (an indented blank line is just trailing whitespace).
function render_bullet(b: Sb, body: pointer) -> void {
let n = slen(body)
var i = 0
var first = true
while i < n {
let ln = line_at(body, i)
i = i + slen(ln) + 1
if first { sb_puts(b, "- "); first = false }
else if slen(ln) == 0 { sb_putc(b, '\n'); continue }
else { sb_puts(b, " ") }
sb_puts(b, ln)
sb_putc(b, '\n')
}
}
# the changesets in `dir`, README.md excluded, in filename order
function load_changesets(dir: pointer) -> []Changeset {
let out = new []Changeset
let names = list_sorted(dir)
var i = 0
while i < len(names) {
let nm = names[i]
i += 1
if nm == "README.md" { continue }
if not s_ends(nm, ".md") { continue }
let cs = read_changeset(`{dir}/{nm}`)
if slen(cs.body) > 0 { push(out, cs) }
}
return out
}
# assemble the CHANGELOG.md section for `ver` from the pending changesets into
# the scratch file rel_section.md: a dated header, then one "### <Heading>"
# group per conventional-commit type, each holding its changesets as bullets.
function render_section(ver: pointer, date: pointer, dir: pointer) -> pointer {
let sets = load_changesets(dir)
let b = sb_new()
sb_puts(b, `## v{ver} — {date}\n`)
# walk the type groups in rank order, then any unranked type in first-seen order
let done = new []pointer
var emitted = 0
while emitted < len(sets) {
# pick the lowest-ranked type not yet emitted, ties broken by name
var best = ""
var bestrank = 0
var si = 0
while si < len(sets) {
let t = sets[si].typ
si += 1
var already = false
var di = 0
while di < len(done) { if done[di] == t { already = true }; di += 1 }
if already { continue }
let r = type_rank(t)
if best == "" or r < bestrank or (r == bestrank and str_gt(best, t)) { best = t; bestrank = r }
}
if best == "" { break }
push(done, best)
# collect this group's bodies and sort them, so a release is reproducible
let bodies = new []pointer
var k = 0
while k < len(sets) {
if sets[k].typ == best { push(bodies, sets[k].body) }
k += 1
}
strs_sort(bodies)
sb_puts(b, `\n### {type_heading(best)}\n\n`)
var j = 0
while j < len(bodies) {
render_bullet(b, bodies[j])
j += 1
emitted += 1
}
}
return sb_str(b)
}
function build_section(ver: pointer) -> void {
let date = capture_line("date +%Y-%m-%d")
write_file(tmp_path("rel_section.md"), render_section(ver, date, "changes"))
}
# ludic-dev changelog-render <version> <date> <dir> — print the CHANGELOG section that
# `dir`'s changesets would produce. Used to re-render the sections of releases
# cut before the renderer preserved markdown structure: check the changesets out
# of the tag's parent commit, point this at them, and splice the result back in.
function cmd_changelog_render() -> int {
if arg_count() < 5 {
err("usage: ludic-dev changelog-render <version> <date> <changesets-dir>\n")
return 1
}
out(render_section(arg(2), arg(3), arg(4)))
return 0
}
# prepend the scratch rel_section.md into CHANGELOG.md, above the first existing
# release section (or at the end of the header if this is the first release).
function prepend_changelog() -> void {
if not file_exists("CHANGELOG.md") {
shell("printf '# Changelog\n\nAll notable changes to the Ludic toolchain, newest first. Generated from the\nchangesets under changes/ by ludic-dev release; do not edit released sections by hand.\n\n' > CHANGELOG.md")
}
let ln = capture_line("grep -n '^## ' CHANGELOG.md | head -1 | cut -d: -f1")
if (ln == "") {
shell(`cat CHANGELOG.md {tmp_dir()}/rel_section.md > {tmp_dir()}/rel_new.md`)
} else {
shell(`head -n $(expr {ln} - 1) CHANGELOG.md > {tmp_dir()}/rel_new.md`)
shell(`cat {tmp_dir()}/rel_section.md >> {tmp_dir()}/rel_new.md`)
shell(`tail -n +{ln} CHANGELOG.md >> {tmp_dir()}/rel_new.md`)
}
shell(`cp {tmp_dir()}/rel_new.md CHANGELOG.md`)
}
# ---- release artifacts + publishing -----------------------------------------
# Extract one release's section out of CHANGELOG.md: everything from its
# "## vX.Y.Z" heading up to the next "## " heading. This is what a release's
# notes are — the notes and the changelog can then never disagree.
function changelog_section(ver: pointer) -> pointer {
let text = read_file("CHANGELOG.md")
if text == null { return "" }
let head = `## v{ver} `
let n = slen(text)
var i = 0
var start = -1
while i < n {
let ln = line_at(text, i)
let next = i + slen(ln) + 1
if start < 0 {
if s_starts(ln, head) { start = i }
} else {
if s_starts(ln, "## ") { return sslice(text, start, i) }
}
i = next
}
if start < 0 { return "" }
return sslice(text, start, n)
}
# ludic-dev changelog-section <version> — print that release's CHANGELOG section.
function cmd_changelog_section() -> int {
if arg_count() < 3 { err("usage: ludic-dev changelog-section <version>\n"); return 1 }
let sec = changelog_section(arg(2))
if slen(sec) == 0 { err(`changelog-section: no section for v{arg(2)} in CHANGELOG.md\n`); return 1 }
out(sec)
return 0
}
# the platform's SHA-256 tool (coreutils on Linux, shasum on macOS)
function sha256_cmd() -> pointer {
if shq("command -v sha256sum >/dev/null 2>&1") { return "sha256sum" }
return "shasum -a 256"
}
# Build the release artifacts into dist/: a reproducible source+seed tarball from
# the tag, a ready-to-run toolchain built on this host, and one `.sha256` beside
# each — a release without checksums asks everyone downstream to trust the
# transport.
#
# The toolchain tarball is a complete install root, not just binaries: bin/ next
# to the engine runtime, the bundled ludic.* packages and VERSION, laid out
# exactly as ludic_home() expects. install.sh unpacks it and is done; anything
# missing here is a game that will not link on a machine with no checkout.
#
# The checksums are per-artifact rather than a single SHA256SUMS on purpose. A
# release is assembled from more than one host (a Linux runner cannot build the
# macOS toolchain), and `forgejo_upload_assets` skips an asset whose name is
# already attached — so a shared SHA256SUMS would be written by whichever host
# published first and would then never cover anything added later. One file per
# artifact has a unique name, so each host's contribution stands on its own.
#
# shasum -a 256 -c ludic-X.Y.Z-src.tar.gz.sha256
function build_artifacts(ver: pointer) -> bool {
shell("rm -rf dist && mkdir -p dist")
if not shq(`git archive --format=tar.gz --prefix=ludic-{ver}/ -o dist/ludic-{ver}-src.tar.gz v{ver}`) {
err(`release: git archive of v{ver} failed (is the tag present?)\n`)
return false
}
if is_exec("bin/ludicc") {
let plat = capture_line("uname -s | tr '[:upper:]' '[:lower:]'")
let arch = capture_line("uname -m")
let name = `ludic-{ver}-{plat}-{arch}`
let stage = `{tmp_dir()}/{name}`
shell(`rm -rf {stage} && mkdir -p {stage}/bin`)
if not shq(`cp bin/ludic bin/ludicc bin/ludic-fmt bin/ludic-lsp {stage}/bin/`) {
err("release: the toolchain is not built (run: ludic-dev build)\n")
return false
}
shell(`cp -R runtime {stage}/runtime`)
shell(`cp -R packages {stage}/packages`)
# the language reference: ludic-lsp indexes it for hover and go-to-definition
# on built-ins (docs/language/**.md, ~4 MB of markdown)
shell(`mkdir -p {stage}/docs && cp -R docs/language {stage}/docs/language`)
shell(`cp VERSION LICENSE README.md {stage}/`)
# the Linux link needs the Darwin-stdio shim injected through $LUDIC_CC; it
# travels with the toolchain that needs it (see tools/ci/linux_stdio_shim.ll).
if plat == "linux" { shell(`mkdir -p {stage}/lib && cp tools/ci/linux_stdio_shim.ll {stage}/lib/`) }
if not shq(`tar -czf dist/{name}.tar.gz -C {tmp_dir()} {name}`) {
err("release: building the toolchain tarball failed\n")
return false
}
shell(`rm -rf {stage}`)
}
if not shq(`cd dist && for f in *.tar.gz; do {sha256_cmd()} "$f" > "$f.sha256" || exit 1; done`) {
err("release: writing the per-artifact .sha256 files failed\n")
return false
}
shell("ls -l dist")
return true
}
# ludic-dev publish [vX.Y.Z] — publish an already-tagged release: build the artifacts,
# take the notes from that version's CHANGELOG section, and create the Forgejo
# release. Defaults to the version in VERSION. This is what CI runs on a tag
# push, and what `ludic-dev release --publish` calls once it has tagged.
function publish_tag(ver: pointer) -> int {
if getenv_or("FORGEJO_TOKEN", "") == "" { err("publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 }
let body = tmp_path("rel_notes.md")
let sec = changelog_section(ver)
if slen(sec) == 0 { err(`publish: no CHANGELOG section for v{ver}\n`); return 1 }
write_file(body, sec)
if not build_artifacts(ver) { return 1 }
if forgejo_publish(`v{ver}`, body, "dist") != 0 {
err("publish: creating the Forgejo release failed\n")
return 1
}
return 0
}
function cmd_publish() -> int {
var ver = read_version_or("")
if arg_count() >= 3 {
var a = arg(2)
if s_starts(a, "v") { a = sslice(a, 1, slen(a)) }
ver = a
}
if ver == "" { err("publish: no version given and no VERSION file\n"); return 1 }
return publish_tag(ver)
}
# push main + the tag and create the Forgejo release with build artifacts.
function publish_release(ver: pointer) -> int {
if getenv_or("FORGEJO_TOKEN", "") == "" { err("release --publish: set FORGEJO_TOKEN (a Forgejo access token)\n"); return 1 }
if not shq("git push origin HEAD") { err("release: git push (main) failed\n"); return 1 }
if not shq(`git push origin v{ver}`) { err("release: git push (tag) failed\n"); return 1 }
return publish_tag(ver)
}
function cmd_release() -> int {
# parse args: an optional level positional, and a --publish flag
var level = ""
var publish = false
var dry = false
var ai = 2
while ai < arg_count() {
let a = arg(ai)
if (a == "--publish") { publish = true }
else { if (a == "--dry-run") { dry = true }
else { if (a == "major") or (a == "minor") or (a == "patch") { level = a }
else { err(`release: unknown argument {a}\n`); return 1 } } }
ai += 1
}
if not has_changesets() {
err("release: no changesets under changes/ — add one (see changes/README.md)\n"); return 1
}
if (level == "") { level = highest_bump() }
if (level == "") { err("release: no bump: level in any changeset\n"); return 1 }
let cur = read_version_or("0.0.0")
let ver = compute_next(cur, level)
print(`releasing v{ver} ({level} bump from {cur})`)
build_section(ver)
# --dry-run stops here: the section is rendered to stdout and nothing on disk,
# in git, or on the remote is touched. This is the cheap way to read a release
# before cutting it, which the old all-or-nothing command had no answer for.
if dry {
print("")
let sec = read_file(tmp_path("rel_section.md"))
if sec != null { out(sec) }
print("")
print(` dry run — nothing written. cut it with: ludic-dev release {level}`)
return 0
}
prepend_changelog()
if not write_file("VERSION", `{ver}\n`) { err("release: cannot write VERSION\n"); return 1 }
shell("rm -f $(ls changes/*.md | grep -v '/README.md')")
# stage only the release artifacts — never a blanket `git add -A`, which would
# sweep unrelated in-flight edits (this tree is worked on concurrently).
if not shq("git add VERSION CHANGELOG.md changes") { err("release: git add failed\n"); return 1 }
if not shq(`git commit -q -m 'chore(release): v{ver}'`) { err("release: git commit failed\n"); return 1 }
if not shq(`git tag v{ver}`) { err("release: git tag failed (already exists?)\n"); return 1 }
print(` committed + tagged v{ver}`)
if publish { return publish_release(ver) }
print(` local release ready. publish with: FORGEJO_TOKEN=… ludic-dev release {level} --publish`)
print(` (or push: git push origin HEAD && git push origin v{ver})`)
return 0
}