ludic/tools/ludic-cli/forgejo.ludic
Orkuncakilkaya 55b0e2ebf6 feat(windows): the ludic CLI and ludic bundle on Windows
The CLI's shell commands go through shell(), which is run() on POSIX and a
scratch script handed to Git for Windows' bash on Windows (exit codes read
directly there). compile_app links through `ludicc -o` on Windows, ludic run
starts the .exe, and ludic-dev build and ensure_ludicc assemble
selfhost/ludicc.win.seed.ll, which ludic-dev reseed now writes beside the
macOS seed. ludic bundle makes build/<name>/ with a GUI-subsystem exe carrying
the .ico beside `app icon` as an llvm-rc resource, game.lpak and packs.index;
ludicc gains --gui and --link, and quotes its whole link line for cmd.exe.

Verified: ludic-dev test 135/135, selfhost-test 32/32; on the PC a checkout
bootstraps from the Windows seed, ludic-dev build makes the toolchain, and
`ludic bundle` makes build/Maroon Lake/, which runs from its own folder.

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

93 lines
4.2 KiB
Text

# forgejo.ludic — publish a release on the Forgejo instance: create the release
# for a tag (or reuse it), then attach every file from an assets directory.
# Replaces tools/ci/forgejo_release.py: the one piece of the release flow that
# speaks HTTP now does so through curl, driven from Ludic like every other
# external tool the runner uses.
#
# The API root comes from $LUDIC_FORGEJO_API (default: this project's repo);
# the token from $FORGEJO_TOKEN, passed to curl through a 0600 header file so
# it never appears on a command line.
#
# Idempotent-ish: an existing release for the tag is reused, and an asset whose
# name is already attached is skipped, not fatal.
const FORGEJO_API_DEFAULT: string = "https://git.workshopsoft.io/api/v1/repos/workshopsoft/ludic"
function forgejo_api() -> pointer { return getenv_or("LUDIC_FORGEJO_API", FORGEJO_API_DEFAULT) }
# the curl header file carrying the token (written once per process)
var forgejo_hdr: pointer = null
function forgejo_headers() -> pointer {
if forgejo_hdr == null {
forgejo_hdr = tmp_path("forgejo_headers.txt")
shell(`umask 077 && printf 'Authorization: token %s\n' "$FORGEJO_TOKEN" > {forgejo_hdr}`)
}
return forgejo_hdr
}
# one API call: `extra` carries the method/body flags; returns the HTTP status
# and leaves the response body in `out`
function forgejo_call(path: pointer, extra: pointer, out: pointer) -> int {
let code = capture_line(`curl -sS -o {out} -w '%{{http_code}}' -H @{forgejo_headers()} {extra} '{forgejo_api()}{path}'`)
return str_to_int(code)
}
# create-or-reuse the release for `tag`, with `body_file` as its notes; returns
# the release JSON, or null after printing why
function forgejo_release(tag: pointer, body_file: pointer) -> JVal {
let out = tmp_path("forgejo_release.json")
let code = forgejo_call(`/releases/tags/{tag}`, "", out)
if code == 200 {
let rel = json_parse(read_file(out))
print(`release {tag} already exists (id {string(j_get(rel, "id").num)}); uploading assets`)
return rel
}
if code != 404 { err(`forgejo: GET /releases/tags/{tag} -> HTTP {string(code)}\n`); return null }
var body = ""
if shq(`test -f {body_file}`) { body = read_file(body_file) }
var prerelease = "false"
if Text.starts_with(tag, "v0.") { prerelease = "true" }
let req = tmp_path("forgejo_create.json")
write_file(req, `{{"tag_name":{j_quote(tag)},"name":{j_quote(tag)},"body":{j_quote(body)},"draft":false,"prerelease":{prerelease}}}`)
let ccode = forgejo_call("/releases", `-X POST -H 'Content-Type: application/json' --data-binary @{req}`, out)
if ccode != 201 { err(`forgejo: POST /releases -> HTTP {string(ccode)}\n`); return null }
let rel = json_parse(read_file(out))
print(`created release {tag} id {string(j_get(rel, "id").num)}`)
return rel
}
# attach every regular file in `dir` that the release does not already carry
function forgejo_upload_assets(rel: JVal, dir: pointer) -> bool {
if not shq(`test -d {dir}`) { return true }
let rid = j_get(rel, "id").num
let existing = new []pointer
let assets = j_get(rel, "assets")
var i = 0
while i < len(assets.kids) { push(existing, j_get(assets.kids[i], "name").s); i += 1 }
let names = Text.split(capture(`ls -1 {dir}`), "\n")
var ok_all = true
i = 0
while i < len(names) {
let name = Text.trim(names[i])
i += 1
if name == "" { continue }
if set_has(existing, name) { print(` skip (exists): {name}`); continue }
let path = `{dir}/{name}`
if not shq(`test -f {path}`) { continue }
let out = tmp_path("forgejo_asset.json")
let code = forgejo_call(`/releases/{string(rid)}/assets?name={name}`, `-X POST -F 'attachment=@{path}'`, out)
if code == 201 { print(` uploaded: {name} (HTTP {string(code)})`) }
else { err(` upload failed: {name} (HTTP {string(code)})\n`); ok_all = false }
}
return ok_all
}
# the whole flow; 0 on success
function forgejo_publish(tag: pointer, body_file: pointer, assets_dir: pointer) -> int {
if getenv_or("FORGEJO_TOKEN", "") == "" { err("forgejo: FORGEJO_TOKEN is not set\n"); return 1 }
let rel = forgejo_release(tag, body_file)
if rel == null { return 1 }
if not forgejo_upload_assets(rel, assets_dir) { return 1 }
print(`done: {forgejo_api()}/releases/tag/{tag}`)
return 0
}