ludic/tools/x/forgejo.ludic
Orkuncakilkaya 8fed9add66 refactor(x): per-process scratch dirs, Ludic ports of the LSP test and Forgejo release
- every scratch file lives in `$TMPDIR/x_<pid>/` (tmp_dir/tmp_path in
  prelude.ludic), removed by main's new dispatch() → tmp_cleanup();
  X_KEEP_TMP=1 keeps it. `x test` and `x check-*` may now run together.
- `x test-lsp` (tools/x/lsp_test.ludic) replaces tools/test-lsp.py: the
  whole request stream is framed into one stdin file, the server runs to
  `exit`, and the response stream is parsed back by request id; adds a
  check that ludicc's own error is published on save
- tools/x/forgejo.ludic replaces tools/ci/forgejo_release.py (curl with a
  0600 header file; the token is no longer on the command line;
  LUDIC_FORGEJO_API for forks)
- `x docs-palette --check` regenerates into scratch and compares, so the
  drift guard judges the working tree rather than git HEAD; the generator
  no longer emits a trailing blank line the formatter rejects
- `x test-tools`: exit 2 from test-lsp / test-grammar.js is a visible
  skip, never a pass; the widget-prop test uses the `id: Root` syntax
- tools/test-grammar.js: current vocabulary (property/model/handler/
  prefab/scene/event/become/@Queries), LUDIC_NODE_MODULES, exit 2 on skip
- tools/atlas.ludic rewritten in the current language (it did not compile)
- operators.ludic / os.ludic registered in the suite
- json.ludic: j_quote() writer helper

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:12:26 +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")
run(`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
}