Story: Move the version stamp out of the binaries
Table of Contents
- 1. Goal
- 2. Status
- 3. Acceptance
- 4. Design
- 4.1. Nothing is embedded in a binary.
version.cpp.inloses - 4.2. No new component. The stamp stays in
ores.utility. Once nothing is - 4.3. A manifest carries the fast-changing facts. A
key=valuefile written - 4.4. Lookup order, first hit wins:
- 4.5. Why each target works, and what it cost. A local build writes the
- 4.6. Nothing may depend on the manifest. This is the one way to reintroduce
- 4.7. Reading it.
build_info()reads the first manifest that carries - 4.8. No signing yet. This is a development traceability mechanism, so the
- 4.1. Nothing is embedded in a binary.
- 5. Tasks
- 6. Decisions
- 7. Out of scope
- 8. Result
This page documents a story in Sprint 28. It captures the goal, current status, acceptance criteria, and the tasks that compose it.
1. Goal
Know which commit produced a running binary, without a commit changing any compiled artifact.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent sprint | Sprint 28 |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-10-14 |
3. Acceptance
- A commit changes no compiled file, so the build after a commit relinks no target.
- A running service, the shell and the web server report the commit that produced them, wherever they run.
- The same mechanism serves a local build, a package and a deployed image.
- A missing or unreadable manifest degrades to the release version with a clear marker. It never stops a service starting.
- The relink set a commit causes is measured before and after.
4. Design
Agreed 2026-10-14, revised the same day after the analysis.
4.1. Nothing is embedded in a binary. version.cpp.in loses
@ORES_BUILD_INFO@. version.hpp keeps only the release numbers and the
platform triplet, which change at release time, not per commit. The
generator keeps writing version.cpp, but its content no longer depends on
the commit, so copy_if_different leaves it alone and nothing recompiles or
relinks when a commit lands.
4.2. No new component. The stamp stays in ores.utility. Once nothing is
embedded, the relink cascade disappears on its own: libores.utility.so
changes only when utility code changes, which it has to. Isolating version
into ores.version was a means of shrinking the cascade, and with the
cascade gone the means buys nothing. It would also cost a whole shared
library and a codegen ceremony for one translation unit, at a time when
LINK_DEPENDS_NO_SHARED cannot be scoped to a single dependency anyway.
4.3. A manifest carries the fast-changing facts. A key=value file written
beside the binaries:
format=1 version=1.8.17.0 build_info=local bdf73e1d24 commit=bdf73e1d24 commit_full=bdf73e1d24... dirty=false branch=main triplet=x64-linux checkout_label=brave_hopper
key=value rather than JSON because the only reader is one C++ function
that wants one field, and a line-oriented file needs no parser, no schema
tolerance rules and no library. It is readable with cat and searchable
with grep. If a JSON consumer ever appears, the format can move.
There is deliberately no built_at. The wall-clock time was already
available at runtime from the executable's own mtime, and writing it here
would change the file on every build for no gain.
checkout_label is empty in a local build, because compass reads
ORES_CHECKOUT_LABEL from the environment file but does not export it into
the build. The branch field identifies the worktree today. Populating the
label is a small compass change if it turns out to be worth having.
4.4. Lookup order, first hit wins:
$ORES_VERSION_MANIFESTversion.manifestbeside the running executable/usr/local/share/ores/version.manifest/etc/ores/version.manifest
Resolving "beside the running executable" reuses the existing
ores.platform::process::executable_path(). Keeping the stamp in
ores.utility introduces no new dependency edge, since that library
already links ores.platform.
4.5. Why each target works, and what it cost. A local build writes the
manifest into publish/bin next to the binaries. A package and a deployed
image do not copy publish/ wholesale: they stage a curated subset, so each
stager has to carry the manifest explicitly or the image silently reports an
unknown commit. Both were taught to. docker/stage-runtime.sh, which feeds
the compute image, copies it into its flat bin/. And
library_partition.stage_for_build(), which feeds the base and runtime
images, copies it beside each service in services/<svc>/bin/.
Copying it into the flat bin/ before the ldd walk is a trap worth
recording. ldd fails on anything that is not an ELF binary, and
stage-runtime.sh runs under set -euo pipefail, so a single failing
ldd aborted the staging in silence and produced an image carrying no
libraries at all. The walk is now tolerant of a non-binary, and the manifest
is copied after it.
4.6. Nothing may depend on the manifest. This is the one way to reintroduce
the bug. The manifest must not appear in BYPRODUCTS, in DEPENDS, in
configure_file, or in any command that produces a compiled input. It is
written as an undeclared side effect of a target that runs every build. A
build input that changes every commit is exactly what relinks the tree.
4.7. Reading it. build_info() reads the first manifest that carries
build_info and caches it in a function-local static, so the file is read
once per process. full_version_string() and format_startup_message()
keep their shape and their output. build_info() returns "unknown" when
no manifest can be read, and the reader skips a manifest that is readable
but lacks the field, so a damaged record can never stop a service starting.
4.8. No signing yet. This is a development traceability mechanism, so the
manifest binds to nothing and a stale manifest beside a new binary is possible. Signing, and binding the artifact digest into the manifest, are deferred.
5. Tasks
| Task | State | Start | End | Description |
|---|---|---|---|---|
| Scaffold story: Isolate the version stamp into its own component | DONE | 2026-10-14 | 2026-10-14 | Story scaffolding rides this task: documents, sprint wiring, and the scaffold PR. Close it before merging that PR. |
| Analyse how version is used and size the relink blast radius | DONE | 2026-10-14 | Initial task for: Isolate the version stamp into its own component | |
| Move the version stamp out of the binaries | DONE | 2026-10-14 | 2026-10-14 | The per-commit git hash is compiled into ores.utility.lib, which 179 targets link, so every commit relinks all of them. Move the commit out of every binary into a manifest read at runtime, so a commit changes no compiled file. No new component: with nothing embedded the cascade disappears on its own. |
6. Decisions
- Nothing is embedded in a binary; a manifest is the only carrier of the
commit. Chosen over a linker-owned stamp and over
LINK_DEPENDS_NO_SHARED, which cannot be scoped to a single dependency and would blunt ABI-change detection for every shared library of a target it is set on. - No separate
ores.versioncomponent. With nothing embedded, the cascade the component was meant to shrink no longer exists, so it would buy a shared library and a codegen ceremony for one translation unit and no benefit. Revisit only if a second reason to split appears. - The manifest is not signed yet. It is for development traceability.
- The manifest is
key=value, not JSON, because the only reader wants one field and a line-oriented file needs no parser or schema tolerance rules. - The manifest carries no build timestamp. Runtime already reads the executable's mtime for that, and a timestamp would rewrite the file on every build for no gain.
7. Out of scope
- Signing the manifest, and binding the artifact digest into it.
LINK_DEPENDS_NO_SHARED. Considered and rejected for this work.- The separate
ores.versioncomponent. Considered and dropped. - The wider observation that relinking dependents of a changed shared library is not required for correctness, so most of the 179 relinks are wasted work. Worth a separate decision.
8. Result
Landing a commit no longer recompiles or relinks anything.
The commit left every binary for a version.manifest read at runtime, so
version.cpp is byte-identical on every build and libores.utility.so
changes only when utility code changes, which it has to. A commit-only build
now does 0 compiles and 0 links, where it previously relinked all 179 direct
dependents of that library. A real change to version.cpp still costs 1
compile and 177 links, so the relink is now driven by content rather than by
the stamp.
The work is verified where it runs, not only where it is built. The compute
image was built and deployed to goedel, and its three nodes report the commit
from the manifest inside the container. The local environment was stopped and
started and reports the same commit. That mattered: the first attempt staged
the manifest into the flat bin/ before the ldd walk, and ldd failing on
a non-binary under set -euo pipefail aborted the staging in silence,
producing an image with no libraries. Only running the stager found it.
No separate ores.version component, deliberately. The cascade it was meant
to shrink is gone, so it would have been a shared library and a codegen
ceremony for one translation unit and no benefit.
Open, and worth a decision later: the manifest records the commit of the last build, while the image tag records the checkout at image-build time. A C++ commit without a rebuild would make the tag claim more than the binary can back.