diff --git a/build.zig b/build.zig index a2e4db0..a1e988e 100644 --- a/build.zig +++ b/build.zig @@ -118,21 +118,27 @@ pub fn build(b: *std.Build) void { } /// Produce the `build_info` module exposing `version` (derived from `git -/// describe`) and `build_timestamp` (Unix epoch seconds at build time). +/// describe`) and `build_timestamp` (committer timestamp of HEAD). /// Consumed as `@import("build_info")` from `src/version.zig`. +/// +/// Both values are reproducible per-commit: `git describe` is stable +/// until a new commit/tag/dirty-flag flip, and the committer timestamp +/// comes from `git log -1 --format=%ct`. Neither varies with wall-clock +/// time, so the Options module doesn't rebuild every invocation, which +/// used to cascade into a full exe relink on every `zig build`. +/// +/// Falls back to `fallbackVersion()` / `0` when git is unavailable +/// (source tarball, pre-commit environment). fn buildInfoOptions(b: *std.Build) *std.Build.Module { const opts = b.addOptions(); - // `git describe --tags --always --dirty` gives the closest tag, with a - // distance-+-hash suffix when HEAD isn't exactly on a tag, plus a - // `-dirty` marker if the working tree has uncommitted changes. Falls - // back to the build.zig.zon `.version` when git is unavailable. const version = gitDescribe(b) orelse fallbackVersion(); opts.addOption([]const u8, "version", version); - // Seconds since the Unix epoch at build time. Rendered by `zfin version - // --verbose` as a human-readable date. - opts.addOption(i64, "build_timestamp", std.time.timestamp()); + // Use HEAD's committer timestamp (reproducible) instead of + // `std.time.timestamp()` (changes every build). See comment above. + const timestamp = gitHeadTimestamp(b) orelse 0; + opts.addOption(i64, "build_timestamp", timestamp); return opts.createModule(); } @@ -141,7 +147,22 @@ fn buildInfoOptions(b: *std.Build) *std.Build.Module { /// the trimmed output. Returns null on any error (git missing, not a repo, /// non-zero exit). fn gitDescribe(b: *std.Build) ?[]const u8 { - var child = std.process.Child.init(&.{ "git", "describe", "--tags", "--always", "--dirty" }, b.allocator); + return gitCapture(b, &.{ "git", "describe", "--tags", "--always", "--dirty" }); +} + +/// Run `git log -1 --format=%ct HEAD` and parse the stdout as an i64 +/// (Unix seconds). Returns null on any error. Stable per-commit, which +/// keeps the Options module cache-friendly. +fn gitHeadTimestamp(b: *std.Build) ?i64 { + const text = gitCapture(b, &.{ "git", "log", "-1", "--format=%ct", "HEAD" }) orelse return null; + return std.fmt.parseInt(i64, text, 10) catch null; +} + +/// Spawn a git subcommand in the repo root, capture stdout, trim and +/// dupe it through `b.allocator`. Returns null on any error (git +/// missing, non-zero exit, empty output). +fn gitCapture(b: *std.Build, argv: []const []const u8) ?[]const u8 { + var child = std.process.Child.init(argv, b.allocator); child.cwd = b.build_root.path; child.stdout_behavior = .Pipe; child.stderr_behavior = .Ignore; diff --git a/src/version.zig b/src/version.zig index 0b1eeef..0573c3e 100644 --- a/src/version.zig +++ b/src/version.zig @@ -2,8 +2,13 @@ //! //! `version_string` is `git describe --tags --always --dirty` output (or the //! `.version` field from `build.zig.zon` if git is unavailable at build time). -//! `build_timestamp` is the Unix epoch seconds at the moment the build -//! executed. Together they identify the binary precisely. +//! `build_timestamp` is the committer timestamp of HEAD (Unix epoch +//! seconds), or 0 when git is unavailable. Together they identify the +//! binary precisely. +//! +//! The timestamp is deliberately per-commit rather than wall-clock: it +//! keeps the build-options module cache-stable, so repeated `zig build` +//! invocations don't relink the exe just because the second ticked over. //! //! Consumers (the `zfin version` command, snapshot metadata writers, bug //! reports) should route through this module rather than importing @@ -17,8 +22,9 @@ const build_info = @import("build_info"); /// "1a2b3c4" (no tags yet), or a fallback from build.zig.zon. pub const version_string: []const u8 = build_info.version; -/// Unix epoch seconds at build time. Rendered as ISO date by the -/// `zfin version --verbose` command. +/// Unix epoch seconds — committer timestamp of HEAD at build time, or 0 +/// when git is unavailable. Rendered as ISO date by the `zfin version +/// --verbose` command; 0 renders as 1970-01-01 (clearly-fake sentinel). pub const build_timestamp: i64 = build_info.build_timestamp; /// True when `version_string` ends in `-dirty`, indicating the build was @@ -31,9 +37,10 @@ test "version_string is non-empty" { try std.testing.expect(version_string.len > 0); } -test "build_timestamp is positive" { - // A legitimate build always runs after 2020 (roughly 1577836800). - // If this fires, either the host clock is badly wrong, or the build - // options didn't wire through. - try std.testing.expect(build_timestamp > 1_577_836_800); +test "build_timestamp is either zero (no-git fallback) or a plausible commit time" { + // Two acceptable shapes: + // 0 → git unavailable or `git log -1 %ct` failed + // > 1_577_836_800 → real committer timestamp (Jan 1 2020 onward) + // Anything in between would indicate a broken build-info wiring. + try std.testing.expect(build_timestamp == 0 or build_timestamp > 1_577_836_800); }