diff --git a/README.md b/README.md index 49008d0..817b18c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,21 @@ return { **Build systems**: `autotools`, `cmake`, `meson`, `make`, `cargo`, `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 ``` diff --git a/toolchain/examples/hooks-demo/build.sh b/toolchain/examples/hooks-demo/build.sh new file mode 100644 index 0000000..f59c52b --- /dev/null +++ b/toolchain/examples/hooks-demo/build.sh @@ -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" diff --git a/toolchain/examples/hooks-demo/hooks-demo.recipe b/toolchain/examples/hooks-demo/hooks-demo.recipe new file mode 100644 index 0000000..6df2b73 --- /dev/null +++ b/toolchain/examples/hooks-demo/hooks-demo.recipe @@ -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", +} diff --git a/toolchain/lib/build.lua b/toolchain/lib/build.lua index df0da16..5c26981 100644 --- a/toolchain/lib/build.lua +++ b/toolchain/lib/build.lua @@ -11,6 +11,7 @@ local recipe = require("recipe") local builder = require("builder") local packager = require("packager") local indexer = require("indexer") +local hooks = require("hooks") --------------------------------------------------------------------------- -- Dependency resolution (--follow) @@ -118,6 +119,8 @@ function build.single(recipe_path, opts) 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 @@ -126,7 +129,7 @@ function build.single(recipe_path, opts) 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) + packager.write_manifest(r, sha256, pkg_dir, opts.repo, hook_paths) if not opts.no_index then indexer.generate(packages_dir) diff --git a/toolchain/lib/hooks.lua b/toolchain/lib/hooks.lua new file mode 100644 index 0000000..5b1bd6d --- /dev/null +++ b/toolchain/lib/hooks.lua @@ -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 "/" prefix + rels[#rels + 1] = rel + log.ok(("hook %s"):format(rel)) + end + + return rels +end + +return hooks diff --git a/toolchain/lib/packager.lua b/toolchain/lib/packager.lua index c88e463..7a5177c 100644 --- a/toolchain/lib/packager.lua +++ b/toolchain/lib/packager.lua @@ -97,7 +97,13 @@ end -- The generated manifest uses archive mode (strip=1) and includes the -- computed sha256 of the binary tarball. Optional fields (deps, files, -- 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 lines = {} 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 file_strs[#file_strs + 1] = string.format('"%s"', escape(f)) 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, ", ")) end