Browse Source

G-102 M2: build.zig.zon pinned manifest + zig-native bootstrap staging

suite_tcl90 gains the no-tclsh source path:

- build.zig.zon: content-hashed manifest pinning each source as a fossil
  per-checkin tarball (tcl 1a9c3b9d96 / thread caa1d3aa30 / tclvfs f082c47f9b
  / tk effef6c657 / tklib 2d53beafc0 / tcllib c784f6fda4 - the exact checkouts
  the promoted punk905 runtime was built from) plus critcl 3.3.1 as a github
  archive. All lazy: the fossil dev flow never triggers a fetch. Refresh =
  bump a pin (zig fetch --save). Verified honored under --build-file
  build905.zig.
- build905.zig mode split: from the staged recipe copy the full graph
  registers as before; from the TRACKED suite dir (sources.config present,
  no ../tcl905) only the staging steps register. 'zig build stage'
  materializes pinned sources + a recipe copy into ../_build/<suite> (the
  same layout suite.tcl's fossil staging produces - both flows share the
  staged invocation, and the stage dir derives from the suite folder name so
  copy-and-tweak isolation holds). 'zig build bootstrap' chains the staged
  build: nested zig invocation with suite.tcl-parity cwd (parts of the
  recipe resolve sources cwd-relative), forward-slash prefix (the prefix
  flows into -DTCL_PACKAGE_PATH as a C string literal - backslashes broke
  the stubs compile), per-zig-version shared cache dir, hermetic-child env
  scrub, ReleaseFast default.
- tools/stagetree.zig: host-compiled materializer. Pinned mode keys a
  .stage-materialized marker on the dependency's content-addressed cache
  path (pin bump = invalidation, stale trees deleted so orphans cannot
  survive); always-mode for recipe items; fossil/git checkouts at the
  destination are never touched (tested).

Verified: 'zig build stage' against the fossil-populated stage refreshes
only the recipe copy (checkouts untouched, fossil changes empty); a full
copy-and-tweak suite copy (suite_tcl90zt) ran 'zig build bootstrap' from
nothing but the tracked tree + pinned zig - all 7 sources fetched/staged
from the manifest, all four shells built; tcl::build-info reports the same
9.0.5+1a9c3b9d... as the fossil-flow build and the zip shell boots its
attached library (tzdata + autoload).

Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 1 week ago
parent
commit
51fc727fe2
  1. 68
      src/buildsuites/suite_tcl90/build.zig.zon
  2. 143
      src/buildsuites/suite_tcl90/build905.zig
  3. 173
      src/buildsuites/suite_tcl90/tools/stagetree.zig

68
src/buildsuites/suite_tcl90/build.zig.zon

@ -0,0 +1,68 @@
//build.zig.zon - suite_tcl90 pinned source manifest (G-102).
//
//This is the CONTENT-HASHED tweak surface for the no-tclsh bootstrap flow: each
//dependency pins an exact upstream state (fossil per-checkin tarball urls /
//github archive urls) plus the zig package hash of the extracted tree. 'Refresh'
//means bumping a pin: point .url at the new checkin/commit tarball and update
//.hash ('zig fetch <url>' prints it; 'zig fetch --save=<name> <url>' edits this
//file in place). Branch-tip tarball urls are deliberately NOT used - they are not
//hash-stable, and the pin IS the provenance record.
//
//The fossil-checkout flow (suite.tcl + sources.config) remains the dev
//alternative for live-branch work; sources.config documents the relationship.
//Dependency names here match sources.config source names; the checkout dirs they
//materialize into are the recipe-coupled dir names documented there.
//
//All dependencies are lazy: nothing is fetched unless the bootstrap staging flow
//actually needs it (the fossil-staged flow never triggers a fetch).
.{
.name = .suite_tcl90,
.version = "0.0.1",
.fingerprint = 0xacfa2b8743c3fafc,
.minimum_zig_version = "0.16.0",
.dependencies = .{
//tcl core-9-0-branch checkout 1a9c3b9d96 2026-07-16 (9.0.5 era)
.tcl = .{
.url = "https://core.tcl-lang.org/tcl/tarball/1a9c3b9d965d66f69f037cab6718d64bd802ef2c/tcl.tar.gz",
.hash = "N-V-__8AACdREwL2XmVaouKzc7VAtnbgvv6DiWJ9PZEpDEIA",
.lazy = true,
},
//thread trunk checkout caa1d3aa30 2026-07-13 (3.0.1 era)
.tclthread = .{
.url = "https://core.tcl-lang.org/thread/tarball/caa1d3aa302798de2200de7297f9d4ca40102a5d/thread.tar.gz",
.hash = "N-V-__8AALOqFAA-WUgJ777DabyIuinMYLe_jXido0KjnJbT",
.lazy = true,
},
//tclvfs trunk checkout f082c47f9b 2023-09-05 (1.4.2 era)
.tclvfs = .{
.url = "https://core.tcl-lang.org/tclvfs/tarball/f082c47f9b2e04566e8ae70d56c6a02980d43674/tclvfs.tar.gz",
.hash = "N-V-__8AAK_2DQDNpjv04LswPFyezzY97rkc5cuwcaqO-Nyq",
.lazy = true,
},
//tk core-9-0-branch checkout effef6c657 2025-04-07 (9.0.2)
.tk = .{
.url = "https://core.tcl-lang.org/tk/tarball/effef6c657952d3304048267fa40a795a318997a/tk.tar.gz",
.hash = "N-V-__8AANEfGQECMKAN0YhYXuom0BxQMprrwcZL_bc28RB5",
.lazy = true,
},
//tklib trunk checkout 2d53beafc0 2026-07-03 (0.9 era)
.tklib = .{
.url = "https://core.tcl-lang.org/tklib/tarball/2d53beafc0e8621dacc75b3c809898cc9f1f92f9/tklib.tar.gz",
.hash = "N-V-__8AABomLAFd2U5OW96AfpQLoLm3Q9swpQZEwslrEJx3",
.lazy = true,
},
//tcllib trunk checkout c784f6fda4 2026-06-30 (2.0 era)
.tcllib = .{
.url = "https://core.tcl-lang.org/tcllib/tarball/c784f6fda4516f676453a2785b3c56f1ca5999f4/tcllib.tar.gz",
.hash = "N-V-__8AAFvlBARw4fIY107nOJmfxdwAjr1Vr89cYx98czpF",
.lazy = true,
},
//critcl 3.3.1 (tag commit 254bdffe2a)
.critcl = .{
.url = "https://github.com/andreas-kupries/critcl/archive/254bdffe2afc78148f79f92df8d6823a24f971bf.tar.gz",
.hash = "N-V-__8AAF8-bABDlxChmtnyalKgofVfN3kzcZdmZ7pjEmE0",
.lazy = true,
},
},
.paths = .{""},
}

143
src/buildsuites/suite_tcl90/build905.zig

@ -83,6 +83,25 @@ const targets: []const std.Target.Query = &.{
};
pub fn build(b: *std.Build) !void {
//G-102 invocation modes. This recipe serves two build roots:
// STAGED mode - build root is the staged recipe copy (_build/<suite>/build) with
// the source trees materialized beside it (../tcl905 etc) either by suite.tcl's
// fossil flow or by this recipe's own bootstrap staging. Full build graph.
// BOOTSTRAP mode - build root is the TRACKED suite dir (sources.config present,
// no ../tcl905): only the staging steps are registered - 'zig build stage'
// materializes the pinned build.zig.zon sources + a recipe copy into the
// _build stage, and 'zig build bootstrap' chains the staged build after it.
// This is the no-tclsh entry point.
if (!common.pathExists(b, tcl_source_folder)) {
if (common.pathExists(b, "sources.config")) {
try bootstrapMode(b);
return;
}
@panic("suite recipe: no source trees found beside the recipe (" ++ tcl_source_folder ++
" missing). Use 'zig build stage' (or bootstrap) from the tracked suite dir for the " ++
"pinned zon flow, or 'tclsh suite.tcl build' for the fossil dev flow.");
}
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
@ -2160,3 +2179,127 @@ pub fn build(b: *std.Build) !void {
//b.getInstallStep().dependOn(install_libraries);
}
//G-102 bootstrap mode: registered when this recipe is invoked from the TRACKED suite
//directory (see the mode note at the top of build()). The stage layout produced here
//is identical to suite.tcl's fossil staging, so both flows share the staged
//invocation - and the stage dir derives from THIS suite folder's name, preserving
//the copy-and-tweak isolation property (a copied suite stages into _build/<copy>).
fn bootstrapMode(b: *std.Build) !void {
//default ReleaseFast for suite.tcl parity (standardOptimizeOption would default
//Debug; the suite's runtimes are release artifacts)
const optimize = b.option(std.builtin.OptimizeMode, "optimize", "optimize mode forwarded to the staged build (default ReleaseFast)") orelse .ReleaseFast;
const suite_dirname = std.fs.path.basename(b.pathFromRoot(""));
const stage_rel = b.pathJoin(&.{ "..", "_build", suite_dirname });
const stage_tool = b.addExecutable(.{
.name = "stagetree",
.root_module = b.createModule(.{
.root_source_file = b.path("tools/stagetree.zig"),
.target = b.graph.host,
}),
});
const stage_step = b.step("stage", "materialize pinned sources (build.zig.zon) + recipe copy into ../_build/<suite> (existing fossil/git checkouts left untouched)");
//dependency name (build.zig.zon) -> stage dir (the recipe-coupled dir names
//documented in sources.config)
const source_dirs = [_][2][]const u8{
.{ "tcl", "tcl905" },
.{ "tclthread", "tclthread" },
.{ "tclvfs", "tclvfs" },
.{ "tk", "tk9" },
.{ "tklib", "tklib" },
.{ "tcllib", "tcllib" },
.{ "critcl", "critcl" },
};
var deps_pending = false;
for (source_dirs) |sd| {
const dep_name = sd[0];
const destdir = sd[1];
const dest_from_root = b.pathJoin(&.{ stage_rel, destdir });
//an existing fossil/git checkout is owned by the suite.tcl dev flow: skip the
//whole dependency so nothing is fetched for it (stagetree guards again at
//make time - this check just avoids needless fetches).
if (common.pathExists(b, b.pathJoin(&.{ dest_from_root, ".fslckout" })) or
common.pathExists(b, b.pathJoin(&.{ dest_from_root, "_FOSSIL_" })) or
common.pathExists(b, b.pathJoin(&.{ dest_from_root, ".git" })))
{
continue;
}
if (b.lazyDependency(dep_name, .{})) |dep| {
const run = b.addRunArtifact(stage_tool);
run.setName(b.fmt("stage {s}", .{destdir}));
run.has_side_effects = true;
//the dependency's content-addressed cache path doubles as the pin key:
//a manifest pin bump changes it, invalidating the materialized copy
const dep_root = dep.path("").getPath(b);
run.addArgs(&.{ "-key", dep_root, "-src", dep_root, "-dest", b.pathFromRoot(dest_from_root) });
stage_step.dependOn(&run.step);
} else {
deps_pending = true;
}
}
if (deps_pending) return; //zig fetches the lazy deps, then re-runs configure
//recipe copy -> <stage>/build (the same item list suite.tcl stages; per-item
//replace every run - recipe files are small tracked working files, no pin)
const recipe_items = [_][]const u8{
"build905.zig",
"build_common.zig",
"build_libtommath",
"build_zlib",
"build_tclvfs",
"build_tclthread",
"build_tk",
"patches",
"src",
"tools",
};
for (recipe_items) |item| {
const run = b.addRunArtifact(stage_tool);
run.setName(b.fmt("stage recipe {s}", .{item}));
run.has_side_effects = true;
run.addArgs(&.{ "-key", "-", "-src", b.pathFromRoot(item), "-dest", b.pathFromRoot(b.pathJoin(&.{ stage_rel, "build", item })) });
stage_step.dependOn(&run.step);
}
//bootstrap: stage, then drive the staged recipe - the same nested invocation
//suite.tcl performs after its fossil staging. Per-zig-version cache dir (object
//caches must never be shared across zig versions - user-confirmed hazard), same
//name suite.tcl derives so both flows share the staged cache.
const steps_opt = b.option([]const []const u8, "steps", "steps for the staged build (repeat the flag; default: install install-libraries make-zipfs)") orelse @as([]const []const u8, &.{ "install", "install-libraries", "make-zipfs" });
var cachever: []const u8 = builtin.zig_version_string;
cachever = common.replaceAll(b, cachever, "+", "_");
cachever = common.replaceAll(b, cachever, "/", "_");
cachever = common.replaceAll(b, cachever, ":", "_");
//forward-slash paths throughout (suite.tcl parity): the prefix flows into
//-D macros like TCL_PACKAGE_PATH as a C string literal - backslashes there are
//parsed as escape sequences and break the compile
const nested = b.addSystemCommand(&.{
b.graph.zig_exe,
"build",
"--build-file",
common.replaceAll(b, b.pathFromRoot(b.pathJoin(&.{ stage_rel, "build", "build905.zig" })), "\\", "/"),
"--cache-dir",
common.replaceAll(b, b.pathFromRoot(b.pathJoin(&.{ stage_rel, "build", b.fmt(".zig-cache-{s}", .{cachever}) })), "\\", "/"),
"--prefix",
common.replaceAll(b, b.pathFromRoot(b.pathJoin(&.{ stage_rel, "out" })), "\\", "/"),
b.fmt("-Doptimize={s}", .{@tagName(optimize)}),
});
for (steps_opt) |s| nested.addArg(s);
nested.setName("staged build (nested zig build)");
nested.has_side_effects = true;
//cwd = staged build dir (suite.tcl parity): parts of the recipe (e.g build_zlib's
//source iteration) resolve source paths relative to the process cwd, not the
//build root
nested.setCwd(.{ .cwd_relative = b.pathFromRoot(b.pathJoin(&.{ stage_rel, "build" })) });
//hermetic child shells (parity with suite.tcl): steps in the staged graph run
//the freshly built tclsh, which must not see the user's machine-level tcl env
for ([_][]const u8{ "TCLLIBPATH", "TCL_LIBRARY", "TK_LIBRARY" }) |ev| {
nested.removeEnvironmentVariable(ev);
}
nested.step.dependOn(stage_step);
const bootstrap_step = b.step("bootstrap", "stage + run the staged recipe end-to-end (no pre-existing tclsh required)");
bootstrap_step.dependOn(&nested.step);
}

173
src/buildsuites/suite_tcl90/tools/stagetree.zig

@ -0,0 +1,173 @@
//stagetree - materialize a source tree (or single file) into the build stage (G-102).
//
//Used by the suite recipe's bootstrap mode to copy zon-fetched dependency trees and
//the tracked recipe files into the _build/<suite> stage layout that the staged build
//invocation (and the fossil dev flow) share. Invariant guarded here: an existing
//fossil/git CHECKOUT at the destination is never touched - the fossil dev flow owns
//those directories.
//
// stagetree -key <pin|-> -src <path> -dest <path>
//
// -key <pin> pinned-tree mode: if <dest>/.stage-materialized already contains
// exactly <pin>, the destination is up to date and nothing is done.
// Otherwise the destination tree is DELETED, the source tree copied,
// and the marker written. Pass the dependency's content-addressed
// cache path as the pin - a pin bump changes it, invalidating the copy.
// -key - always mode (recipe files): delete destination, copy, no marker.
//
//Informational prints go to stdout only: zig 0.16's build_runner reports any step
//with nonempty stderr under a misleading 'failed command:' block even on success.
const std = @import("std");
const usage =
\\Usage: ./stagetree -key <pin|-> -src <path> -dest <path>
;
const marker_name = ".stage-materialized";
pub fn main(init: std.process.Init.Minimal) !void {
var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var threaded: std.Io.Threaded = .init(arena, .{
.environ = init.environ,
.argv0 = .init(init.args),
});
defer threaded.deinit();
const io = threaded.io();
const args = try init.args.toSlice(arena);
var opt_key: ?[]const u8 = null;
var opt_src: ?[]const u8 = null;
var opt_dest: ?[]const u8 = null;
{
var i: usize = 1;
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "-help", arg)) {
std.debug.print("{s}\n", .{usage});
return std.process.cleanExit(io);
} else if (std.mem.eql(u8, "-key", arg)) {
i += 1;
if (i >= args.len) fatal("expected arg after '{s}'", .{arg});
opt_key = args[i];
} else if (std.mem.eql(u8, "-src", arg)) {
i += 1;
if (i >= args.len) fatal("expected arg after '{s}'", .{arg});
opt_src = args[i];
} else if (std.mem.eql(u8, "-dest", arg)) {
i += 1;
if (i >= args.len) fatal("expected arg after '{s}'", .{arg});
opt_dest = args[i];
} else {
fatal("unknown argument '{s}'\n{s}", .{ arg, usage });
}
}
}
const key = opt_key orelse fatal("missing -key", .{});
const src = opt_src orelse fatal("missing -src", .{});
const dest = opt_dest orelse fatal("missing -dest", .{});
const cwd = std.Io.Dir.cwd();
//never touch a fossil/git checkout at the destination
inline for (.{ ".fslckout", "_FOSSIL_", ".git" }) |vcsmark| {
const p = try std.fs.path.join(arena, &.{ dest, vcsmark });
if (pathExists(cwd, io, p)) {
try say(io, arena, "stagetree: {s} is a VCS checkout ({s} present) - left untouched\n", .{ dest, vcsmark });
return std.process.cleanExit(io);
}
}
const src_stat = cwd.statFile(io, src, .{}) catch |err| {
fatal("cannot stat -src '{s}': {s}", .{ src, @errorName(err) });
};
const marker_path = try std.fs.path.join(arena, &.{ dest, marker_name });
const pinned = !std.mem.eql(u8, key, "-");
if (pinned and src_stat.kind == .directory) {
if (cwd.readFileAlloc(io, marker_path, arena, .limited(64 * 1024))) |existing| {
if (std.mem.eql(u8, std.mem.trim(u8, existing, " \r\n"), key)) {
try say(io, arena, "stagetree: {s} up to date (pin match)\n", .{dest});
return std.process.cleanExit(io);
}
} else |_| {}
}
if (src_stat.kind == .file) {
//single recipe file: parent dir must exist or be creatable; plain replace
if (std.fs.path.dirname(dest)) |destparent| {
cwd.createDirPath(io, destparent) catch |err| {
fatal("cannot create '{s}': {s}", .{ destparent, @errorName(err) });
};
}
cwd.deleteFile(io, dest) catch |err| switch (err) {
error.FileNotFound => {},
else => fatal("cannot remove stale '{s}': {s}", .{ dest, @errorName(err) }),
};
cwd.copyFile(src, cwd, dest, io, .{}) catch |err| {
fatal("copy '{s}' -> '{s}' failed: {s}", .{ src, dest, @errorName(err) });
};
try say(io, arena, "stagetree: staged file {s}\n", .{dest});
return std.process.cleanExit(io);
}
//directory tree: delete stale destination entirely (orphan files from a previous
//pin must not survive), then copy
cwd.deleteTree(io, dest) catch |err| {
fatal("cannot remove stale tree '{s}': {s}", .{ dest, @errorName(err) });
};
cwd.createDirPath(io, dest) catch |err| {
fatal("cannot create '{s}': {s}", .{ dest, @errorName(err) });
};
var src_dir = cwd.openDir(io, src, .{ .iterate = true }) catch |err| {
fatal("cannot open -src dir '{s}': {s}", .{ src, @errorName(err) });
};
defer src_dir.close(io);
var dest_dir = cwd.openDir(io, dest, .{}) catch |err| {
fatal("cannot open -dest dir '{s}': {s}", .{ dest, @errorName(err) });
};
defer dest_dir.close(io);
var nfiles: usize = 0;
var walker = try src_dir.walk(arena);
defer walker.deinit();
while (try walker.next(io)) |entry| {
switch (entry.kind) {
.directory => {
dest_dir.createDirPath(io, entry.path) catch |err| {
fatal("mkdir '{s}' in '{s}': {s}", .{ entry.path, dest, @errorName(err) });
};
},
.file => {
src_dir.copyFile(entry.path, dest_dir, entry.path, io, .{}) catch |err| {
fatal("copy '{s}' into '{s}' failed: {s}", .{ entry.path, dest, @errorName(err) });
};
nfiles += 1;
},
else => {},
}
}
if (pinned) {
try cwd.writeFile(io, .{ .sub_path = marker_path, .data = key });
}
try say(io, arena, "stagetree: staged {d} files -> {s}\n", .{ nfiles, dest });
return std.process.cleanExit(io);
}
fn say(io: std.Io, arena: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void {
const msg = try std.fmt.allocPrint(arena, fmt, args);
var f = std.Io.File.stdout();
try f.writeStreamingAll(io, msg);
}
fn pathExists(dir: std.Io.Dir, io: std.Io, p: []const u8) bool {
dir.access(io, p, .{}) catch return false;
return true;
}
fn fatal(comptime format: []const u8, args: anytype) noreturn {
std.debug.print(format, args);
std.process.exit(1);
}
Loading…
Cancel
Save