# 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() -> string { 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 "### " # 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 — 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 \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 — print that release's CHANGELOG section. function cmd_changelog_section() -> int { if arg_count() < 3 { err("usage: ludic-dev changelog-section \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 bin/ludic-ui-preview {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 }