Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/design/recipe_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,48 @@ package "hostname" {
}
```

### `realizer` Node (Optional, child of `package`)

A package can provide a generation-time tool for derived artifacts without using
an arbitrary shell hook. The provider package owns the definition and must ship
the executable named by the first `command` argument. Other packages trigger it
simply by contributing paths that match its `inputs`.

```kdl
package "glib" {
files "usr/"

realizer "glib-schemas" {
inputs "usr/share/glib-2.0/schemas/*.xml"
command "/usr/bin/glib-compile-schemas" "usr/share/glib-2.0/schemas"
}
}
```

`inputs` uses the same pattern language as package `files`: paths are relative
to the generation root, POSIX `fnmatch(3)` is pathname-aware, `*` does not cross
`/`, `?` and bracket expressions are supported, a trailing `/` recursively
matches a directory, `!` excludes matches, and recipe variables are expanded.
Unlike `files`, a realizer input is a trigger rather than a package-content
assertion: matching no paths is valid and skips the realizer. Any included file
or symlink that survives exclusions makes it applicable. Mere snapshots all
applicable realizers before running any command, so generated outputs cannot
activate another realizer in the same generation build.

`command` is an argv array, not a shell script. Its first argument must be an
absolute path supplied by the declaring package. Mere runs it in an isolated
build namespace after assembling the complete staged generation and before
publication. `/usr` and the store are read-only, the staged generation is the
working directory and is writable at `/work`, and the environment includes
`MERE_REALIZATION_ROOT=/work`. Relative command arguments therefore refer to
the staged generation, as in the GLib schema directory above. A non-zero exit or
setup failure aborts publication and leaves the currently active generation
unchanged.

The MVP always evaluates and, when applicable, runs realizers for each newly
constructed generation. It has no host-scoped writes, incremental realization
cache, or lifecycle `scope` option.

## Variable Interpolation

Variables can be used in strings with `${...}` syntax.
Expand Down
11 changes: 11 additions & 0 deletions src/kdl_schema.zig
Original file line number Diff line number Diff line change
Expand Up @@ -502,11 +502,22 @@ const recipe_service_children = [_]NodeSpec{
.{ .name = "env", .any_child_args = .{ .kind = .string, .min = 1, .max = 1, .label = "value" } },
};

const recipe_realizer_children = [_]NodeSpec{
.{ .name = "inputs", .required = true, .args = .{ .kind = .string, .min = 1, .label = "pattern" } },
.{ .name = "command", .required = true, .args = .{ .kind = .string, .min = 1, .label = "argv" } },
};

const recipe_package_children = [_]NodeSpec{
.{ .name = "files", .required = true, .args = .{ .kind = .string, .min = 1, .label = "value" } },
.{ .name = "strip", .args = .{ .kind = .boolean, .min = 1, .max = 1, .label = "value" } },
.{ .name = "compress-manpages", .args = .{ .kind = .boolean, .min = 1, .max = 1, .label = "value" } },
.{ .name = "arch", .args = .{ .kind = .string, .min = 1, .max = 1, .label = "value" } },
.{
.name = "realizer",
.repeatable = true,
.args = .{ .kind = .string, .min = 1, .max = 1, .label = "name" },
.children = &recipe_realizer_children,
},
.{
.name = "service",
.repeatable = true,
Expand Down
86 changes: 86 additions & 0 deletions src/meta.zig
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,24 @@ pub const Service = struct {
}
};

pub const Realizer = struct {
name: []const u8,
inputs: std.ArrayList([]const u8) = .empty,
command: std.ArrayList([]const u8) = .empty,

pub fn init() Realizer {
return .{ .name = "" };
}

pub fn deinit(self: *Realizer, allocator: std.mem.Allocator) void {
if (self.name.len > 0) allocator.free(self.name);
for (self.inputs.items) |value| allocator.free(value);
self.inputs.deinit(allocator);
for (self.command.items) |value| allocator.free(value);
self.command.deinit(allocator);
}
};

pub const Data = struct {
dependencies: std.ArrayList(Dependency),
provisions: std.ArrayList(Provision),
Expand All @@ -134,6 +152,7 @@ pub const Data = struct {
licenses: std.ArrayList([]const u8) = .empty,
source_urls: std.ArrayList([]const u8) = .empty,
services: std.ArrayList(Service) = .empty,
realizers: std.ArrayList(Realizer) = .empty,

pub fn init(allocator: std.mem.Allocator) Data {
return Data{
Expand Down Expand Up @@ -162,6 +181,8 @@ pub const Data = struct {
self.source_urls.deinit(self.allocator);
for (self.services.items) |*service| service.deinit(self.allocator);
self.services.deinit(self.allocator);
for (self.realizers.items) |*realizer| realizer.deinit(self.allocator);
self.realizers.deinit(self.allocator);
}

pub fn addDependency(self: *Data, dep_type: DependencyType, value: []const u8) MetaError!void {
Expand Down Expand Up @@ -248,6 +269,19 @@ pub const Data = struct {
try self.services.append(self.allocator, service);
}

pub fn addRealizer(self: *Data, source: anytype) MetaError!void {
var realizer = Realizer.init();
errdefer realizer.deinit(self.allocator);
realizer.name = self.allocator.dupe(u8, source.name) catch return MetaError.OutOfMemory;
for (source.inputs.items) |value| {
try realizer.inputs.append(self.allocator, try self.allocator.dupe(u8, value));
}
for (source.command.items) |value| {
try realizer.command.append(self.allocator, try self.allocator.dupe(u8, value));
}
try self.realizers.append(self.allocator, realizer);
}

/// Populate recipe-level metadata (description, homepage, licenses, source URLs).
/// This data is carried through to repo.db for display and upstream tracking.
pub fn populateRecipeMetadata(
Expand Down Expand Up @@ -407,6 +441,20 @@ pub const Data = struct {
buffer.appendSlice(allocator, "}\n") catch return MetaError.OutOfMemory;
}

if (self.realizers.items.len > 0) {
if (buffer.items.len > 0) buffer.append(allocator, '\n') catch return MetaError.OutOfMemory;
buffer.appendSlice(allocator, "realizers {\n") catch return MetaError.OutOfMemory;
for (self.realizers.items) |realizer| {
buffer.appendSlice(allocator, " realizer \"") catch return MetaError.OutOfMemory;
try appendEscaped(&buffer, allocator, realizer.name);
buffer.appendSlice(allocator, "\" {\n") catch return MetaError.OutOfMemory;
try appendMetaArgs(&buffer, allocator, "inputs", realizer.inputs.items, 8);
try appendMetaArgs(&buffer, allocator, "command", realizer.command.items, 8);
buffer.appendSlice(allocator, " }\n") catch return MetaError.OutOfMemory;
}
buffer.appendSlice(allocator, "}\n") catch return MetaError.OutOfMemory;
}

return buffer.toOwnedSlice(allocator) catch return MetaError.OutOfMemory;
}

Expand Down Expand Up @@ -465,6 +513,17 @@ pub const Data = struct {
try parseServiceArgs(allocator, child, "depends-on", &service.depends_on);
try meta.services.append(allocator, service);
}
} else if (std.mem.eql(u8, node.name, "realizers")) {
for (node.children.items) |*child| {
if (!std.mem.eql(u8, child.name, "realizer")) continue;
var realizer = Realizer.init();
errdefer realizer.deinit(allocator);
realizer.name = allocator.dupe(u8, child.getFirstArgString() orelse return MetaError.InvalidInput) catch return MetaError.OutOfMemory;
try parseServiceArgs(allocator, child, "inputs", &realizer.inputs);
try parseServiceArgs(allocator, child, "command", &realizer.command);
if (realizer.inputs.items.len == 0 or realizer.command.items.len == 0) return MetaError.InvalidInput;
try meta.realizers.append(allocator, realizer);
}
} else if (std.mem.eql(u8, node.name, "metadata")) {
for (node.children.items) |*child| {
if (std.mem.eql(u8, child.name, "description")) {
Expand Down Expand Up @@ -859,3 +918,30 @@ test "Data encode and parse split-runtime dependency" {
try std.testing.expect(parsed.dependencies.items[0].version_constraint != null);
try std.testing.expectEqualStrings("=3.6.1-4", parsed.dependencies.items[0].version_constraint.?);
}

test "Data generation realizer metadata roundtrip" {
const allocator = std.testing.allocator;
var value = Data.init(allocator);
defer value.deinit();

var realizer = Realizer.init();
realizer.name = try allocator.dupe(u8, "glib-schemas");
try realizer.inputs.append(allocator, try allocator.dupe(u8, "usr/share/glib-2.0/schemas/*.xml"));
try realizer.inputs.append(allocator, try allocator.dupe(u8, "!usr/share/glib-2.0/schemas/ignored.xml"));
try realizer.command.append(allocator, try allocator.dupe(u8, "/usr/bin/glib-compile-schemas"));
try realizer.command.append(allocator, try allocator.dupe(u8, "usr/share/glib-2.0/schemas"));
try value.realizers.append(allocator, realizer);

const encoded = try value.encode(allocator);
defer allocator.free(encoded);
var parsed = try Data.parse(allocator, encoded);
defer parsed.deinit();

try std.testing.expectEqual(@as(usize, 1), parsed.realizers.items.len);
const parsed_realizer = parsed.realizers.items[0];
try std.testing.expectEqualStrings("glib-schemas", parsed_realizer.name);
try std.testing.expectEqualStrings("usr/share/glib-2.0/schemas/*.xml", parsed_realizer.inputs.items[0]);
try std.testing.expectEqualStrings("!usr/share/glib-2.0/schemas/ignored.xml", parsed_realizer.inputs.items[1]);
try std.testing.expectEqualStrings("/usr/bin/glib-compile-schemas", parsed_realizer.command.items[0]);
try std.testing.expectEqualStrings("usr/share/glib-2.0/schemas", parsed_realizer.command.items[1]);
}
59 changes: 59 additions & 0 deletions src/package_staging.zig
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,49 @@ fn matchesRecursiveDirPattern(rel_path: []const u8, pattern: []const u8) bool {
return std.mem.eql(u8, rel_path, root) or std.mem.startsWith(u8, rel_path, pattern);
}

pub fn validatePathPatterns(patterns: []const []const u8) PackageStagingError!void {
var has_include = false;
for (patterns) |pattern| {
const raw_pattern = basePattern(pattern);
if (raw_pattern.len == 0 or raw_pattern[0] == '/') return PackageStagingError.InvalidInput;
if (!isExclusionPattern(pattern)) has_include = true;
}
if (!has_include) return PackageStagingError.InvalidInput;
}

/// Match one relative path using the same pathname-aware pattern language as
/// recipe `files`. Positive patterns are ORed and exclusions override them.
/// Unlike package staging, this helper does not require any pattern to match.
pub fn matchesPathPatterns(
allocator: std.mem.Allocator,
rel_path: []const u8,
patterns: []const []const u8,
) PackageStagingError!bool {
try validatePathPatterns(patterns);
if (rel_path.len == 0 or rel_path[0] == '/') return PackageStagingError.InvalidInput;
const rel_path_z = allocator.dupeZ(u8, rel_path) catch return PackageStagingError.OutOfMemory;
defer allocator.free(rel_path_z);

var included = false;
var excluded = false;
for (patterns) |pattern| {
const raw_pattern = basePattern(pattern);
if (raw_pattern.len == 0 or raw_pattern[0] == '/') return PackageStagingError.InvalidInput;
const matched = if (isRecursiveDirPattern(raw_pattern))
matchesRecursiveDirPattern(rel_path, raw_pattern)
else blk: {
const pattern_z = allocator.dupeZ(u8, raw_pattern) catch return PackageStagingError.OutOfMemory;
defer allocator.free(pattern_z);
const code = c.fnmatch(pattern_z.ptr, rel_path_z.ptr, c.FNM_PATHNAME);
if (code != 0 and code != c.FNM_NOMATCH) return PackageStagingError.InvalidInput;
break :blk code == 0;
};
if (!matched) continue;
if (isExclusionPattern(pattern)) excluded = true else included = true;
}
return included and !excluded;
}

/// Convert an absolute symlink target to a relative path if it points within source_dir.
/// If the target points outside source_dir, return an error.
///
Expand Down Expand Up @@ -1201,3 +1244,19 @@ test "PackageStaging rejects absolute symlink target outside source boundary" {
});
try std.testing.expectError(error.InvalidInput, result);
}

test "matchesPathPatterns reuses files globs without requiring a match" {
const allocator = std.testing.allocator;
const patterns = [_][]const u8{
"usr/share/glib-2.0/schemas/*.xml",
"!usr/share/glib-2.0/schemas/ignored.xml",
};
try std.testing.expect(try matchesPathPatterns(allocator, "usr/share/glib-2.0/schemas/app.xml", &patterns));
try std.testing.expect(!try matchesPathPatterns(allocator, "usr/share/glib-2.0/schemas/ignored.xml", &patterns));
try std.testing.expect(!try matchesPathPatterns(allocator, "usr/share/glib-2.0/schemas/nested/app.xml", &patterns));
try std.testing.expect(!try matchesPathPatterns(allocator, "usr/share/icons/app.png", &patterns));
try std.testing.expect(try matchesPathPatterns(allocator, "usr/share/fonts/truetype/app.ttf", &.{"usr/share/fonts/"}));
try std.testing.expect(try matchesPathPatterns(allocator, "usr/bin/tool7", &.{"usr/bin/tool[0-9]"}));
try std.testing.expectError(error.InvalidInput, validatePathPatterns(&.{"!usr/share/ignored"}));
try std.testing.expectError(error.InvalidInput, validatePathPatterns(&.{"/usr/share/data"}));
}
15 changes: 15 additions & 0 deletions src/packaging.zig
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,12 @@ pub const Packager = struct {
};
}
}
for (config.artifact.realizers.items) |realizer| {
pkg_meta.addRealizer(realizer) catch {
self.ctx.allocator.free(content_hash);
return self.fail(config.staging_dir, "failed to populate realizer metadata", PackagingError.CreationFailed);
};
}

meta.writeFile(self.ctx.allocator, config.staging_dir, &pkg_meta) catch {
self.ctx.allocator.free(content_hash);
Expand Down Expand Up @@ -722,6 +728,11 @@ test "Packager handles signing and metadata generation" {
defer test_artifact.deinit(test_env.ctx.allocator);
// Properly allocate the name since BuildArtifact.deinit() will free it
test_artifact.name = try test_env.ctx.allocator.dupe(u8, "custom-name");
var test_realizer = recipe.RealizerDef.init();
test_realizer.name = try test_env.ctx.allocator.dupe(u8, "glib-schemas");
try test_realizer.inputs.append(test_env.ctx.allocator, try test_env.ctx.allocator.dupe(u8, "usr/share/glib-2.0/schemas/*.xml"));
try test_realizer.command.append(test_env.ctx.allocator, try test_env.ctx.allocator.dupe(u8, "/usr/bin/glib-compile-schemas"));
try test_artifact.realizers.append(test_env.ctx.allocator, test_realizer);

// Create staging directory with content
const staging_dir = try std.fs.path.join(test_env.ctx.allocator, &.{ test_env.path, "staging2" });
Expand Down Expand Up @@ -757,6 +768,10 @@ test "Packager handles signing and metadata generation" {
defer generated_meta.deinit();
try testing.expectEqual(@as(usize, 1), generated_meta.source_urls.items.len);
try testing.expectEqualStrings("https://example.org/source-2.1.0.tar.xz", generated_meta.source_urls.items[0]);
try testing.expectEqual(@as(usize, 1), generated_meta.realizers.items.len);
try testing.expectEqualStrings("glib-schemas", generated_meta.realizers.items[0].name);
try testing.expectEqualStrings("usr/share/glib-2.0/schemas/*.xml", generated_meta.realizers.items[0].inputs.items[0]);
try testing.expectEqualStrings("/usr/bin/glib-compile-schemas", generated_meta.realizers.items[0].command.items[0]);

// Verify manifest.v1.sig exists in staging (was written before archiving)
const manifest_sig_path = try std.fs.path.join(test_env.ctx.allocator, &.{ staging_dir, manifest.MANIFEST_SIG_FILENAME });
Expand Down
Loading