First Commit

This commit is contained in:
2026-08-06 12:54:48 -04:00
commit dd01ccb258
15 changed files with 1780 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
-- builder.lua -- source fetching, extraction, build dispatch, and test runner.
--
-- Handles the full build pipeline for a recipe:
-- 1. fetch_source — download to cache (or detect git)
-- 2. extract_source — unpack tarball or clone git repo
-- 3. run_build — dispatch autotools/cmake/meson/make/cargo/custom
-- 4. run_test — execute post-build verification command
--
-- All subprocesses run in a minimal environment. Build tools (meson, cmake,
-- ninja, cargo, etc.) must be installed on the host system.
local builder = {}
local path = require("path")
local log = require("log")
---------------------------------------------------------------------------
-- Utilities
---------------------------------------------------------------------------
local function find_downloader()
if path.have("curl") then return "curl" end
if path.have("wget") then return "wget" end
return nil
end
local function download(url, dest)
local dl = find_downloader()
if not dl then
error("no downloader found (install curl or wget)", 0)
end
log.detail("downloading " .. url)
local tmp = dest .. ".part"
local ok
if dl == "curl" then
ok = path.run("curl -L --fail --show-error -sS -o "
.. path.quote(tmp) .. " " .. path.quote(url))
else
ok = path.run("wget -q -O " .. path.quote(tmp) .. " " .. path.quote(url))
end
if not ok then
os.remove(tmp)
error(("download failed: %s"):format(url), 0)
end
return os.rename(tmp, dest)
end
local function sha256_file(filepath)
-- Prefer native tools for speed, fall back to openssl.
local f = io.popen("sha256sum " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local digest = f:read("*l")
f:close()
local hex = digest and digest:match("^(%x+)")
if hex then return hex end
end
f = io.popen("shasum -a 256 " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local digest = f:read("*l")
f:close()
local hex = digest and digest:match("^(%x+)")
if hex then return hex end
end
f = io.popen("openssl dgst -sha256 " .. path.quote(filepath) .. " 2>/dev/null")
if f then
local digest = f:read("*l")
f:close()
local hex = digest and digest:match("=(%x+)")
if hex then return hex end
end
error("sha256: no supported tool found (install sha256sum, shasum, or openssl)", 0)
end
local function get_nproc()
local f = io.popen("nproc 2>/dev/null")
if not f then return 1 end
local n = tonumber(f:read("*l"))
f:close()
return (n and n > 0) and n or 1
end
-- After extracting a tarball, detect the source root:
-- if the extraction dir contains exactly one subdirectory, use that.
-- Otherwise use the extraction dir itself.
local function detect_source_dir(extract_dir)
local f = io.popen("ls -1 " .. path.quote(extract_dir) .. " 2>/dev/null")
if not f then return extract_dir end
local entries = {}
for line in f:lines() do
if line ~= "" and line ~= "." and line ~= ".." then
entries[#entries + 1] = line
end
end
f:close()
if #entries == 1 then
return path.join(extract_dir, entries[1])
end
return extract_dir
end
---------------------------------------------------------------------------
-- Public API
---------------------------------------------------------------------------
-- Fetch source into the cache directory. Returns the cached file path,
-- or nil if the URL is a git repository (signals "use git clone").
function builder.fetch_source(recipe, cache_dir)
local url = recipe.url
-- Git repos are cloned, not cached as files.
if url:match("%.git$") or url:match("^git://") or url:match("^git@") then
log.detail("git source: will clone directly")
return nil
end
local url_base = url:match("[^/]+$") or "source"
local ext = ""
for _, e in ipairs({ ".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".tgz", ".txz", ".tbz2", ".tzst" }) do
if url_base:lower():match(e:gsub("%.", "%%.") .. "$") then
ext = e
break
end
end
local cache_file = path.join(cache_dir, recipe.name .. "-" .. recipe.version .. ext)
if not path.exists(cache_file) then
path.mkdir_p(cache_dir)
if url:match("^https?://") then
log.step("downloading " .. url)
download(url, cache_file)
elseif url:match("^file://") then
local src = url:gsub("^file://", "")
log.step("copying " .. src)
if not path.run("cp " .. path.quote(src) .. " " .. path.quote(cache_file)) then
error(("failed to copy %s"):format(src), 0)
end
elseif url:match("^/") then
log.step("copying " .. url)
if not path.run("cp " .. path.quote(url) .. " " .. path.quote(cache_file)) then
error(("failed to copy %s"):format(url), 0)
end
else
error(("unrecognised url scheme: %s"):format(url), 0)
end
else
log.info("using cached source: " .. path.basename(cache_file))
end
-- Optional source checksum verification.
if recipe.sha256 then
log.step("verifying source sha256")
local computed = sha256_file(cache_file)
if computed ~= recipe.sha256 then
error(("source sha256 mismatch:\n expected %s\n got %s"):format(
recipe.sha256, computed), 0)
end
log.ok("source sha256 verified")
end
return cache_file
end
-- Extract a source tarball or clone a git repository into work_dir/src.
-- Returns the actual source root directory.
function builder.extract_source(recipe, cache_file, work_dir)
local src_dir = path.join(work_dir, "src")
if not cache_file then
-- Git clone.
path.mkdir_p(work_dir)
log.step("cloning " .. recipe.url)
if not path.run("git clone --depth 1 " .. path.quote(recipe.url)
.. " " .. path.quote(src_dir)) then
error(("git clone failed for %s"):format(recipe.url), 0)
end
log.ok("cloned " .. recipe.name)
return src_dir
end
-- Tarball extraction.
log.step("extracting " .. path.basename(cache_file))
path.mkdir_p(src_dir)
local fname = cache_file:lower()
local cmd
if fname:match("%.tar%.gz$") or fname:match("%.tgz$") then
cmd = "tar -xzf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
elseif fname:match("%.tar%.xz$") or fname:match("%.txz$") then
cmd = "tar -xJf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
elseif fname:match("%.tar%.bz2$") or fname:match("%.tbz2$") then
cmd = "tar -xjf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
elseif fname:match("%.tar%.zst$") or fname:match("%.tzst$") then
cmd = "tar --zstd -xf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
elseif fname:match("%.tar$") then
cmd = "tar -xf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
else
-- Unrecognised archive — try generic tar.
cmd = "tar -xf " .. path.quote(cache_file) .. " -C " .. path.quote(src_dir) .. " --no-same-owner"
end
if not path.run(cmd) then
error(("extraction failed for %s"):format(path.basename(cache_file)), 0)
end
local source_dir = detect_source_dir(src_dir)
log.detail("source directory: " .. source_dir)
return source_dir
end
-- Dispatch the build based on recipe.build_system.
-- Commands run from the source directory, installing into stage_dir.
function builder.run_build(recipe, source_dir, stage_dir, opts)
opts = opts or {}
path.mkdir_p(stage_dir)
local name = recipe.name
local bs = recipe.build_system
local args = recipe.configure_args or {}
local args_str = ""
if #args > 0 then
args_str = " " .. table.concat(args, " ")
end
local njobs = opts.jobs or get_nproc()
log.step(("building %s (%s)"):format(name, bs))
local function run(cmd)
log.detail("$ " .. cmd)
if not path.run("cd " .. path.quote(source_dir) .. " && " .. cmd) then
error(("build step failed: %s"):format(cmd), 0)
end
end
if bs == "autotools" then
run("./configure --prefix=/usr" .. args_str)
run("make -j" .. njobs)
run("make install DESTDIR=" .. path.quote(stage_dir))
elseif bs == "cmake" then
run("cmake -B build -DCMAKE_INSTALL_PREFIX=/usr" .. args_str)
run("cmake --build build -j" .. njobs)
run("DESTDIR=" .. path.quote(stage_dir) .. " cmake --install build")
elseif bs == "meson" then
run("meson setup build --prefix=/usr" .. args_str)
run("ninja -C build")
run("DESTDIR=" .. path.quote(stage_dir) .. " ninja -C build install")
elseif bs == "make" then
run("make -j" .. njobs .. args_str)
run("make install DESTDIR=" .. path.quote(stage_dir))
elseif bs == "cargo" then
run("cargo build --release" .. args_str)
local bin_dir = path.join(stage_dir, "usr/bin")
path.mkdir_p(bin_dir)
-- Cargo puts binaries in target/release/<name>. Also support kebab-case.
local target = path.join(source_dir, "target", "release", name)
if not path.exists(target) then
-- Try kebab-case transformation: underscores → hyphens.
local kebab = name:gsub("_", "-")
target = path.join(source_dir, "target", "release", kebab)
end
if not path.exists(target) then
-- Try listing directory.
local f = io.popen("ls " .. path.quote(path.join(source_dir, "target", "release")) .. " 2>/dev/null")
if f then
for line in f:lines() do
local cand = path.join(source_dir, "target", "release", line)
if path.run("test -f " .. path.quote(cand) .. " -a -x " .. path.quote(cand)) then
target = cand
log.detail("detected cargo binary: " .. line)
break
end
end
f:close()
end
end
run("install -D " .. path.quote(target) .. " " .. path.quote(path.join(bin_dir, path.basename(target))))
elseif bs == "custom" then
local script = recipe.build_script
run("DESTDIR=" .. path.quote(stage_dir) .. " sh " .. path.quote(script))
else
error(("unknown build_system: %s"):format(bs), 0)
end
log.ok("build complete")
return true
end
-- Run the test command, substituting ${DESTDIR} with stage_dir.
function builder.run_test(test_cmd, stage_dir)
local cmd = test_cmd:gsub("${DESTDIR}", stage_dir)
cmd = cmd:gsub("$DESTDIR", stage_dir)
log.step("testing: " .. cmd)
if not path.run(cmd) then
error(("test failed: %s"):format(cmd), 0)
end
log.ok("test passed")
return true
end
return builder