myxce/.kiro/specs/build-time-versioning/research.md
Rongsong Shen d08fda76a5
feat(build-time-versioning): nix packages compute build time at build phase
First attempt used builtins.currentTime at flake-evaluation time -- this
broke the plain, documented `nix build .#xcecore_debug` command entirely
(pure-eval strips builtins.currentTime, requiring --impure) and produced
a raw epoch-seconds string instead of UTC ISO-8601. Caught by task-local
review (REJECTED, round 1) before merge.

Fixed by moving the computation into each derivation's own build phase:
a preConfigure hook in xce.nix/xcecli.nix runs `date -u` (stdenv's
bundled coreutils, unaffected by pure-eval) and appends -Dbuild_time= to
mesonFlagsArray, with a fail-loud guard against empty output. commit_id's
existing self.shortRev/self.dirtyShortRev eval-time mechanism is
untouched. Verified via nix eval (no --impure) and nix build reaching
configure with real values on both xcecore_debug and xcecli (round 2,
APPROVED).

Task 2.2/13 of build-time-versioning. Also corrects design.md/research.md,
which described the now-superseded builtins.currentTime approach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:32:34 +00:00

57 lines
8.8 KiB
Markdown

# Research & Design Decisions
## Summary
- **Feature**: `build-time-versioning`
- **Discovery Scope**: Extension (existing, partial C-side infrastructure; zero Go/Python infrastructure)
- **Key Findings**:
- Controller and scheduler already have a **working `--version`/`-V` flag** (GNU `argp` auto-generates it from `argp_program_version = MK_VERSION("xce v0.0.1-", STR(VERSION_COMMIT_ID))`, both `main.c`s) and a project-wide `VERSION_COMMIT_ID` preprocessor define wired through meson (`src/meson.build:30`, `src/meson.options`'s `commit_id` option) — but it carries **no build timestamp**, and **`xce-build.sh` never sets `-Dcommit_id=`**, so every locally-built binary (the entire `/xce-build` dev loop, including the one that produced the stale binary in the motivating incident) gets an **empty** commit id today.
- Worker uses plain `getopt_long()`, not `argp` — it has **no `--version` at all**.
- `git` is deliberately **not** a nix devShell buildInput (`src/nixSupport/xce.nix`) — the existing `commit_id` mechanism avoids shelling out to git entirely; the two production nix packages (`xce.nix:109`, `xcecli.nix:30`) derive it from the flake's own `self.shortRev`/`self.dirtyShortRev` introspection instead. Any new mechanism must respect this: no `run_command`/`custom_target` in `meson.build` may shell out to `git`.
- Go (apiserver, xcecli) and Python (xceadmin) have **zero** version infrastructure — no `-ldflags` injection anywhere in `src/tools/go_build` or `src/frontend/Makefile`, no `click.version_option`, no dynamic `pyproject.toml` version.
## Research Log
### Where does `xce-build.sh` (the actual dev-loop path) get its build metadata from?
- **Context**: the motivating incident happened via `/xce-build scheduler debug`, not `nix build` — need to know which computation path is actually load-bearing for fixing it.
- **Findings**: `xce-build.sh`'s `MESON_OPTS` array (set per-profile at `xce-build.sh:70-72`) never includes `-Dcommit_id=` or anything build-time related; `meson setup` is called with only `--buildtype`/`-Db_sanitize`. `git` IS available here despite not being a declared nix devShell buildInput, because `nix develop` (unlike a sandboxed `nix build`) layers onto the ambient host PATH rather than replacing it — this project's own scripts already run from inside a real git checkout.
- **Implications**: `xce-build.sh` must be the one to compute commit id (`git rev-parse --short HEAD`, `-dirty` suffixed via `git diff --quiet HEAD --` / `git status --porcelain`) and build time (`date -u`), independently of the nix-package path, and pass both via meson options. This is the fix that actually closes the incident's gap — the nix-package path already has a working (commit-id-only) mechanism.
### Message/value plumbing per toolchain
- **Context**: three different build tool invocations (meson C custom target-free preprocessor define, Go's `go_build` shell script + `Makefile`, Python's `meson-build.sh` custom_target) need the same two values.
- **Findings**: `src/meson.build:30` already threads `commit_id` project-wide via `add_project_arguments()` for C. `src/tools/go_build` (invoked by both `apiserver/meson.build`'s and `xcecli/meson.build`'s `custom_target()`) already threads other env vars (e.g. CGO flags) into its `go build` invocation — same mechanism can carry new `XCE_GIT_COMMIT`/`XCE_BUILD_TIME` env vars for `-ldflags -X`. `src/xceadmin/meson.build`'s `custom_target` already passes several positional args to `meson-build.sh`; two more (or two new env vars) can carry the same values for a small generated Python module written before the `pip install` step.
- **Implications**: meson itself is the single source of truth for one build invocation — it resolves the two new options (`commit_id`, now actually SET by `xce-build.sh`; a new `build_time`) once, and every language-specific build step (C define, Go ldflags, Python generated module) reads from that same resolved value instead of independently recomputing it. Avoids the 3 toolchains ever disagreeing within one build.
### Dirty-tree representation: separate flag vs. suffix convention
- **Context**: Requirement 1 AC3 requires distinguishing an uncommitted-changes build from an exact commit.
- **Findings**: both git's own `--dirty` flag and this project's existing Nix `self.dirtyShortRev` already use a suffix convention (`<hash>-dirty`) rather than a separate boolean field.
- **Implications**: reuse that convention — `commit_id`'s own string value carries `-dirty` when applicable, no third meson option/env var needed. Matches the Build-vs-Adopt lens (reuse an established convention already present twice in this codebase) and the Simplification lens (one string carries both facts).
## Design Decisions
### Decision: One resolved value pair, fed from two independent computation sites
- **Context**: production (`nix build`) and dev-loop (`xce-build.sh`) are architecturally different environments (fully sandboxed vs. ambient-PATH devShell) that already compute `commit_id` differently for exactly that reason.
- **Alternatives Considered**:
1. Make `xce-build.sh` also avoid shelling out to git, deriving everything from Nix like the packages do — rejected, `xce-build.sh` doesn't invoke Nix package builds at all, it drives `meson` directly inside `nix develop`.
2. Add a git-invoking `run_command()` inside `meson.build` itself, used by both paths — rejected, breaks the sandboxed `nix build` path where git is unavailable by design.
- **Selected Approach**: extend the existing split — `xce-build.sh` computes via `git`+`date` and passes `-Dcommit_id=`/`-Dbuild_time=`; the nix packages (`xce.nix`, `xcecli.nix`) compute `commit_id` via `self.shortRev`/`self.dirtyShortRev` (unchanged, eval-time) and `build_time` via a `preConfigure` build-phase `date -u` hook appending to `mesonFlagsArray` (build-time, not eval-time), passed the same `-Dbuild_time=` way.
- **Rationale**: matches the codebase's own existing precedent exactly; zero new dependencies; keeps the sandboxed build's reproducibility model intact.
- **Trade-offs**: two computation sites to keep in sync (same risk the existing `commit_id`-only mechanism already carries and has lived with).
- **Follow-up**: verify during implementation that a plain `meson setup` with neither `xce-build.sh` nor a nix package (e.g. `meson setup build` run by hand) degrades to the "unknown" fallback (Requirement 1 AC4), not a build failure.
- **Correction (task 2.2, round 1 → round 2)**: the nix-side value was originally attempted via `builtins.currentTime` (eval-time) as noted above — this broke Nix's flake pure-eval mode entirely (`nix build .#xcecore_debug`, the documented no-flag command, failed with `error: attribute 'currentTime' missing`, requiring `--impure`) and produced a raw epoch-seconds string rather than UTC ISO-8601. Caught by task-local review (REJECTED, round 1) before merge; corrected to the `preConfigure`/`date -u` build-phase approach described in "Selected Approach" above, verified to restore pure-eval and produce a correctly-formatted value (round 2, APPROVED). Recorded here so this decision's rationale stays accurate for anyone reading only `research.md`.
### Decision: Worker gets a hand-rolled `--version` case, not a switch to argp
- **Context**: worker uses `getopt_long()`, not `argp`; controller/scheduler's free `--version` comes from argp's own auto-generated handling.
- **Alternatives Considered**: migrate worker's arg parsing to argp for consistency — rejected as disproportionate scope (a full parser migration to fix a missing flag).
- **Selected Approach**: add a `{"version", no_argument, ...}` long option and a `case`, printing the same `MK_VERSION`-style string then exiting 0 — mirrors argp's own output shape without adopting argp.
- **Rationale**: minimal, consistent output, zero risk to worker's existing option handling.
## Risks & Mitigations
- Two independent computation sites (dev-loop vs. nix-package) could drift in format — mitigate by having both resolve to the exact same two meson option names and the exact same downstream consumption code, so only the *value-sourcing* differs, never the *shape*.
- `go_build`/`meson-build.sh` are shared shell scripts already carrying other env-var-driven behavior (CGO flags, schema sync) — new env vars must be additive/optional so a caller that doesn't set them still builds (falls back to "unknown"), not required.
## References
- `src/meson.build:30`, `src/meson.options` — existing `commit_id` mechanism.
- `src/flake.nix:15-18`, `src/nixSupport/xce.nix:109`, `xcecli.nix:30` — existing Nix-side commit id derivation.
- `src/tools/xce-build.sh:70-98` — dev-loop meson invocation, current gap.
- `src/tools/go_build`, `src/frontend/apiserver/meson.build:53-62`, `src/frontend/xcecli/meson.build:9-17` — Go build plumbing.
- `src/xceadmin/meson.build:27-47`, `src/xceadmin/meson-build.sh` — Python build plumbing.