a73x

969a7983

Install without root, and keep the commit in the container

a73x   2026-08-13 15:01

Commit message
Install without root, and keep the commit in the container

Two packaging defects, both of which make the documented path the wrong one.

`PREFIX ?= /usr/local` meant the `make install` the README documents without
qualification failed for the ordinary case — one person installing a personal
forge on their own machine — and only worked under sudo. The default is now
`$HOME/.local`, which needs no root and whose `bin` is already on most PATHs;
`sudo make install PREFIX=/usr/local` remains the system-wide install.

`uninstall` removed the two binaries and left every man page behind. It now
removes `git-collab.1` and `git-collab-*.1` from the same prefix, which is the
whole set `install` writes plus any pages an older version installed for
subcommands that no longer exist.

Man pages also no longer gate the install. They were a prerequisite, so a
prefix with an unwritable `share/man` aborted before a single binary was
written — the convenience blocking the thing being installed. The binaries go
in first and the man pages are attempted after, with a warning and exit 0 if
they cannot be written. `make install-man` on its own stays strict: asking for
man pages explicitly and silently not getting them would be the worse failure.

Separately (7156e09a): a container build has no commit to report. `.dockerignore`
excludes `.git/`, correctly — the image needs the source, not the repository —
so `build.rs` found nothing to read and `--version` degraded to the crate
version alone, in the one place that costs most. A deployed container is the
artifact whose identity is hardest to establish after the fact: you cannot
`git log` it, and an image tagged by commit is only as good as whoever typed
the tag.

The commit is now passed in instead of the repository being shipped:
`--build-arg GIT_COMMIT`, which `make docker` fills from `git rev-parse`, along
with a dirty marker so an image built from a work-in-progress tree says so.
`build.rs` prefers an explicitly passed commit and falls back to asking git.

The passed value deliberately bypasses the same-checkout guard from 5519937d
rather than being filtered by it. That guard exists because *discovery* can
silently pick up a stranger's repository when this crate is vendored into
another tree, and nobody chose that; passing a value is a choice made by
whoever is in a position to know, so it is authoritative. The price of that
trust is that a passed value is validated: `GIT_COMMIT=HEAD` from an unexpanded
shell variable fails the build loudly rather than quietly producing the
untraceable binary the mechanism exists to prevent. A *discovered* value that
looks wrong still degrades to silence, because failing a build over an odd
local checkout would regress the tarball case.

The decision logic moved to `src/build_provenance.rs`, included by `build.rs`
the way `src/cli.rs` already is, so it can be tested. `tests/version_test.rs`
covers the resolution rules directly, and builds a throwaway crate whose build
script is that code to check what a real `cargo build` emits with a commit
passed and with neither a commit nor a repository — the two paths a binary
built from this checkout can never exercise itself.

One more thing, found by running the first container rather than by reasoning
about it: it reported the commit it was given, and `-dirty`, from a spotlessly
clean tree. `ENV FOO=$BAR` with an unset `ARG BAR` does not leave `FOO` unset,
it sets it to the empty string, and `option_env!` reads the compiler's whole
environment and not only what the build script emitted. So both variables
arrived as `Some("")` no matter how little the build had been told — which
would also have rendered a container built with no arguments at all as
`0.1.0 ()`. `src/cli.rs` now asks what a variable says rather than whether it
exists. Every unit test here passed while that was broken; the image did not.

Fixes 4876fba0
Fixes 7156e09a

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Dockerfile
Old New
@@ -1,4 +1,16 @@
1 FROM rust:1.88-bookworm AS builder 1 FROM rust:1.88-bookworm AS builder
2
3 # The build context has no `.git/` (see `.dockerignore`), so `build.rs` cannot
4 # discover the commit and `--version` in the running container would name only
5 # the crate version. Whoever starts the build does know the commit, so they pass
6 # it: `make docker` does this from `git rev-parse`. Left unset, both arrive as
7 # empty strings, which `build.rs` reads as "not given" and degrades exactly as a
8 # release tarball does.
9 ARG GIT_COMMIT
10 ARG GIT_DIRTY
11 ENV GIT_COLLAB_BUILD_COMMIT=$GIT_COMMIT
12 ENV GIT_COLLAB_BUILD_DIRTY=$GIT_DIRTY
13
2 WORKDIR /build 14 WORKDIR /build
3 COPY . . 15 COPY . .
4 RUN cargo build --release --bin git-collab-server 16 RUN cargo build --release --bin git-collab-server
Makefile
Old New
@@ -2,7 +2,11 @@ CARGO := cargo
2 PROFILE ?= debug 2 PROFILE ?= debug
3 BIN := git-collab 3 BIN := git-collab
4 SERVER_BIN := git-collab-server 4 SERVER_BIN := git-collab-server
5 PREFIX ?= /usr/local 5 # A personal forge is installed by one person onto their own machine, so the
6 # default has to be a directory that person can write to. `~/.local` is already
7 # on most PATHs via `~/.local/bin` and is where a per-user install belongs;
8 # `make install PREFIX=/usr/local` under sudo remains the system-wide install.
9 PREFIX ?= $(HOME)/.local
6 10
7 # --- Building blocks --- 11 # --- Building blocks ---
8 .PHONY: fmt check clippy test build install clean 12 .PHONY: fmt check clippy test build install clean
@@ -30,19 +34,39 @@ lint: fmt check clippy
30 ci: lint test build 34 ci: lint test build
31 35
32 # --- Install --- 36 # --- Install ---
37 .PHONY: uninstall
38
39 # The binaries go in first, and the man pages are attempted afterwards without
40 # the power to fail the install. Man pages are a convenience; the binaries are
41 # the thing being installed. Someone whose `$(MAN_INSTALL_DIR)` is not writable
42 # still wants `git-collab` on their PATH, and the old ordering — man pages as a
43 # prerequisite — meant the convenience could abort the install before a single
44 # binary had been written. `make install-man` on its own stays strict: asking
45 # for man pages explicitly and silently not getting them would be worse.
33 install: PROFILE := release 46 install: PROFILE := release
34 install: build install-man 47 install: build man
35 install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN) 48 install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN)
36 install -Dm755 target/release/$(SERVER_BIN) $(PREFIX)/bin/$(SERVER_BIN) 49 install -Dm755 target/release/$(SERVER_BIN) $(PREFIX)/bin/$(SERVER_BIN)
37 50 @install -d $(MAN_INSTALL_DIR) 2>/dev/null && \
51 install -m644 $(MAN_DIR)/*.1 $(MAN_INSTALL_DIR)/ 2>/dev/null && \
52 echo "installed man pages to $(MAN_INSTALL_DIR)" || \
53 echo "warning: could not write $(MAN_INSTALL_DIR) — binaries are installed, man pages are not"
54
55 # Removes exactly what `install` creates. The man pages are matched by name
56 # rather than regenerated: they are `git-collab.1` plus one page per subcommand,
57 # all named `git-collab-*.1`, so the two patterns are the whole set — including
58 # pages for subcommands that an older version installed and this one no longer
59 # generates, which are precisely the litter worth clearing.
38 uninstall: 60 uninstall:
39 rm -f $(PREFIX)/bin/$(BIN) $(PREFIX)/bin/$(SERVER_BIN) 61 rm -f $(PREFIX)/bin/$(BIN) $(PREFIX)/bin/$(SERVER_BIN)
62 rm -f $(MAN_INSTALL_DIR)/$(BIN).1 $(MAN_INSTALL_DIR)/$(BIN)-*.1
40 63
41 clean: clean-man 64 clean: clean-man
42 $(CARGO) clean 65 $(CARGO) clean
43 66
44 # --- Man pages --- 67 # --- Man pages ---
45 MAN_DIR := man/man1 68 MAN_DIR := man/man1
69 MAN_INSTALL_DIR := $(PREFIX)/share/man/man1
46 70
47 .PHONY: man install-man clean-man 71 .PHONY: man install-man clean-man
48 72
@@ -51,8 +75,8 @@ man:
51 @echo "Man pages written to $(MAN_DIR)/" 75 @echo "Man pages written to $(MAN_DIR)/"
52 76
53 install-man: man 77 install-man: man
54 install -d $(PREFIX)/share/man/man1 78 install -d $(MAN_INSTALL_DIR)
55 install -m644 $(MAN_DIR)/*.1 $(PREFIX)/share/man/man1/ 79 install -m644 $(MAN_DIR)/*.1 $(MAN_INSTALL_DIR)/
56 80
57 clean-man: 81 clean-man:
58 rm -rf man/ 82 rm -rf man/
@@ -60,12 +84,29 @@ clean-man:
60 # --- Docker --- 84 # --- Docker ---
61 REGISTRY := registry.a73x.sh 85 REGISTRY := registry.a73x.sh
62 IMAGE := $(REGISTRY)/git-collab-server 86 IMAGE := $(REGISTRY)/git-collab-server
63 TAG ?= 0.0.1 87
88 # `.dockerignore` excludes `.git/`, correctly — the image needs the source, not
89 # the repository — so the build inside the container has nothing to read the
90 # commit from and `--version` there would report the crate version alone. A
91 # deployed container is the artifact whose identity is hardest to establish
92 # after the fact: you cannot `git log` it, and an image tagged by commit is only
93 # as good as whoever typed the tag. So the commit is passed in, and the binary
94 # carries it. Tag and stamp come from the same `git rev-parse` for that reason.
95 GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null)
96 # `1` when tracked files differ from HEAD, so an image built from a
97 # work-in-progress tree says `-dirty` instead of claiming to be the commit.
98 # The `test -n` rather than the porcelain output itself: `$(if ...)` strips
99 # whitespace from its condition, and porcelain lines start with a space.
100 GIT_DIRTY ?= $(shell test -n "$$(git status --porcelain --untracked-files=no 2>/dev/null)" && echo 1)
101 TAG ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo 0.0.1)
64 102
65 .PHONY: docker docker-push 103 .PHONY: docker docker-push
66 104
67 docker: 105 docker:
68 docker build -t $(IMAGE):$(TAG) . 106 docker build \
107 $(if $(GIT_COMMIT),--build-arg GIT_COMMIT=$(GIT_COMMIT),) \
108 $(if $(GIT_DIRTY),--build-arg GIT_DIRTY=1,) \
109 -t $(IMAGE):$(TAG) .
69 110
70 docker-push: docker 111 docker-push: docker
71 docker push $(IMAGE):$(TAG) 112 docker push $(IMAGE):$(TAG)
README.md
Old New
@@ -143,9 +143,20 @@ one parseable value whether or not the network was involved.
143 Requires Rust 1.88 or newer. 143 Requires Rust 1.88 or newer.
144 144
145 ```console 145 ```console
146 $ make install # installs git-collab and git-collab-server, plus man pages 146 $ make install # git-collab and git-collab-server into ~/.local/bin, plus man pages
147 ``` 147 ```
148 148
149 That installs under `$HOME/.local`, so it needs no root and `~/.local/bin` is
150 already on most PATHs. For a system-wide install:
151
152 ```console
153 $ sudo make install PREFIX=/usr/local
154 ```
155
156 `make uninstall` removes what `make install` wrote, and takes the same `PREFIX`.
157 If the man directory is not writable the binaries still install and a warning
158 says the man pages did not.
159
149 ## Getting started 160 ## Getting started
150 161
151 ```console 162 ```console
build.rs
Old New
@@ -1,9 +1,7 @@
1 use std::env;
2 use std::fs;
3 use std::path::PathBuf;
4 use std::process::Command;
5
6 include!("src/cli.rs"); 1 include!("src/cli.rs");
2 // Brings `std::{env, fs, path::PathBuf, process::Command}` into scope for this
3 // file too; importing them here as well would collide with those.
4 include!("src/build_provenance.rs");
7 5
8 fn main() { 6 fn main() {
9 // Cargo's default is to rerun this script whenever any file in the package 7 // Cargo's default is to rerun this script whenever any file in the package
@@ -24,76 +22,6 @@ fn main() {
24 generate_manpages(&cmd, &out); 22 generate_manpages(&cmd, &out);
25 } 23 }
26 24
27 /// Capture the commit this binary is being built from, for `--version`.
28 ///
29 /// Every step here is allowed to fail and none of them may fail the build: the
30 /// source may be a release tarball with no `.git`, git may not be installed,
31 /// or the checkout may be one this build has no permission to inspect. In all
32 /// of those cases nothing is emitted, `option_env!` in `src/cli.rs` yields
33 /// `None`, and `--version` degrades to the crate version alone.
34 fn emit_build_provenance() {
35 if !in_our_own_checkout() {
36 return;
37 }
38 let Some(commit) = git(&["rev-parse", "HEAD"]) else {
39 return;
40 };
41 if commit.is_empty() || !commit.chars().all(|c| c.is_ascii_hexdigit()) {
42 return;
43 }
44 println!("cargo:rustc-env=GIT_COLLAB_BUILD_COMMIT={commit}");
45
46 // `--untracked-files=no` matches `git describe --dirty`: a stray build
47 // artifact or editor swapfile is not a modification of the source.
48 if let Some(status) = git(&["status", "--porcelain", "--untracked-files=no"]) {
49 if !status.is_empty() {
50 println!("cargo:rustc-env=GIT_COLLAB_BUILD_DIRTY=1");
51 }
52 }
53
54 // Rebuild when the checkout moves to another commit. `--git-path` resolves
55 // through worktrees and `$GIT_DIR`, where `.git` is a file rather than a
56 // directory, so a hard-coded `.git/HEAD` would silently track nothing.
57 for path in ["HEAD", "refs", "packed-refs"] {
58 if let Some(resolved) = git(&["rev-parse", "--git-path", path]) {
59 if PathBuf::from(&resolved).exists() {
60 println!("cargo:rerun-if-changed={resolved}");
61 }
62 }
63 }
64 }
65
66 /// Whether the enclosing git repository is this crate's own checkout.
67 ///
68 /// Vendoring `git-collab` into another project's tree would otherwise make
69 /// `git rev-parse HEAD` report *that* project's commit, and a `--version` that
70 /// names the wrong commit is worse than one that names none: the whole point
71 /// is to settle arguments about which source a binary came from.
72 fn in_our_own_checkout() -> bool {
73 let Some(toplevel) = git(&["rev-parse", "--show-toplevel"]) else {
74 return false;
75 };
76 let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") else {
77 return false;
78 };
79 match (
80 fs::canonicalize(&toplevel),
81 fs::canonicalize(&manifest_dir),
82 ) {
83 (Ok(a), Ok(b)) => a == b,
84 _ => false,
85 }
86 }
87
88 /// Run git and return trimmed stdout, or `None` for any failure at all.
89 fn git(args: &[&str]) -> Option<String> {
90 let output = Command::new("git").args(args).output().ok()?;
91 if !output.status.success() {
92 return None;
93 }
94 Some(String::from_utf8(output.stdout).ok()?.trim().to_string())
95 }
96
97 fn generate_manpages(cmd: &clap::Command, out: &PathBuf) { 25 fn generate_manpages(cmd: &clap::Command, out: &PathBuf) {
98 let man = clap_mangen::Man::new(cmd.clone()); 26 let man = clap_mangen::Man::new(cmd.clone());
99 let name = cmd.get_name().to_string(); 27 let name = cmd.get_name().to_string();
src/build_provenance.rs
Old New
@@ -0,0 +1,184 @@
1 // ---------------------------------------------------------------------------
2 // Capturing the commit a binary was built from, for `--version`.
3 //
4 // This file is `include!`d by `build.rs` and is also a module of the library,
5 // the same arrangement `src/cli.rs` uses — which is why the header is a plain
6 // comment and not a `//!` one: an inner doc comment cannot survive `include!`.
7 // The build script needs the code; the test suite needs to be able to reason
8 // about it. It therefore depends on nothing outside `std`.
9 //
10 // There are two ways a build can learn its commit, and they are not equally
11 // trustworthy in the same direction:
12 //
13 // Discovered — `git rev-parse HEAD` in the enclosing checkout. Convenient and
14 // usually right, but it is a guess made on the builder's behalf, and it is
15 // absent exactly when it is most wanted: a container build excludes `.git/`
16 // from its context, on purpose, and a release tarball never had one.
17 // Passed — `GIT_COLLAB_BUILD_COMMIT` in the build script's environment, set
18 // deliberately (`docker build --build-arg GIT_COMMIT=...`).
19 //
20 // A passed commit wins over a discovered one, and it bypasses the same-checkout
21 // guard below rather than being filtered by it. That is not a hole in the
22 // guard: the guard exists because *discovery* can silently pick up a stranger's
23 // repository when this crate is vendored into another tree, and nobody chose
24 // that. Passing a value is a choice, made by whoever is in a position to know —
25 // a wrapper that still has the repository when the build no longer does. The
26 // failure the guard prevents (reporting some other tree's commit because it
27 // happened to be lying around) cannot happen by accident down this path.
28 //
29 // The cost of that trust is that a passed value must be *checked*: a build
30 // handed `GIT_COMMIT=HEAD` by an unexpanded shell variable fails loudly instead
31 // of quietly shipping a binary that cannot say where it came from.
32 // ---------------------------------------------------------------------------
33
34 use std::env;
35 use std::fs;
36 use std::path::PathBuf;
37 use std::process::Command;
38
39 /// The environment variable that carries a commit into a build, and also the
40 /// name the value is re-emitted under for `src/cli.rs` to read. One name for
41 /// one fact, going in and coming out.
42 pub const COMMIT_VAR: &str = "GIT_COLLAB_BUILD_COMMIT";
43
44 /// Counterpart of [`COMMIT_VAR`] for the dirty marker. Only consulted when a
45 /// commit was passed: a build that discovers its own checkout can look at the
46 /// working tree itself and does not need to be told.
47 pub const DIRTY_VAR: &str = "GIT_COLLAB_BUILD_DIRTY";
48
49 /// Whether a string is shaped like a git object name.
50 ///
51 /// Length is bounded below at 7 — git's own shortest customary abbreviation,
52 /// and short enough already to be ambiguous in a large repository, so anything
53 /// shorter is a mistake — and above at 64, the width of a SHA-256 object name.
54 pub fn is_object_name(value: &str) -> bool {
55 (7..=64).contains(&value.len()) && value.chars().all(|c| c.is_ascii_hexdigit())
56 }
57
58 /// Decide which commit a build reports, given what was passed to it and what
59 /// it could discover.
60 ///
61 /// `Err` means the build should fail; `Ok(None)` means it should proceed and
62 /// report the crate version alone. The difference between those two is the
63 /// difference between a value someone supplied and a value nobody asked for:
64 /// a bad passed value is a broken invocation and must surface, while a bad or
65 /// missing discovered value is the ordinary condition of building outside a
66 /// checkout.
67 pub fn resolve_build_commit<'a>(
68 passed: Option<&'a str>,
69 discovered: Option<&'a str>,
70 ) -> Result<Option<&'a str>, String> {
71 // An empty or whitespace-only value is the absence of a decision, not a
72 // decision to report nothing: `--build-arg GIT_COMMIT=` and an `ARG` with
73 // no default both arrive here as "".
74 if let Some(passed) = passed.map(str::trim).filter(|v| !v.is_empty()) {
75 return if is_object_name(passed) {
76 Ok(Some(passed))
77 } else {
78 Err(format!(
79 "{COMMIT_VAR} is set to {passed:?}, which is not a git object name. \
80 Pass a commit (`--build-arg GIT_COMMIT=$(git rev-parse HEAD)`) or unset it."
81 ))
82 };
83 }
84 Ok(discovered
85 .map(str::trim)
86 .filter(|value| is_object_name(value)))
87 }
88
89 /// Emit the `cargo:` directives that put the commit into the binary.
90 ///
91 /// Every step is allowed to fail and none of them may fail the build — except
92 /// a malformed passed commit, which is a broken invocation rather than a
93 /// missing convenience. Otherwise nothing is emitted, `option_env!` in
94 /// `src/cli.rs` yields `None`, and `--version` degrades to the crate version.
95 pub fn emit_build_provenance() {
96 println!("cargo:rerun-if-env-changed={COMMIT_VAR}");
97 println!("cargo:rerun-if-env-changed={DIRTY_VAR}");
98
99 let own_checkout = in_our_own_checkout();
100 if own_checkout {
101 emit_git_rerun_paths();
102 }
103
104 let passed = env::var(COMMIT_VAR).ok();
105 let discovered = if own_checkout {
106 git(&["rev-parse", "HEAD"])
107 } else {
108 None
109 };
110
111 let commit = match resolve_build_commit(passed.as_deref(), discovered.as_deref()) {
112 Ok(Some(commit)) => commit.to_string(),
113 Ok(None) => return,
114 // The one fatal case. A build told the wrong thing about its own
115 // identity is worse than a build told nothing, because the wrong
116 // answer is the one that gets believed.
117 Err(message) => panic!("{message}"),
118 };
119 let dirty = if passed_a_commit(passed.as_deref()) {
120 // Nothing here can inspect the tree the commit refers to, so the
121 // caller has to say. The Makefile's `docker` target does.
122 env::var(DIRTY_VAR).is_ok_and(|value| !value.trim().is_empty())
123 } else {
124 // `--untracked-files=no` matches `git describe --dirty`: a stray build
125 // artifact or editor swapfile is not a modification of the source.
126 git(&["status", "--porcelain", "--untracked-files=no"])
127 .is_some_and(|status| !status.is_empty())
128 };
129
130 println!("cargo:rustc-env={COMMIT_VAR}={commit}");
131 if dirty {
132 println!("cargo:rustc-env={DIRTY_VAR}=1");
133 }
134 }
135
136 fn passed_a_commit(passed: Option<&str>) -> bool {
137 passed.map(str::trim).is_some_and(|value| !value.is_empty())
138 }
139
140 /// Rebuild when the checkout moves to another commit.
141 ///
142 /// `--git-path` resolves through worktrees and `$GIT_DIR`, where `.git` is a
143 /// file rather than a directory, so a hard-coded `.git/HEAD` would silently
144 /// track nothing.
145 fn emit_git_rerun_paths() {
146 for path in ["HEAD", "refs", "packed-refs"] {
147 if let Some(resolved) = git(&["rev-parse", "--git-path", path]) {
148 if PathBuf::from(&resolved).exists() {
149 println!("cargo:rerun-if-changed={resolved}");
150 }
151 }
152 }
153 }
154
155 /// Whether the enclosing git repository is this crate's own checkout.
156 ///
157 /// Vendoring `git-collab` into another project's tree would otherwise make
158 /// `git rev-parse HEAD` report *that* project's commit, and a `--version` that
159 /// names the wrong commit is worse than one that names none: the whole point
160 /// is to settle arguments about which source a binary came from.
161 fn in_our_own_checkout() -> bool {
162 let Some(toplevel) = git(&["rev-parse", "--show-toplevel"]) else {
163 return false;
164 };
165 let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") else {
166 return false;
167 };
168 match (
169 fs::canonicalize(&toplevel),
170 fs::canonicalize(&manifest_dir),
171 ) {
172 (Ok(a), Ok(b)) => a == b,
173 _ => false,
174 }
175 }
176
177 /// Run git and return trimmed stdout, or `None` for any failure at all.
178 fn git(args: &[&str]) -> Option<String> {
179 let output = Command::new("git").args(args).output().ok()?;
180 if !output.status.success() {
181 return None;
182 }
183 Some(String::from_utf8(output.stdout).ok()?.trim().to_string())
184 }
src/cli.rs
Old New
@@ -4,20 +4,43 @@ use clap_complete::Shell;
4 // --------------------------------------------------------------------------- 4 // ---------------------------------------------------------------------------
5 // Build provenance 5 // Build provenance
6 // 6 //
7 // `build.rs` captures these with `cargo:rustc-env` and is allowed to capture 7 // `build.rs` captures these with `cargo:rustc-env`, from a commit passed into
8 // neither. `option_env!` is what makes that degradation total: a source tree 8 // the build or from the enclosing checkout (see `src/build_provenance.rs`), and
9 // with no `.git`, or a machine with no git, simply yields `None` here and the 9 // is allowed to capture neither. `option_env!` is what makes that degradation
10 // build succeeds. It also has to be `option_env!` rather than `env!` because 10 // total: a source tree with no `.git` and no commit passed to it simply yields
11 // `build.rs` itself `include!`s this file, and the variables do not exist 11 // nothing here and the build succeeds. It also has to be `option_env!` rather
12 // while the build script is being compiled. 12 // than `env!` because `build.rs` itself `include!`s this file, and the
13 // variables do not exist while the build script is being compiled.
13 // --------------------------------------------------------------------------- 14 // ---------------------------------------------------------------------------
14 15
16 /// A variable that carries a value, as opposed to one that merely exists.
17 ///
18 /// `option_env!` reads the compiler's entire environment and not just what the
19 /// build script emitted, so a variable can arrive here set to the empty string
20 /// — which is exactly what a Dockerfile's `ENV FOO=$BAR` produces when `ARG
21 /// BAR` was never given a value. Empty is the absence of provenance, not a
22 /// commit named "" or a tree that is dirty in no particular way, so the
23 /// constants below ask what a variable says rather than whether it is set.
24 pub const fn declared(value: Option<&str>) -> Option<&str> {
25 match value {
26 // A match guard would read better; `const fn` does not take them.
27 Some(value) => {
28 if value.is_empty() {
29 None
30 } else {
31 Some(value)
32 }
33 }
34 None => None,
35 }
36 }
37
15 /// The full git commit this binary was built from, or `None` if the build had 38 /// The full git commit this binary was built from, or `None` if the build had
16 /// no git checkout to read. 39 /// neither a commit passed to it nor a git checkout to read.
17 pub const BUILD_COMMIT: Option<&str> = option_env!("GIT_COLLAB_BUILD_COMMIT"); 40 pub const BUILD_COMMIT: Option<&str> = declared(option_env!("GIT_COLLAB_BUILD_COMMIT"));
18 41
19 /// Whether tracked files were modified in the checkout at build time. 42 /// Whether tracked files were modified in the checkout at build time.
20 pub const BUILD_DIRTY: bool = option_env!("GIT_COLLAB_BUILD_DIRTY").is_some(); 43 pub const BUILD_DIRTY: bool = declared(option_env!("GIT_COLLAB_BUILD_DIRTY")).is_some();
21 44
22 /// Render a version string from its parts. 45 /// Render a version string from its parts.
23 /// 46 ///
src/lib.rs
Old New
@@ -1,5 +1,6 @@
1 pub mod abbrev; 1 pub mod abbrev;
2 pub mod body; 2 pub mod body;
3 pub mod build_provenance;
3 pub mod cache; 4 pub mod cache;
4 pub mod cli; 5 pub mod cli;
5 pub mod commit_link; 6 pub mod commit_link;
tests/version_test.rs
Old New
@@ -7,10 +7,12 @@
7 7
8 mod common; 8 mod common;
9 9
10 use std::fs;
10 use std::process::Command; 11 use std::process::Command;
11 12
12 use common::TestRepo; 13 use common::TestRepo;
13 use git_collab::cli::{format_version, version_string, BUILD_COMMIT}; 14 use git_collab::build_provenance::resolve_build_commit;
15 use git_collab::cli::{declared, format_version, version_string, BUILD_COMMIT};
14 16
15 /// A commit captured at build time must look like a git object name. 17 /// A commit captured at build time must look like a git object name.
16 fn assert_looks_like_commit(commit: &str) { 18 fn assert_looks_like_commit(commit: &str) {
@@ -170,3 +172,260 @@ fn version_with_a_dirty_tree_says_so() {
170 "0.1.0 (29768e2-dirty)" 172 "0.1.0 (29768e2-dirty)"
171 ); 173 );
172 } 174 }
175
176 // ---------------------------------------------------------------------------
177 // Where the commit comes from.
178 //
179 // The container build is the case that motivates this: `.dockerignore` excludes
180 // `.git/`, correctly, so a build inside the image has no repository to
181 // interrogate, and the graceful degradation costs exactly the artifact whose
182 // identity is hardest to establish from the outside — you cannot `git log` a
183 // container. The fix is to pass the commit in, which makes "which commit is
184 // this" a decision with two possible sources, and therefore a rule about which
185 // source wins.
186 // ---------------------------------------------------------------------------
187
188 const FULL: &str = "b2db9cf477d1b655cf3a3bde35cb15f0b034536e";
189 const OTHER: &str = "7a13f3011ee40e28c1abed8f1c58e0a7d1e51a2c";
190
191 /// The whole point of the override: a build with no repository still knows
192 /// which commit it is.
193 #[test]
194 fn a_passed_commit_is_used_when_there_is_no_checkout() {
195 assert_eq!(resolve_build_commit(Some(FULL), None), Ok(Some(FULL)));
196 }
197
198 /// Passing a commit is a deliberate act by whoever started the build; the
199 /// enclosing checkout is a guess made on their behalf. When the two disagree
200 /// the deliberate one wins, which is what makes `--build-arg` usable from a
201 /// wrapper that knows more than the build tree does.
202 #[test]
203 fn a_passed_commit_overrides_the_discovered_one() {
204 assert_eq!(resolve_build_commit(Some(FULL), Some(OTHER)), Ok(Some(FULL)));
205 }
206
207 #[test]
208 fn without_a_passed_commit_the_discovered_one_is_used() {
209 assert_eq!(resolve_build_commit(None, Some(OTHER)), Ok(Some(OTHER)));
210 }
211
212 /// The degraded case, which must stay degraded rather than become an error: a
213 /// release tarball with no `.git` and no build argument still builds.
214 #[test]
215 fn with_neither_source_there_is_no_commit() {
216 assert_eq!(resolve_build_commit(None, None), Ok(None));
217 }
218
219 /// `--build-arg GIT_COMMIT=` and an `ARG` left at an empty default both arrive
220 /// as an empty string, which is the absence of a decision rather than a
221 /// decision to report an empty commit.
222 #[test]
223 fn an_empty_or_blank_passed_commit_is_not_a_decision() {
224 assert_eq!(resolve_build_commit(Some(""), Some(OTHER)), Ok(Some(OTHER)));
225 assert_eq!(
226 resolve_build_commit(Some(" \n"), Some(OTHER)),
227 Ok(Some(OTHER))
228 );
229 assert_eq!(resolve_build_commit(Some(""), None), Ok(None));
230 }
231
232 /// Surrounding whitespace is a shell artefact, not part of the name.
233 #[test]
234 fn a_passed_commit_is_trimmed() {
235 assert_eq!(
236 resolve_build_commit(Some(" b2db9cf "), None),
237 Ok(Some("b2db9cf"))
238 );
239 }
240
241 /// A passed value that is not a commit is a mistake in the invocation, and the
242 /// build must say so rather than quietly producing the untraceable binary this
243 /// whole mechanism exists to prevent. Degrading here would hide the typo in
244 /// precisely the pipeline that cares most about the answer.
245 #[test]
246 fn a_malformed_passed_commit_fails_the_build() {
247 let long = "a".repeat(65);
248 for bad in [
249 "HEAD",
250 "not-a-sha",
251 "b2db",
252 "$(git rev-parse HEAD)",
253 long.as_str(),
254 ] {
255 assert!(
256 resolve_build_commit(Some(bad), None).is_err(),
257 "'{bad}' should be rejected as a passed commit"
258 );
259 }
260 }
261
262 /// The asymmetry: a discovered value nobody asked for degrades to silence,
263 /// because failing the build over an odd local checkout would be a regression
264 /// against the tarball case that already works.
265 #[test]
266 fn a_malformed_discovered_commit_degrades_to_silence() {
267 assert_eq!(
268 resolve_build_commit(None, Some("ref: refs/heads/main")),
269 Ok(None)
270 );
271 assert_eq!(resolve_build_commit(None, Some("")), Ok(None));
272 }
273
274 /// The rejection names the variable and the value, because whoever reads it is
275 /// looking at a `docker build` line and not at this file.
276 #[test]
277 fn the_rejection_names_the_variable_and_the_value() {
278 let err = resolve_build_commit(Some("HEAD"), None).unwrap_err();
279 assert!(err.contains("GIT_COLLAB_BUILD_COMMIT"), "{err}");
280 assert!(err.contains("HEAD"), "{err}");
281 }
282
283 // ---------------------------------------------------------------------------
284 // End to end: what a real `cargo build` emits, and so what `--version` says.
285 //
286 // The binaries under test are built from this checkout and can never exercise
287 // either the passed-commit path or the no-repository path themselves. A
288 // throwaway crate whose build script *is* this crate's provenance code can
289 // exercise both, under a real cargo build, including the `option_env!` that
290 // `src/cli.rs` reads.
291 // ---------------------------------------------------------------------------
292
293 /// What a build ends up reporting: the two values `src/cli.rs` derives from
294 /// `option_env!`, run through the same `declared` filter and formatter the
295 /// binaries use.
296 fn probe_version(env: &[(&str, &str)]) -> String {
297 let (commit, dirty) = probe_raw(env);
298 format_version(
299 "0.1.0",
300 declared(commit.as_deref()),
301 declared(dirty.as_deref()).is_some(),
302 )
303 }
304
305 /// Build a probe crate whose `build.rs` is our `emit_build_provenance`, and
306 /// report the pair `src/cli.rs` would see — distinguishing a variable that is
307 /// unset from one that is set to the empty string, because that distinction is
308 /// exactly where this went wrong.
309 fn probe_raw(env: &[(&str, &str)]) -> (Option<String>, Option<String>) {
310 let dir = tempfile::tempdir().expect("tempdir");
311 let root = dir.path();
312 let provenance = concat!(env!("CARGO_MANIFEST_DIR"), "/src/build_provenance.rs");
313
314 fs::write(
315 root.join("Cargo.toml"),
316 // The empty `[workspace]` detaches the probe from any workspace above
317 // the temporary directory.
318 "[package]\nname = \"provenance-probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n",
319 )
320 .unwrap();
321 fs::write(
322 root.join("build.rs"),
323 format!("include!(\"{provenance}\");\nfn main() {{ emit_build_provenance(); }}\n"),
324 )
325 .unwrap();
326 fs::create_dir(root.join("src")).unwrap();
327 fs::write(
328 root.join("src/main.rs"),
329 r#"fn report(name: &str, value: Option<&str>) {
330 match value {
331 Some(value) => println!("{name}=set:{value}"),
332 None => println!("{name}=unset"),
333 }
334 }
335 fn main() {
336 report("commit", option_env!("GIT_COLLAB_BUILD_COMMIT"));
337 report("dirty", option_env!("GIT_COLLAB_BUILD_DIRTY"));
338 }
339 "#,
340 )
341 .unwrap();
342
343 let mut cmd = Command::new(env!("CARGO"));
344 cmd.args(["run", "--quiet", "--offline"])
345 .current_dir(root)
346 .env("CARGO_TARGET_DIR", root.join("target"))
347 .env_remove("GIT_COLLAB_BUILD_COMMIT")
348 .env_remove("GIT_COLLAB_BUILD_DIRTY");
349 for (key, value) in env {
350 cmd.env(key, value);
351 }
352 let out = cmd.output().expect("failed to run cargo for the probe crate");
353 assert!(
354 out.status.success(),
355 "probe build failed: {}",
356 String::from_utf8_lossy(&out.stderr)
357 );
358
359 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
360 let value = |prefix: &str| {
361 stdout
362 .lines()
363 .find_map(|line| line.trim().strip_prefix(prefix))
364 .and_then(|line| line.strip_prefix("set:"))
365 .map(str::to_string)
366 };
367 (value("commit="), value("dirty="))
368 }
369
370 /// The container case: no `.git` in the build context, the commit passed in,
371 /// and `--version` naming it.
372 #[test]
373 fn a_build_given_a_commit_reports_it_in_the_version() {
374 let (commit, _) = probe_raw(&[("GIT_COLLAB_BUILD_COMMIT", FULL)]);
375 assert_eq!(commit.as_deref(), Some(FULL));
376 assert_eq!(
377 probe_version(&[("GIT_COLLAB_BUILD_COMMIT", FULL)]),
378 format!("0.1.0 ({FULL})")
379 );
380 }
381
382 /// The tarball case: nothing passed, no checkout of our own to read, the build
383 /// still succeeding, and the version degrading to the crate version alone.
384 #[test]
385 fn a_build_with_neither_a_commit_nor_a_repository_degrades() {
386 let (commit, dirty) = probe_raw(&[]);
387 assert_eq!(
388 commit, None,
389 "an absent or foreign checkout must not be reported"
390 );
391 assert_eq!(dirty, None);
392 assert_eq!(probe_version(&[]), "0.1.0");
393 }
394
395 /// The shape a Dockerfile actually produces. `ENV FOO=$BAR` with `ARG BAR`
396 /// unset does not leave `FOO` unset — it sets it to the empty string, and
397 /// `option_env!` reads the compiler's whole environment and not merely what the
398 /// build script emitted. So both variables arrive as `Some("")` however little
399 /// the build was told, which is why "is it set" is the wrong question and "does
400 /// it have a value" is the right one.
401 ///
402 /// Found by running the built image rather than by reasoning about it: the
403 /// first container built from this change reported a commit it had been given
404 /// correctly, and `-dirty` from a spotlessly clean tree.
405 #[test]
406 fn empty_environment_variables_are_not_provenance() {
407 assert_eq!(
408 probe_version(&[
409 ("GIT_COLLAB_BUILD_COMMIT", FULL),
410 ("GIT_COLLAB_BUILD_DIRTY", ""),
411 ]),
412 format!("0.1.0 ({FULL})"),
413 "an empty dirty variable must not make a clean build claim to be dirty"
414 );
415 assert_eq!(
416 probe_version(&[
417 ("GIT_COLLAB_BUILD_COMMIT", ""),
418 ("GIT_COLLAB_BUILD_DIRTY", ""),
419 ]),
420 "0.1.0",
421 "an empty commit variable must degrade, not report an empty commit"
422 );
423 }
424
425 /// The filter itself, at the boundary the constants use.
426 #[test]
427 fn a_variable_set_to_nothing_declares_nothing() {
428 assert_eq!(declared(Some("")), None);
429 assert_eq!(declared(None), None);
430 assert_eq!(declared(Some("29768e2")), Some("29768e2"));
431 }