build_artifacts wrote a single dist/SHA256SUMS covering whatever that host happened to build. But a release is assembled from more than one machine — the Linux runner cannot produce the darwin-arm64 toolchain — and forgejo_upload_assets deliberately skips an asset whose name is already attached. So the first host to publish wrote SHA256SUMS, and every artifact added later was silently left uncovered by it. Emit one <artifact>.sha256 per tarball instead. The names are unique, so each host's contribution stands on its own and nothing goes stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
430 lines
17 KiB
Text
430 lines
17 KiB
Text
# release.ludic — versioning + release cutting for the toolchain.
|
|
#
|
|
# x version print the toolchain version (from the VERSION file)
|
|
# x 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.
|
|
# x release [level] --dry-run render the changelog section to stdout and
|
|
# stop: nothing is written, committed, tagged or pushed.
|
|
# x 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}`
|
|
}
|
|
|
|
# x version — report the toolchain version. Prefer the compiler's own --version
|
|
# (proving that path works); fall back to the file if ludicc is not built yet.
|
|
function cmd_version() -> int {
|
|
if is_exec("bin/ludicc") {
|
|
run("LUDIC_HOME=. bin/ludicc --version")
|
|
return 0
|
|
}
|
|
print(`ludic {read_version_or("(version unknown)")}`)
|
|
return 0
|
|
}
|
|
|
|
# ---- 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"))
|
|
}
|
|
|
|
# x 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: x 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") {
|
|
run("printf '# Changelog\n\nAll notable changes to the Ludic toolchain, newest first. Generated from the\nchangesets under changes/ by x 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 == "") {
|
|
run(`cat CHANGELOG.md {tmp_dir()}/rel_section.md > {tmp_dir()}/rel_new.md`)
|
|
} else {
|
|
run(`head -n $(expr {ln} - 1) CHANGELOG.md > {tmp_dir()}/rel_new.md`)
|
|
run(`cat {tmp_dir()}/rel_section.md >> {tmp_dir()}/rel_new.md`)
|
|
run(`tail -n +{ln} CHANGELOG.md >> {tmp_dir()}/rel_new.md`)
|
|
}
|
|
run(`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)
|
|
}
|
|
|
|
# x changelog-section <version> — print that release's CHANGELOG section.
|
|
function cmd_changelog_section() -> int {
|
|
if arg_count() < 3 { err("usage: x 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, the toolchain built on this host, and one `.sha256` beside each — a
|
|
# release without checksums asks everyone downstream to trust the transport.
|
|
#
|
|
# 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 {
|
|
run("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")
|
|
if not shq(`tar -czf dist/ludic-{ver}-{plat}-{arch}.tar.gz bin selfhost/ludicc.seed.ll VERSION`) {
|
|
err("release: building the toolchain tarball failed\n")
|
|
return false
|
|
}
|
|
}
|
|
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
|
|
}
|
|
run("ls -l dist")
|
|
return true
|
|
}
|
|
|
|
# x 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 `x 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: x release {level}`)
|
|
return 0
|
|
}
|
|
prepend_changelog()
|
|
if not write_file("VERSION", `{ver}\n`) { err("release: cannot write VERSION\n"); return 1 }
|
|
run("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=… x release {level} --publish`)
|
|
print(` (or push: git push origin HEAD && git push origin v{ver})`)
|
|
return 0
|
|
}
|