- 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>
443 lines
18 KiB
Text
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
|
|
}
|