feat(release): SemVer + ludicc --version, changesets, and x release
Some checks failed
commit-lint / conventional-commits (push) Waiting to run
bootstrap / cfree-fixpoint (push) Successful in 13s
ci / build-and-test (push) Has been cancelled

The project had no versioning discipline: 0 tags, no CHANGELOG, no way for the
compiler to report a version. Add a lightweight, native release flow.

- Versioning: SemVer, with VERSION as the single source of truth. `ludicc
  --version` (and `ludic --version`) read it at runtime — so a bump touches one
  file and never reseeds the compiler. `x version` reports it too.
- Changesets: one small Markdown file per user-facing change under changes/
  (bump level + type + summary; see changes/README.md). This replaces "remember
  to edit the changelog" with a mergeable artifact, no Node changeset tool.
- `x release [major|minor|patch] [--publish]`: fold the pending changesets into a
  new CHANGELOG.md section (grouped by type), bump VERSION, commit, and tag
  vX.Y.Z. The level defaults to the highest changeset bump. `--publish` also
  pushes and creates the Forgejo release with source + toolchain tarballs;
  tools/ci/forgejo_release.py is the small stdlib-Python HTTP glue for the
  release API (a native Http client is issue #6).

Seed the initial changesets describing the shipped surface; the first `x release`
turns them into the v0.1.0 CHANGELOG. Reseeded for the --version flag; C-free
bootstrap fixpoint holds; suites 56 / 29 / 29 on macOS, 51 / 28 (+skips) on Linux
CI, bootstrap-cfree byte-identical on both.

Part of the repository-cleanup / DX pass (with #32, #34).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 23:46:59 +03:00
parent 709465cdd8
commit fed80f2152
15 changed files with 787 additions and 293 deletions

107
tools/ci/forgejo_release.py Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Create a Forgejo release and attach build artifacts.
forgejo_release.py <tag> <body-file> <assets-dir>
Reads the access token from $FORGEJO_TOKEN. `x release --publish` calls this
after it has pushed the tag; it is the one piece of the release flow that speaks
HTTP, which the Ludic task runner cannot do yet (a native Http client is issue
#6). Python stdlib only — no third-party deps — matching the docgen tooling.
Idempotent-ish: if the release for <tag> already exists it is reused, and each
asset in <assets-dir> is uploaded (a duplicate name is skipped, not fatal).
"""
import json
import os
import sys
import urllib.request
import urllib.error
API = "https://git.workshopsoft.io/api/v1/repos/workshopsoft/ludic"
def token():
t = os.environ.get("FORGEJO_TOKEN", "").strip()
if not t:
sys.exit("forgejo_release: FORGEJO_TOKEN is not set")
return t
def api(method, path, tok, data=None, headers=None, raw=False):
url = API + path
h = {"Authorization": "token " + tok}
body = None
if data is not None and not raw:
h["Content-Type"] = "application/json"
body = json.dumps(data).encode()
elif raw:
body = data
h.update(headers or {})
req = urllib.request.Request(url, data=body, headers=h, method=method)
with urllib.request.urlopen(req) as r:
return r.getcode(), json.loads(r.read().decode() or "null")
def get_release_by_tag(tag, tok):
try:
_, rel = api("GET", "/releases/tags/" + tag, tok)
return rel
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
def main():
if len(sys.argv) != 4:
sys.exit(__doc__)
tag, body_file, assets_dir = sys.argv[1], sys.argv[2], sys.argv[3]
tok = token()
body = ""
if os.path.exists(body_file):
with open(body_file, encoding="utf-8") as fh:
body = fh.read()
rel = get_release_by_tag(tag, tok)
if rel is None:
_, rel = api("POST", "/releases", tok, {
"tag_name": tag,
"name": tag,
"body": body,
"draft": False,
"prerelease": tag.startswith("v0."),
})
print("created release", tag, "id", rel["id"])
else:
print("release", tag, "already exists (id %s); uploading assets" % rel["id"])
rid = rel["id"]
existing = {a["name"] for a in (rel.get("assets") or [])}
if os.path.isdir(assets_dir):
for name in sorted(os.listdir(assets_dir)):
if name in existing:
print(" skip (exists):", name)
continue
path = os.path.join(assets_dir, name)
if not os.path.isfile(path):
continue
with open(path, "rb") as fh:
blob = fh.read()
boundary = "----ludicrelease7f3b2a"
payload = (
("--%s\r\n" % boundary)
+ ('Content-Disposition: form-data; name="attachment"; filename="%s"\r\n' % name)
+ "Content-Type: application/octet-stream\r\n\r\n"
).encode() + blob + ("\r\n--%s--\r\n" % boundary).encode()
code, _ = api(
"POST", "/releases/%s/assets?name=%s" % (rid, name), tok,
data=payload, raw=True,
headers={"Content-Type": "multipart/form-data; boundary=" + boundary},
)
print(" uploaded:", name, "(HTTP %d)" % code)
print("done:", API + "/releases/tag/" + tag)
if __name__ == "__main__":
main()

View file

@ -17,6 +17,7 @@ program X {
import "tools.ludic"
import "selfhost_test.ludic"
import "test.ludic"
import "release.ludic"
function usage() -> void {
print("x — the Ludic task runner (run from the repository root)")
@ -35,6 +36,11 @@ program X {
print(" x test-tools the editor-toolchain suite")
print(" x golden regenerate selfhost/golden/renders.sha256 (review with git diff)")
print("")
print("release:")
print(" x version print the toolchain version (ludicc --version)")
print(" x release [major|minor|patch] [--publish]")
print(" cut a release: CHANGELOG + VERSION bump + tag (+ Forgejo release)")
print("")
print("self-host internals:")
print(" x selfhost-build [ludicc] [out] assemble + compile the self-host compiler")
print(" x bootstrap the self-hosting fixpoint proof (seeded from bin/ludicc)")
@ -75,6 +81,8 @@ program X {
if (arg_count() < 5) { err("usage: x game-build <ludicc> <game.ludic> <out>\n"); exit(1) }
exit(cmd_game_build(arg(2), arg(3), arg(4)))
}
if (cmd == "version") or (cmd == "--version") or (cmd == "-v") { exit(cmd_version()) }
if (cmd == "release") { exit(cmd_release()) }
if (cmd == "help") or (cmd == "--help") or (cmd == "-h") { usage(); exit(0) }
err(`x: unknown command '{cmd}'\n`)

148
tools/x/release.ludic Normal file
View file

@ -0,0 +1,148 @@
# 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] --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
}
# assemble the CHANGELOG.md section for `ver` from the pending changesets into
# /tmp/x_rel_section.md (header + one bullet per changeset, sorted by type).
function build_section(ver: pointer) -> void {
let date = capture_line("date +%Y-%m-%d")
run(`printf '## v%s — %s\n\n' '{ver}' '{date}' > /tmp/x_rel_section.md`)
# one bullet per changeset: "- **<type>**: <body>" (body = the non-header text)
run("( for f in changes/*.md; do case \"$f\" in */README.md) continue;; esac; t=$(sed -n 's/^type:[[:space:]]*//p' \"$f\" | head -1); b=$(grep -vE '^(type|bump):' \"$f\" | sed '/^[[:space:]]*$/d' | tr '\\n' ' ' | sed 's/[[:space:]]*$//'); printf -- '- **%s**: %s\\n' \"$t\" \"$b\"; done | sort ) >> /tmp/x_rel_section.md")
run("printf '\n' >> /tmp/x_rel_section.md")
}
# prepend /tmp/x_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/x_rel_section.md > /tmp/x_rel_new.md")
} else {
run(`head -n $(expr {ln} - 1) CHANGELOG.md > /tmp/x_rel_new.md`)
run("cat /tmp/x_rel_section.md >> /tmp/x_rel_new.md")
run(`tail -n +{ln} CHANGELOG.md >> /tmp/x_rel_new.md`)
}
run("cp /tmp/x_rel_new.md CHANGELOG.md")
}
# push main + the tag and create the Forgejo release with build artifacts.
function publish_release(ver: pointer) -> int {
let tok = getenv_or("FORGEJO_TOKEN", "")
if (tok == "") { 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 }
# artifacts: a reproducible source+seed tarball, and the built macOS toolchain.
run("mkdir -p dist")
run(`git archive --format=tar.gz --prefix=ludic-{ver}/ -o dist/ludic-{ver}-src.tar.gz v{ver}`)
if is_exec("bin/ludicc") {
let plat = capture_line("uname -s | tr '[:upper:]' '[:lower:]'")
let arch = capture_line("uname -m")
run(`tar -czf dist/ludic-{ver}-{plat}-{arch}.tar.gz bin selfhost/ludicc.seed.ll VERSION`)
}
# the changelog section written by build_section is the release body
if not shq(`FORGEJO_TOKEN='{tok}' python3 tools/ci/forgejo_release.py v{ver} /tmp/x_rel_section.md dist`) {
err("release: creating the Forgejo release failed\n"); return 1
}
return 0
}
function cmd_release() -> int {
# parse args: an optional level positional, and a --publish flag
var level = ""
var publish = false
var ai = 2
while ai < arg_count() {
let a = arg(ai)
if (a == "--publish") { publish = true }
else { if (a == "major") or (a == "minor") or (a == "patch") { level = a }
else { err(`release: unknown argument {a}\n`); return 1 } }
ai = 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)
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
}