Compare commits

..
5 Commits
Author SHA1 Message Date
huntedbytheirs 0b3ebd0f7a add: zig and build.sh
build systems
2026-08-23 16:48:51 -04:00
huntedbytheirs 4c701854f9 patch: support Configure in autotools 2026-08-19 16:47:46 -04:00
huntedbytheirs 551ba34cab patch: hooks from zeta 2026-08-17 19:23:32 -04:00
huntedbytheirs 159f7bfe83 feat: --follow for deps and a custom example
--follow now follows the deptree of the definition
--follow is incompatible with --all
build_system = "custom" example with build.sh
2026-08-07 15:17:47 -04:00
huntedbytheirs a42001b9d8 feat: --all batches and -w for multi building.
--all builds all the files in a specified directory.
-w/--workers specifies how many workers to send out to build multiple
packages at once. -j specifies how many jobs each worker has. defaults
to one.
2026-08-07 15:11:00 -04:00
15 changed files with 689 additions and 95 deletions
+17 -2
View File
@@ -31,13 +31,28 @@ return {
summary = "Short description", summary = "Short description",
url = "https://.../src.tar.gz", url = "https://.../src.tar.gz",
deps = { "libfoo", "libbar>=2.0" }, deps = { "libfoo", "libbar>=2.0" },
build_system = "autotools", -- autotools | cmake | meson | make | cargo | custom build_system = "autotools", -- autotools | cmake | meson | make | cargo | zig | build.sh | custom
configure_args = { "--enable-x" }, configure_args = { "--enable-x" },
test = "${DESTDIR}/usr/bin/mypkg --version", test = "${DESTDIR}/usr/bin/mypkg --version",
} }
``` ```
**Build systems**: `autotools`, `cmake`, `meson`, `make`, `cargo`, `custom` **Build systems**: `autotools`, `cmake`, `meson`, `make`, `cargo`, `zig`, `build.sh`, `custom`
## Post-transaction hooks
Packages may ship post-transaction hooks — pure-data Lua files under
`usr/share/zeta/hooks/*.hook` in the stage tree. A hook declares an `order`,
a `trigger` (e.g. `{ op = "install", type = "package", target = { "mypkg" } }`),
and an `action` (e.g. `{ when = "post", exec = "..." }`).
At build time `zeta-makepkg` scans the stage for `*.hook` files, validates each
one (they must load as a table carrying `trigger` and `action`), and merges
their paths into the generated `files` whitelist so hooks are committed
alongside the declared files. A malformed hook fails the build.
See [`toolchain/examples/hooks-demo/`](toolchain/examples/hooks-demo/hooks-demo.recipe)
for a working example.
## Structure ## Structure
+45 -85
View File
@@ -44,12 +44,10 @@ package.path = here .. "/../lib/?.lua;"
.. here .. "/../lib/zeta-toolchain/?.lua;" .. here .. "/../lib/zeta-toolchain/?.lua;"
.. package.path .. package.path
local recipe = require("recipe")
local builder = require("builder")
local packager = require("packager")
local indexer = require("indexer")
local path = require("path") local path = require("path")
local log = require("log") local log = require("log")
local build = require("build")
local indexer = require("indexer")
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
-- Help -- Help
@@ -60,10 +58,12 @@ zeta-makepkg — build Zeta binary packages from declarative .recipe files.
Usage: Usage:
zeta-makepkg <recipe> [flags] zeta-makepkg <recipe> [flags]
zeta-makepkg --all <dir> [flags]
zeta-makepkg --index [flags] zeta-makepkg --index [flags]
Commands: Commands:
<recipe> Build a package from a .recipe file <recipe> Build a package from a .recipe file
--all <dir> Build all .recipe files found in <dir>
--index Regenerate packages/index.lua from existing package.lua files --index Regenerate packages/index.lua from existing package.lua files
Flags: Flags:
@@ -72,8 +72,11 @@ Flags:
--repo <url> Base URL for generated package.lua manifests --repo <url> Base URL for generated package.lua manifests
(default: https://raw.githubusercontent.com/gretagen/...) (default: https://raw.githubusercontent.com/gretagen/...)
-j, --jobs <N> Number of parallel build jobs (default: nproc) -j, --jobs <N> Number of parallel build jobs (default: nproc)
-w, --workers <N> Number of parallel package builds for --all (default: 1)
--no-index Don't update packages/index.lua after building --no-index Don't update packages/index.lua after building
--keep-work Don't remove the build work directory --keep-work Don't remove the build work directory
--follow Build all dependencies first (bottom-up), then the package.
Incompatible with --all.
--force Overwrite existing package tarball --force Overwrite existing package tarball
--help Show this help --help Show this help
@@ -84,6 +87,7 @@ Cache:
Examples: Examples:
zeta-makepkg mypkg.recipe zeta-makepkg mypkg.recipe
zeta-makepkg mypkg.recipe --output ~/zeta-packages --force zeta-makepkg mypkg.recipe --output ~/zeta-packages --force
zeta-makepkg --all recipes/ --output ~/zeta-packages
zeta-makepkg --index --output ~/zeta-packages zeta-makepkg --index --output ~/zeta-packages
]] ]]
@@ -98,9 +102,12 @@ local function parse_cli(args)
keep_work = false, keep_work = false,
force = false, force = false,
index = false, index = false,
all = nil,
follow = false,
help = false, help = false,
recipe = nil, recipe = nil,
jobs = nil, -- nil = auto-detect (nproc) jobs = nil, -- nil = auto-detect (nproc)
workers = 1, -- parallel package builds (--all only)
repo = "https://raw.githubusercontent.com/gretagen/zeta-packages/refs/heads/main", repo = "https://raw.githubusercontent.com/gretagen/zeta-packages/refs/heads/main",
} }
@@ -111,6 +118,13 @@ local function parse_cli(args)
opts.help = true opts.help = true
elseif a == "--index" then elseif a == "--index" then
opts.index = true opts.index = true
elseif a == "--all" then
i = i + 1
if i > #args then
io.stderr:write("error: --all requires a directory argument\n")
return nil
end
opts.all = args[i]
elseif a == "--output" then elseif a == "--output" then
i = i + 1 i = i + 1
if i > #args then if i > #args then
@@ -131,6 +145,8 @@ local function parse_cli(args)
opts.keep_work = true opts.keep_work = true
elseif a == "--force" then elseif a == "--force" then
opts.force = true opts.force = true
elseif a == "--follow" then
opts.follow = true
elseif a == "--jobs" then elseif a == "--jobs" then
i = i + 1 i = i + 1
if i > #args then if i > #args then
@@ -158,6 +174,21 @@ local function parse_cli(args)
return nil return nil
end end
opts.jobs = n opts.jobs = n
elseif a == "--workers" then
i = i + 1
if i > #args then
io.stderr:write("error: --workers requires a number argument\n")
return nil
end
local n = tonumber(args[i])
if not n or n < 1 then
io.stderr:write(("error: invalid workers value %q\n"):format(args[i]))
return nil
end
opts.workers = n
elseif a:match("^%-w%d+$") then
local n = tonumber(a:match("^%-w(%d+)$"))
opts.workers = n
elseif a:match("^%-") then elseif a:match("^%-") then
io.stderr:write(("error: unknown flag %s\n"):format(a)) io.stderr:write(("error: unknown flag %s\n"):format(a))
return nil return nil
@@ -174,85 +205,7 @@ local function parse_cli(args)
end end
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
-- Build pipeline -- Dispatch
---------------------------------------------------------------------------
local function run_build(recipe_path, opts)
-- 1. Load recipe
local r, err = recipe.load(recipe_path)
if not r then
log.fatal(("recipe error: %s"):format(tostring(err)))
end
-- 2. Set up output directory
local packages_dir = path.join(opts.output, "packages")
local pkg_dir = path.join(packages_dir, r.name)
if path.exists(pkg_dir) and not opts.force then
log.fatal(("package %s already exists at %s (use --force to overwrite)"):format(
r.name, pkg_dir))
end
path.mkdir_p(pkg_dir)
-- 3. Work directories
local stamp = os.date("%Y%m%d-%H%M%S")
local work_dir = path.join("/tmp/zeta-makepkg", r.name .. "-" .. stamp)
local stage_dir = path.join(work_dir, "stage")
path.mkdir_p(stage_dir)
log.info(("work directory: %s"):format(work_dir))
-- 4. Cache directory
local cache_dir = path.join(os.getenv("HOME") or "/tmp", ".cache", "zeta-makepkg", "sources")
-- 5. Fetch source
local cache_file = builder.fetch_source(r, cache_dir)
-- 6. Extract source
local source_dir = builder.extract_source(r, cache_file, work_dir)
-- 7. Build
builder.run_build(r, source_dir, stage_dir, { jobs = opts.jobs })
-- 8. Test (optional)
if r.test then
builder.run_test(r.test, stage_dir)
else
log.info("no test command — skipping verification")
end
-- 9. Package
local tarball_path = path.join(pkg_dir, r.name .. "-" .. r.version .. ".tar.gz")
local tarball_sha256 = packager.create_tarball(stage_dir, tarball_path)
-- 10. Write package.lua manifest
packager.write_manifest(r, tarball_sha256, pkg_dir, opts.repo)
-- 11. Update index
if not opts.no_index then
indexer.generate(packages_dir)
else
log.info("--no-index: skipping index update")
end
-- 12. Cleanup
if not opts.keep_work then
path.run("rm -rf " .. path.quote(work_dir))
else
log.info(("--keep-work: work directory preserved at %s"):format(work_dir))
end
log.ok(("%s-%s built successfully"):format(r.name, r.version))
log.info(("output: %s"):format(pkg_dir))
end
local function run_index(opts)
local packages_dir = path.join(opts.output, "packages")
indexer.generate(packages_dir)
end
---------------------------------------------------------------------------
-- Main
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
local args = {} local args = {}
@@ -269,10 +222,17 @@ if opts.help then
os.exit(0) os.exit(0)
end end
if opts.follow and opts.all then
io.stderr:write("error: --follow and --all are incompatible\n")
os.exit(1)
end
if opts.index then if opts.index then
run_index(opts) indexer.generate(path.join(opts.output, "packages"))
elseif opts.all then
build.all(opts.all, opts, arg[0])
elseif opts.recipe then elseif opts.recipe then
local ok, err = pcall(run_build, opts.recipe, opts) local ok, err = pcall(build.single, opts.recipe, opts)
if not ok then if not ok then
log.fatal(tostring(err)) log.fatal(tostring(err))
end end
+3 -1
View File
@@ -227,6 +227,8 @@ local function wizard()
"meson (meson setup build && ninja && ninja install)", "meson (meson setup build && ninja && ninja install)",
"make (make && make install)", "make (make && make install)",
"cargo (cargo build --release)", "cargo (cargo build --release)",
"zig (zig build && zig build install --prefix /usr)",
"build.sh (sh build.sh with DESTDIR staging)",
"custom (run build_script)", "custom (run build_script)",
}) })
@@ -235,7 +237,7 @@ local function wizard()
-- 8. Configure args (skip for cargo) -- 8. Configure args (skip for cargo)
rcp.configure_args = {} rcp.configure_args = {}
if rcp.build_system ~= "cargo" then if rcp.build_system ~= "cargo" and rcp.build_system ~= "build.sh" then
io.write("Configure args" .. dim(" [comma-separated, e.g. --enable-feature, --without-x]") .. ": ") io.write("Configure args" .. dim(" [comma-separated, e.g. --enable-feature, --without-x]") .. ": ")
io.flush() io.flush()
local args_line = io.read("*l") local args_line = io.read("*l")
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# build.sh — example build.sh-style build script for zeta-makepkg.
#
# Invoked from the extracted source directory. The tool sets:
# $DESTDIR → staging tree root (e.g. /tmp/zeta-makepkg/buildsh-tool-.../stage)
#
# This script must compile/assemble the software and install every output
# file under $DESTDIR with standard FHS paths (usr/bin/, usr/lib/, etc.).
#
# For a real project you would replace the example below with the project's
# own build.sh — e.g. one that runs configure && make && make install with
# DESTDIR set, or any autotools-like shell build sequence.
set -eu
echo "==> Building buildsh-tool (build.sh)"
# Example: install a shell script into the staging tree
mkdir -p "$DESTDIR/usr/bin"
cat > "$DESTDIR/usr/bin/buildsh-tool" <<'EOF'
#!/bin/sh
echo "buildsh-tool 1.0"
EOF
chmod 755 "$DESTDIR/usr/bin/buildsh-tool"
echo "==> Install complete"
@@ -0,0 +1,23 @@
-- buildsh-demo.recipe — demonstrates build_system = "build.sh".
--
-- The build.sh build system runs the source tree's build.sh script from the
-- extracted source directory. The script receives $DESTDIR pointing at the
-- staging tree where installed files must be placed, exactly like the
-- 'custom' build system — but the script name is fixed to build.sh.
--
-- To try this:
-- 1. Unpack the source tarball: tar -xzf buildsh-tool-1.0.tar.gz
-- 2. Run the script manually: cd buildsh-tool-1.0 && DESTDIR=/tmp/stage sh build.sh
-- 3. Inspect the staged output: find /tmp/stage
-- 4. Build with makepkg: zeta-makepkg buildsh-demo.recipe
return {
name = "buildsh-tool",
version = "1.0",
summary = "Example build.sh-style package (shell-installed binary)",
url = "buildsh-tool-1.0.tar.gz",
sha256 = nil,
deps = {},
build_system = "build.sh",
test = "test -x ${DESTDIR}/usr/bin/buildsh-tool",
}
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# build.sh — example custom build script for zeta-makepkg.
#
# Invoked from the extracted source directory. The tool sets:
# $DESTDIR → staging tree root (e.g. /tmp/zeta-makepkg/mytool-.../stage)
#
# This script must compile/assemble the software and install every output
# file under $DESTDIR with standard FHS paths (usr/bin/, usr/lib/, etc.).
#
# For a real project you would replace the example below with:
# - Go: go build -o "$DESTDIR/usr/bin/mytool" .
# - Rust: cargo build --release && install -D target/release/mytool "$DESTDIR/usr/bin/mytool"
# - Zig: zig build -Doptimize=ReleaseSafe --prefix /usr -Ddestdir="$DESTDIR"
# - Shell: install -D mytool.sh "$DESTDIR/usr/bin/mytool"
# - Python: install -D mytool.py "$DESTDIR/usr/bin/mytool"
# - Anything you can script.
set -eu
echo "==> Building mytool (custom)"
# Standalone Go binary: init module, build, install
go mod init mytool
go build -o mytool .
# Install into the staging tree
install -Dm755 mytool "$DESTDIR/usr/bin/mytool"
echo "==> Install complete"
@@ -0,0 +1,23 @@
-- custom.recipe — demonstrates build_system = "custom" with a build.sh script.
--
-- The custom build system runs `build_script` from inside the extracted
-- source directory. The script receives $DESTDIR pointing at the staging
-- tree where installed files must be placed.
--
-- To try this:
-- 1. Unpack the source tarball: tar -xzf mytool-1.0.tar.gz
-- 2. Run the script manually: cd mytool-1.0 && DESTDIR=/tmp/stage sh build.sh
-- 3. Inspect the staged output: find /tmp/stage
-- 4. Build with makepkg: zeta-makepkg custom.recipe
return {
name = "mytool",
version = "1.0",
summary = "Example custom-build package (static Go binary)",
url = "mytool-1.0.tar.gz",
sha256 = nil,
deps = {},
build_system = "custom",
build_script = "build.sh",
test = "test -x ${DESTDIR}/usr/bin/mytool",
}
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# build.sh — example custom build script for zeta-makepkg.
#
# Invoked from the extracted source directory. The tool sets:
# $DESTDIR → staging tree root (e.g. /tmp/zeta-makepkg/hooks-demo-.../stage)
#
# This script installs a small command under usr/bin/ and a post-transaction
# hook under usr/share/zeta/hooks/. A hook is a pure-data Lua file:
#
# return {
# order = 50,
# trigger = { op = "install", type = "package", target = { "hooks-demo" } },
# action = { when = "post", exec = "true", description = "example hook" },
# }
#
# zeta-makepkg scans the stage for usr/share/zeta/hooks/*.hook, validates the
# format, and merges the paths into the generated `files` whitelist.
set -eu
echo "==> Building hooks-demo (custom)"
# Install a tiny command
mkdir -p "$DESTDIR/usr/bin"
cat > "$DESTDIR/usr/bin/hooks-demo" <<'EOF'
#!/bin/sh
echo "hooks-demo: post-transaction hooks are working"
EOF
chmod 755 "$DESTDIR/usr/bin/hooks-demo"
# Install a post-transaction hook (pure data, no side effects)
mkdir -p "$DESTDIR/usr/share/zeta/hooks"
cat > "$DESTDIR/usr/share/zeta/hooks/hooks-demo.hook" <<'EOF'
return {
order = 50,
trigger = { op = "install", type = "package", target = { "hooks-demo" } },
action = { when = "post", exec = "true", description = "example hook" },
}
EOF
echo "==> Install complete"
@@ -0,0 +1,26 @@
-- hooks-demo.recipe — demonstrates shipping a post-transaction hook.
--
-- The custom build script installs a small command and a hook file under
-- usr/share/zeta/hooks/. zeta-makepkg scans the stage for *.hook files,
-- validates each one (a hook is a pure-data Lua table carrying `trigger`
-- and `action`), and merges the found paths into the generated `files`
-- whitelist so the hook is committed alongside the declared files.
--
-- To try this:
-- 1. Unpack the source tarball: tar -xzf hooks-demo-1.0.tar.gz
-- 2. Run the script manually: cd hooks-demo-1.0 && DESTDIR=/tmp/stage sh build.sh
-- 3. Inspect the staged output: find /tmp/stage
-- 4. Build with makepkg: zeta-makepkg hooks-demo.recipe
return {
name = "hooks-demo",
version = "1.0",
summary = "Example package shipping a post-transaction hook",
url = "hooks-demo-1.0.tar.gz",
sha256 = nil,
deps = {},
build_system = "custom",
build_script = "build.sh",
files = { "usr/bin/hooks-demo", "usr/share/zeta/hooks/hooks-demo.hook" },
test = "test -x ${DESTDIR}/usr/bin/hooks-demo",
}
+16
View File
@@ -0,0 +1,16 @@
-- zig.recipe — zig build system example.
--
-- Demonstrates: zig, configure_args (-D flags), source sha256.
return {
name = "my-zig-app",
version = "1.0.0",
summary = "An example application built with Zig",
url = "https://example.com/my-zig-app-1.0.0.tar.gz",
sha256 = nil,
deps = {},
build_system = "zig",
configure_args = { "-Doptimize=ReleaseFast" },
test = "test -x ${DESTDIR}/usr/bin/my-zig-app",
files = { "usr/bin/my-zig-app" },
}
+287
View File
@@ -0,0 +1,287 @@
-- build.lua -- single-recipe pipeline and batch dispatcher.
--
-- build_single(recipe_path, opts) → runs the full pipeline for one recipe
-- build_all(dir, opts, script_path) → batch build (sequential or parallel)
local build = {}
local path = require("path")
local log = require("log")
local recipe = require("recipe")
local builder = require("builder")
local packager = require("packager")
local indexer = require("indexer")
local hooks = require("hooks")
---------------------------------------------------------------------------
-- Dependency resolution (--follow)
---------------------------------------------------------------------------
-- Given a recipe directory and a dep spec (e.g. "ncurses" or "libfoo>=2.0"),
-- return the path to <dep>.recipe if it exists, or nil.
local function find_dep_recipe(recipe_dir, dep_spec)
local name = dep_spec:match("^([%w%._+%-]+)")
if not name then return nil end
local p = path.join(recipe_dir, name .. ".recipe")
if path.exists(p) then return p end
return nil
end
-- Recursively collect all dependency recipe paths, bottom-up (deps first).
-- Returns an ordered list (no duplicates) and an in-progress set for cycle
-- detection. Recipes for deps that don't have a .recipe file are silently
-- skipped (assumed already installed).
local function collect_deps(recipe_path, seen, order, in_progress)
if seen[recipe_path] then return end
if in_progress[recipe_path] then
error(("dependency cycle involving %s"):format(path.basename(recipe_path)), 0)
end
in_progress[recipe_path] = true
local r, err = recipe.load(recipe_path)
if not r then
in_progress[recipe_path] = nil
log.warn(("skipping %s: %s"):format(path.basename(recipe_path), tostring(err)))
return
end
local dir = path.dirname(recipe_path)
for _, dep in ipairs(r.deps) do
local dep_recipe = find_dep_recipe(dir, dep)
if dep_recipe then
collect_deps(dep_recipe, seen, order, in_progress)
end
end
in_progress[recipe_path] = nil
seen[recipe_path] = true
order[#order + 1] = recipe_path
end
-- Return an ordered list of recipe paths to build: all dependencies
-- (bottom-up), then the target recipe itself.
local function resolve_deps(recipe_path)
local seen = {}
local order = {}
local in_progress = {}
collect_deps(recipe_path, seen, order, in_progress)
return order
end
---------------------------------------------------------------------------
-- Single recipe pipeline
---------------------------------------------------------------------------
function build.single(recipe_path, opts)
-- If --follow, build dependencies first (bottom-up order, already sorted).
if opts.follow then
log.step("resolving dependencies")
local ordered = resolve_deps(recipe_path)
local dep_count = #ordered - 1 -- last is the target itself
if dep_count > 0 then
log.info(("found %d dependenc%s"):format(dep_count, dep_count == 1 and "y" or "ies"))
for i = 1, dep_count do
io.write("\n" .. string.rep("─", 36) .. "\n")
log.step(("building dependency %s (%d/%d)"):format(
path.basename(ordered[i]), i, dep_count))
build.single(ordered[i], opts)
end
io.write("\n" .. string.rep("─", 36) .. "\n")
else
log.info("no unresolved dependencies")
end
end
local r, err = recipe.load(recipe_path)
if not r then error(("recipe error: %s"):format(tostring(err)), 0) end
local packages_dir = path.join(opts.output, "packages")
local pkg_dir = path.join(packages_dir, r.name)
if path.exists(pkg_dir) and not opts.force then
error(("package %s already exists at %s (use --force to overwrite)"):format(
r.name, pkg_dir), 0)
end
path.mkdir_p(pkg_dir)
local stamp = os.date("%Y%m%d-%H%M%S")
local work_dir = path.join("/tmp/zeta-makepkg", r.name .. "-" .. stamp)
local stage_dir = path.join(work_dir, "stage")
path.mkdir_p(stage_dir)
log.info(("work directory: %s"):format(work_dir))
local cache_dir = path.join(os.getenv("HOME") or "/tmp", ".cache", "zeta-makepkg", "sources")
local recipe_dir = path.dirname(recipe_path)
local cache_file = builder.fetch_source(r, cache_dir, recipe_dir)
local source_dir = builder.extract_source(r, cache_file, work_dir)
builder.run_build(r, source_dir, stage_dir, { jobs = opts.jobs })
local hook_paths = hooks.scan_hooks(stage_dir)
if r.test then
builder.run_test(r.test, stage_dir)
else
log.info("no test command — skipping verification")
end
local tarball_path = path.join(pkg_dir, r.name .. "-" .. r.version .. ".tar.gz")
local sha256 = packager.create_tarball(stage_dir, tarball_path)
packager.write_manifest(r, sha256, pkg_dir, opts.repo, hook_paths)
if not opts.no_index then
indexer.generate(packages_dir)
else
log.info("--no-index: skipping index update")
end
if not opts.keep_work then
path.run("rm -rf " .. path.quote(work_dir))
else
log.info(("--keep-work: work directory preserved at %s"):format(work_dir))
end
log.ok(("%s-%s built successfully"):format(r.name, r.version))
log.info(("output: %s"):format(pkg_dir))
end
---------------------------------------------------------------------------
-- Collect recipes from a directory
---------------------------------------------------------------------------
local function collect_recipes(dir)
local recipes = {}
local f = io.popen("find " .. path.quote(dir)
.. " -maxdepth 1 -name '*.recipe' -type f 2>/dev/null | sort")
if f then
for line in f:lines() do
if line ~= "" then recipes[#recipes + 1] = line end
end
f:close()
end
return recipes
end
---------------------------------------------------------------------------
-- Batch build
---------------------------------------------------------------------------
function build.all(dir, opts, script_path)
if not path.exists(dir) then
error(("directory not found: %s"):format(dir), 0)
end
log.step(("scanning %s for recipes"):format(dir))
local recipes = collect_recipes(dir)
if #recipes == 0 then
error(("no .recipe files found in %s"):format(dir), 0)
end
log.info(("found %d recipe(s)"):format(#recipes))
local batch_opts = {
output = opts.output,
no_index = true,
keep_work = opts.keep_work,
force = opts.force,
jobs = opts.jobs,
repo = opts.repo,
}
local failed = {}
local workers = opts.workers or 1
if workers <= 1 then
for _, recipe_path in ipairs(recipes) do
io.write("\n" .. string.rep("─", 48) .. "\n")
log.step(("building %s"):format(path.basename(recipe_path)))
local ok, err = pcall(build.single, recipe_path, batch_opts)
if not ok then
log.error(tostring(err))
failed[#failed + 1] = { path = recipe_path, err = tostring(err) }
end
end
else
local task_dir = "/tmp/zeta-makepkg-workers"
path.run("rm -rf " .. path.quote(task_dir))
path.mkdir_p(task_dir)
local jobs_flag = opts.jobs and (" -j" .. opts.jobs) or ""
local running = 0
local function count_alive()
local n = 0
for i = 1, #recipes do
if not path.exists(task_dir .. "/" .. i .. ".done") then n = n + 1 end
end
return n
end
for i, recipe_path in ipairs(recipes) do
while running >= workers do
os.execute("sleep 0.5")
running = count_alive()
end
local done_file = task_dir .. "/" .. i .. ".done"
local log_file = task_dir .. "/" .. i .. ".log"
local cmd = string.format(
"(%s %s %s --output %s --no-index --force --repo %s > %s 2>&1; echo $? > %s) &",
path.quote(script_path), jobs_flag, path.quote(recipe_path),
path.quote(opts.output), path.quote(opts.repo),
path.quote(log_file), path.quote(done_file))
os.execute(cmd)
running = running + 1
log.info(("[%d/%d] spawned %s"):format(i, #recipes, path.basename(recipe_path)))
end
log.step("waiting for workers...")
while count_alive() > 0 do
os.execute("sleep 0.5")
end
for i = 1, #recipes do
local done_file = task_dir .. "/" .. i .. ".done"
local f = io.open(done_file, "r")
local exit_code = f and tonumber(f:read("*l")) or 1
if f then f:close() end
if exit_code ~= 0 then
local err_msg = ("exit code %d"):format(exit_code)
local lf = io.open(task_dir .. "/" .. i .. ".log", "r")
if lf then
for line in lf:lines() do
if line:match("error") then err_msg = line end
end
lf:close()
end
failed[#failed + 1] = { path = recipes[i], err = err_msg }
else
log.ok(path.basename(recipes[i]))
end
end
path.run("rm -rf " .. path.quote(task_dir))
end
io.write("\n" .. string.rep("─", 48) .. "\n")
if not opts.no_index then
indexer.generate(path.join(opts.output, "packages"))
end
io.write("\n")
local built = #recipes - #failed
log.ok(("%d built, %d failed"):format(built, #failed))
if #failed > 0 then
log.warn("failed recipes:")
for _, f in ipairs(failed) do
log.warn((" %s — %s"):format(path.basename(f.path), f.err))
end
os.exit(1)
end
end
return build
+31 -3
View File
@@ -3,7 +3,7 @@
-- Handles the full build pipeline for a recipe: -- Handles the full build pipeline for a recipe:
-- 1. fetch_source — download to cache (or detect git) -- 1. fetch_source — download to cache (or detect git)
-- 2. extract_source — unpack tarball or clone git repo -- 2. extract_source — unpack tarball or clone git repo
-- 3. run_build — dispatch autotools/cmake/meson/make/cargo/custom -- 3. run_build — dispatch autotools/cmake/meson/make/cargo/zig/build.sh/custom
-- 4. run_test — execute post-build verification command -- 4. run_test — execute post-build verification command
-- --
-- All subprocesses run in a minimal environment. Build tools (meson, cmake, -- All subprocesses run in a minimal environment. Build tools (meson, cmake,
@@ -104,7 +104,7 @@ end
-- Fetch source into the cache directory. Returns the cached file path, -- Fetch source into the cache directory. Returns the cached file path,
-- or nil if the URL is a git repository (signals "use git clone"). -- or nil if the URL is a git repository (signals "use git clone").
function builder.fetch_source(recipe, cache_dir) function builder.fetch_source(recipe, cache_dir, local_dir)
local url = recipe.url local url = recipe.url
-- Git repos are cloned, not cached as files. -- Git repos are cloned, not cached as files.
@@ -140,9 +140,18 @@ function builder.fetch_source(recipe, cache_dir)
if not path.run("cp " .. path.quote(url) .. " " .. path.quote(cache_file)) then if not path.run("cp " .. path.quote(url) .. " " .. path.quote(cache_file)) then
error(("failed to copy %s"):format(url), 0) error(("failed to copy %s"):format(url), 0)
end end
else
-- Bare filename: resolve relative to the recipe's directory.
if local_dir then
local src = path.join(local_dir, url)
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
else else
error(("unrecognised url scheme: %s"):format(url), 0) error(("unrecognised url scheme: %s"):format(url), 0)
end end
end
else else
log.info("using cached source: " .. path.basename(cache_file)) log.info("using cached source: " .. path.basename(cache_file))
end end
@@ -234,7 +243,16 @@ function builder.run_build(recipe, source_dir, stage_dir, opts)
end end
if bs == "autotools" then if bs == "autotools" then
run("./configure --prefix=/usr" .. args_str) -- Some projects ship a capitalized Configure instead of autoconf's configure.
local config_script = "configure"
if not path.exists(path.join(source_dir, "configure")) then
if path.exists(path.join(source_dir, "Configure")) then
config_script = "Configure"
else
error(("no configure script found in %s (looked for ./configure and ./Configure)"):format(source_dir), 0)
end
end
run("./" .. config_script .. " --prefix=/usr" .. args_str)
run("make -j" .. njobs) run("make -j" .. njobs)
run("make install DESTDIR=" .. path.quote(stage_dir)) run("make install DESTDIR=" .. path.quote(stage_dir))
@@ -280,6 +298,16 @@ function builder.run_build(recipe, source_dir, stage_dir, opts)
end end
run("install -D " .. path.quote(target) .. " " .. path.quote(path.join(bin_dir, path.basename(target)))) run("install -D " .. path.quote(target) .. " " .. path.quote(path.join(bin_dir, path.basename(target))))
elseif bs == "zig" then
run("zig build -Doptimize=ReleaseSafe" .. args_str)
run("zig build install --prefix /usr -Ddestdir=" .. path.quote(stage_dir))
elseif bs == "build.sh" then
if not path.exists(path.join(source_dir, "build.sh")) then
error(("no build.sh script found in %s (build_system is 'build.sh')"):format(source_dir), 0)
end
run("DESTDIR=" .. path.quote(stage_dir) .. " sh build.sh")
elseif bs == "custom" then elseif bs == "custom" then
local script = recipe.build_script local script = recipe.build_script
run("DESTDIR=" .. path.quote(stage_dir) .. " sh " .. path.quote(script)) run("DESTDIR=" .. path.quote(stage_dir) .. " sh " .. path.quote(script))
+108
View File
@@ -0,0 +1,108 @@
-- hooks.lua -- scan and validate post-transactional hook files in the stage.
--
-- Zeta packages may ship hooks under usr/share/zeta/hooks/*.hook. A hook is
-- a Lua file returning a table of the form:
--
-- return {
-- order = 100,
-- trigger = { ... },
-- action = { ... },
-- }
--
-- scan_hooks(stage_dir) finds every *.hook file under the stage's
-- usr/share/zeta/hooks/ directory, loads each one in an empty environment
-- (the same lockdown as recipe.load), verifies it returns a table carrying
-- `trigger` and `action` tables, and returns the stage-relative paths
-- (e.g. "usr/share/zeta/hooks/demo.hook"). A malformed hook — bad syntax,
-- runtime error, non-table result, or missing trigger/action — raises.
local hooks = {}
local path = require("path")
local log = require("log")
----------------------------------------------------------------------------
-- Private helpers
----------------------------------------------------------------------------
-- Load a single hook file under an empty environment and perform minimal
-- validation. Returns the hook table or raises on any failure.
local function load_hook(filepath)
local f, err = io.open(filepath, "rb")
if not f then
error(("hook %q: cannot open: %s"):format(filepath, tostring(err)), 0)
end
local src = f:read("*a")
f:close()
-- Hooks run in a minimal environment — they're pure data, but we still
-- lock down globals to prevent accidental side effects (same as recipes).
local env = {}
local chunk
if setfenv then
chunk, err = loadstring(src, "@" .. filepath)
if chunk then setfenv(chunk, env) end
else
chunk, err = load(src, "@" .. filepath, "t", env)
end
if not chunk then
error(("hook %q: syntax error: %s"):format(filepath, tostring(err)), 0)
end
local ok, raw = pcall(chunk)
if not ok then
error(("hook %q: runtime error: %s"):format(filepath, tostring(raw)), 0)
end
if type(raw) ~= "table" then
error(("hook %q: must return a table"):format(filepath), 0)
end
if type(raw.trigger) ~= "table" then
error(("hook %q: missing or invalid `trigger` table"):format(filepath), 0)
end
if type(raw.action) ~= "table" then
error(("hook %q: missing or invalid `action` table"):format(filepath), 0)
end
return raw
end
----------------------------------------------------------------------------
-- Public API
----------------------------------------------------------------------------
-- Scan the stage for usr/share/zeta/hooks/*.hook, validate each one, and
-- return their stage-relative paths (sorted for deterministic manifests).
-- Returns an empty list when no hooks directory exists. Raises on the first
-- malformed hook.
function hooks.scan_hooks(stage_dir)
local hooks_dir = path.join(stage_dir, "usr/share/zeta/hooks")
if not path.exists(hooks_dir) then
return {}
end
log.step("scanning hooks in " .. hooks_dir)
local found = {}
local f = io.popen("find " .. path.quote(hooks_dir)
.. " -type f -name '*.hook' 2>/dev/null | sort")
if f then
for line in f:lines() do
if line ~= "" then found[#found + 1] = line end
end
f:close()
end
local base = stage_dir:gsub("/+$", "")
local rels = {}
for _, abs in ipairs(found) do
load_hook(abs) -- raises on malformed hooks
local rel = abs:sub(#base + 2) -- strip "<stage_dir>/" prefix
rels[#rels + 1] = rel
log.ok(("hook %s"):format(rel))
end
return rels
end
return hooks
+10 -1
View File
@@ -97,7 +97,13 @@ end
-- The generated manifest uses archive mode (strip=1) and includes the -- The generated manifest uses archive mode (strip=1) and includes the
-- computed sha256 of the binary tarball. Optional fields (deps, files, -- computed sha256 of the binary tarball. Optional fields (deps, files,
-- test) are included when present in the recipe. -- test) are included when present in the recipe.
function packager.write_manifest(recipe, tarball_sha256, pkg_dir, repo) --
-- `hooks` (optional) is a list of stage-relative paths of packaged hook
-- files (usr/share/zeta/hooks/*.hook). When `recipe.files` is set, the
-- hook paths are merged into the emitted files whitelist so the hooks are
-- committed alongside the whitelisted files. When `recipe.files` is nil,
-- hooks are ignored (all staged files commit anyway).
function packager.write_manifest(recipe, tarball_sha256, pkg_dir, repo, hooks)
local repo = repo or "https://raw.githubusercontent.com/gretagen/zeta-packages/refs/heads/main" local repo = repo or "https://raw.githubusercontent.com/gretagen/zeta-packages/refs/heads/main"
local lines = {} local lines = {}
local function emit(fmt, ...) local function emit(fmt, ...)
@@ -129,6 +135,9 @@ function packager.write_manifest(recipe, tarball_sha256, pkg_dir, repo)
for _, f in ipairs(recipe.files) do for _, f in ipairs(recipe.files) do
file_strs[#file_strs + 1] = string.format('"%s"', escape(f)) file_strs[#file_strs + 1] = string.format('"%s"', escape(f))
end end
for _, h in ipairs(hooks or {}) do
file_strs[#file_strs + 1] = string.format('"%s"', escape(h))
end
emit(' files = { %s },', table.concat(file_strs, ", ")) emit(' files = { %s },', table.concat(file_strs, ", "))
end end
+3 -2
View File
@@ -11,7 +11,7 @@
-- url = "https://.../mypkg-1.2.3.tar.gz", -- url = "https://.../mypkg-1.2.3.tar.gz",
-- sha256 = nil, -- optional source checksum -- sha256 = nil, -- optional source checksum
-- deps = { "libfoo" }, -- deps = { "libfoo" },
-- build_system = "meson", -- autotools | cmake | meson | make | cargo | custom -- build_system = "meson", -- autotools | cmake | meson | make | cargo | zig | build.sh | custom
-- configure_args = { ... }, -- optional extra args -- configure_args = { ... }, -- optional extra args
-- build_script = "build.sh", -- required for custom -- build_script = "build.sh", -- required for custom
-- test = "test -f ${DESTDIR}/usr/bin/mypkg", -- test = "test -f ${DESTDIR}/usr/bin/mypkg",
@@ -31,7 +31,8 @@ local KNOWN_KEYS = {
local BUILD_SYSTEMS = { local BUILD_SYSTEMS = {
autotools = true, cmake = true, meson = true, autotools = true, cmake = true, meson = true,
make = true, cargo = true, custom = true, make = true, cargo = true, zig = true,
["build.sh"] = true, custom = true,
} }
local HEX64 = "^" .. string.rep("%x", 64) .. "$" local HEX64 = "^" .. string.rep("%x", 64) .. "$"