969a7983
Install without root, and keep the commit in the container
a73x 2026-08-13 15:01
Commit message
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 | } | ||