tests/version_test.rs
Ref: Size: 15.0 KiB History
//! `--version` and build provenance.
//!
//! The point of these tests is not that a version string exists but that it
//! names the *commit the binary was built from*. A binary that reports only
//! `0.1.0` cannot be distinguished from one built three weeks ago, which is
//! exactly the failure this is meant to make impossible.
mod common;
use std::fs;
use std::process::Command;
use common::TestRepo;
use git_collab::build_provenance::resolve_build_commit;
use git_collab::cli::{declared, format_version, version_string, BUILD_COMMIT};
/// A commit captured at build time must look like a git object name.
fn assert_looks_like_commit(commit: &str) {
assert!(
commit.len() >= 7,
"build commit '{}' is too short to identify anything",
commit
);
assert!(
commit.chars().all(|c| c.is_ascii_hexdigit()),
"build commit '{}' is not hex",
commit
);
}
/// Both binaries must answer `--version`, and when the build had a git
/// checkout the answer must carry the commit.
fn assert_version_output(binary: &str, stdout: &str) {
assert!(
stdout.contains(env!("CARGO_PKG_VERSION")),
"{} --version should name the crate version: {}",
binary,
stdout
);
match BUILD_COMMIT {
Some(commit) => {
assert_looks_like_commit(commit);
assert!(
stdout.contains(commit),
"{} --version should name the build commit '{}': {}",
binary,
commit,
stdout
);
}
// Built from a tarball with no `.git`. The contract is that the
// version degrades to the crate version alone rather than failing.
None => assert_eq!(stdout.trim(), format!("{} {}", binary, version_string())),
}
}
#[test]
fn cli_binary_reports_version_with_build_commit() {
let output = Command::new(env!("CARGO_BIN_EXE_git-collab"))
.arg("--version")
.output()
.expect("failed to run git-collab");
assert!(
output.status.success(),
"git-collab --version should exit 0: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_version_output("git-collab", &String::from_utf8_lossy(&output.stdout));
}
#[test]
fn server_binary_reports_version_with_build_commit() {
let output = Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
.arg("--version")
.output()
.expect("failed to run git-collab-server");
assert!(
output.status.success(),
"git-collab-server --version should exit 0: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_version_output(
"git-collab-server",
&String::from_utf8_lossy(&output.stdout),
);
}
/// `--version` is a question about the binary, not about a repository, so it
/// must answer outside a git checkout. `git-collab` otherwise exits 1 with
/// "could not find repository".
#[test]
fn version_works_outside_a_git_repo() {
let output = Command::new(env!("CARGO_BIN_EXE_git-collab"))
.arg("--version")
.current_dir(std::env::temp_dir())
.output()
.expect("failed to run git-collab");
assert!(
output.status.success(),
"--version should not need a repo: {}",
String::from_utf8_lossy(&output.stderr)
);
}
/// `-V` is the conventional short form and clap wires it alongside `--version`.
#[test]
fn short_version_flag_works_on_both_binaries() {
for binary in [
env!("CARGO_BIN_EXE_git-collab"),
env!("CARGO_BIN_EXE_git-collab-server"),
] {
let output = Command::new(binary)
.arg("-V")
.output()
.expect("failed to run binary");
assert!(output.status.success(), "-V should exit 0 for {}", binary);
assert!(
String::from_utf8_lossy(&output.stdout).contains(env!("CARGO_PKG_VERSION")),
"-V should name the crate version for {}",
binary
);
}
}
/// `status` is where someone looks when behaviour is inexplicable, so the
/// build identity belongs there too.
#[test]
fn status_names_the_build_version() {
let repo = TestRepo::new("Alice", "alice@example.com");
let out = repo.run_ok(&["status"]);
assert!(
out.contains(env!("CARGO_PKG_VERSION")),
"status should name the build version: {}",
out
);
if let Some(commit) = BUILD_COMMIT {
assert!(
out.contains(commit),
"status should name the build commit '{}': {}",
commit,
out
);
}
}
// ---------------------------------------------------------------------------
// The formatting contract, including the degraded no-git case, which cannot be
// exercised through the built binary because this checkout always has a `.git`.
// ---------------------------------------------------------------------------
#[test]
fn version_without_a_commit_is_the_bare_crate_version() {
assert_eq!(format_version("0.1.0", None, false), "0.1.0");
}
/// A dirty marker with no commit would claim more than is known: without a
/// commit there is nothing for "dirty" to be relative to.
#[test]
fn version_without_a_commit_ignores_the_dirty_marker() {
assert_eq!(format_version("0.1.0", None, true), "0.1.0");
}
#[test]
fn version_with_a_clean_commit_names_it() {
assert_eq!(
format_version("0.1.0", Some("29768e2"), false),
"0.1.0 (29768e2)"
);
}
#[test]
fn version_with_a_dirty_tree_says_so() {
assert_eq!(
format_version("0.1.0", Some("29768e2"), true),
"0.1.0 (29768e2-dirty)"
);
}
// ---------------------------------------------------------------------------
// Where the commit comes from.
//
// The container build is the case that motivates this: `.dockerignore` excludes
// `.git/`, correctly, so a build inside the image has no repository to
// interrogate, and the graceful degradation costs exactly the artifact whose
// identity is hardest to establish from the outside — you cannot `git log` a
// container. The fix is to pass the commit in, which makes "which commit is
// this" a decision with two possible sources, and therefore a rule about which
// source wins.
// ---------------------------------------------------------------------------
const FULL: &str = "b2db9cf477d1b655cf3a3bde35cb15f0b034536e";
const OTHER: &str = "7a13f3011ee40e28c1abed8f1c58e0a7d1e51a2c";
/// The whole point of the override: a build with no repository still knows
/// which commit it is.
#[test]
fn a_passed_commit_is_used_when_there_is_no_checkout() {
assert_eq!(resolve_build_commit(Some(FULL), None), Ok(Some(FULL)));
}
/// Passing a commit is a deliberate act by whoever started the build; the
/// enclosing checkout is a guess made on their behalf. When the two disagree
/// the deliberate one wins, which is what makes `--build-arg` usable from a
/// wrapper that knows more than the build tree does.
#[test]
fn a_passed_commit_overrides_the_discovered_one() {
assert_eq!(
resolve_build_commit(Some(FULL), Some(OTHER)),
Ok(Some(FULL))
);
}
#[test]
fn without_a_passed_commit_the_discovered_one_is_used() {
assert_eq!(resolve_build_commit(None, Some(OTHER)), Ok(Some(OTHER)));
}
/// The degraded case, which must stay degraded rather than become an error: a
/// release tarball with no `.git` and no build argument still builds.
#[test]
fn with_neither_source_there_is_no_commit() {
assert_eq!(resolve_build_commit(None, None), Ok(None));
}
/// `--build-arg GIT_COMMIT=` and an `ARG` left at an empty default both arrive
/// as an empty string, which is the absence of a decision rather than a
/// decision to report an empty commit.
#[test]
fn an_empty_or_blank_passed_commit_is_not_a_decision() {
assert_eq!(resolve_build_commit(Some(""), Some(OTHER)), Ok(Some(OTHER)));
assert_eq!(
resolve_build_commit(Some(" \n"), Some(OTHER)),
Ok(Some(OTHER))
);
assert_eq!(resolve_build_commit(Some(""), None), Ok(None));
}
/// Surrounding whitespace is a shell artefact, not part of the name.
#[test]
fn a_passed_commit_is_trimmed() {
assert_eq!(
resolve_build_commit(Some(" b2db9cf "), None),
Ok(Some("b2db9cf"))
);
}
/// A passed value that is not a commit is a mistake in the invocation, and the
/// build must say so rather than quietly producing the untraceable binary this
/// whole mechanism exists to prevent. Degrading here would hide the typo in
/// precisely the pipeline that cares most about the answer.
#[test]
fn a_malformed_passed_commit_fails_the_build() {
let long = "a".repeat(65);
for bad in [
"HEAD",
"not-a-sha",
"b2db",
"$(git rev-parse HEAD)",
long.as_str(),
] {
assert!(
resolve_build_commit(Some(bad), None).is_err(),
"'{bad}' should be rejected as a passed commit"
);
}
}
/// The asymmetry: a discovered value nobody asked for degrades to silence,
/// because failing the build over an odd local checkout would be a regression
/// against the tarball case that already works.
#[test]
fn a_malformed_discovered_commit_degrades_to_silence() {
assert_eq!(
resolve_build_commit(None, Some("ref: refs/heads/main")),
Ok(None)
);
assert_eq!(resolve_build_commit(None, Some("")), Ok(None));
}
/// The rejection names the variable and the value, because whoever reads it is
/// looking at a `docker build` line and not at this file.
#[test]
fn the_rejection_names_the_variable_and_the_value() {
let err = resolve_build_commit(Some("HEAD"), None).unwrap_err();
assert!(err.contains("GIT_COLLAB_BUILD_COMMIT"), "{err}");
assert!(err.contains("HEAD"), "{err}");
}
// ---------------------------------------------------------------------------
// End to end: what a real `cargo build` emits, and so what `--version` says.
//
// The binaries under test are built from this checkout and can never exercise
// either the passed-commit path or the no-repository path themselves. A
// throwaway crate whose build script *is* this crate's provenance code can
// exercise both, under a real cargo build, including the `option_env!` that
// `src/cli.rs` reads.
// ---------------------------------------------------------------------------
/// What a build ends up reporting: the two values `src/cli.rs` derives from
/// `option_env!`, run through the same `declared` filter and formatter the
/// binaries use.
fn probe_version(env: &[(&str, &str)]) -> String {
let (commit, dirty) = probe_raw(env);
format_version(
"0.1.0",
declared(commit.as_deref()),
declared(dirty.as_deref()).is_some(),
)
}
/// Build a probe crate whose `build.rs` is our `emit_build_provenance`, and
/// report the pair `src/cli.rs` would see — distinguishing a variable that is
/// unset from one that is set to the empty string, because that distinction is
/// exactly where this went wrong.
fn probe_raw(env: &[(&str, &str)]) -> (Option<String>, Option<String>) {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let provenance = concat!(env!("CARGO_MANIFEST_DIR"), "/src/build_provenance.rs");
fs::write(
root.join("Cargo.toml"),
// The empty `[workspace]` detaches the probe from any workspace above
// the temporary directory.
"[package]\nname = \"provenance-probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n",
)
.unwrap();
fs::write(
root.join("build.rs"),
format!("include!(\"{provenance}\");\nfn main() {{ emit_build_provenance(); }}\n"),
)
.unwrap();
fs::create_dir(root.join("src")).unwrap();
fs::write(
root.join("src/main.rs"),
r#"fn report(name: &str, value: Option<&str>) {
match value {
Some(value) => println!("{name}=set:{value}"),
None => println!("{name}=unset"),
}
}
fn main() {
report("commit", option_env!("GIT_COLLAB_BUILD_COMMIT"));
report("dirty", option_env!("GIT_COLLAB_BUILD_DIRTY"));
}
"#,
)
.unwrap();
let mut cmd = Command::new(env!("CARGO"));
cmd.args(["run", "--quiet", "--offline"])
.current_dir(root)
.env("CARGO_TARGET_DIR", root.join("target"))
.env_remove("GIT_COLLAB_BUILD_COMMIT")
.env_remove("GIT_COLLAB_BUILD_DIRTY");
for (key, value) in env {
cmd.env(key, value);
}
let out = cmd
.output()
.expect("failed to run cargo for the probe crate");
assert!(
out.status.success(),
"probe build failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let value = |prefix: &str| {
stdout
.lines()
.find_map(|line| line.trim().strip_prefix(prefix))
.and_then(|line| line.strip_prefix("set:"))
.map(str::to_string)
};
(value("commit="), value("dirty="))
}
/// The container case: no `.git` in the build context, the commit passed in,
/// and `--version` naming it.
#[test]
fn a_build_given_a_commit_reports_it_in_the_version() {
let (commit, _) = probe_raw(&[("GIT_COLLAB_BUILD_COMMIT", FULL)]);
assert_eq!(commit.as_deref(), Some(FULL));
assert_eq!(
probe_version(&[("GIT_COLLAB_BUILD_COMMIT", FULL)]),
format!("0.1.0 ({FULL})")
);
}
/// The tarball case: nothing passed, no checkout of our own to read, the build
/// still succeeding, and the version degrading to the crate version alone.
#[test]
fn a_build_with_neither_a_commit_nor_a_repository_degrades() {
let (commit, dirty) = probe_raw(&[]);
assert_eq!(
commit, None,
"an absent or foreign checkout must not be reported"
);
assert_eq!(dirty, None);
assert_eq!(probe_version(&[]), "0.1.0");
}
/// The shape a Dockerfile actually produces. `ENV FOO=$BAR` with `ARG BAR`
/// unset does not leave `FOO` unset — it sets it to the empty string, and
/// `option_env!` reads the compiler's whole environment and not merely what the
/// build script emitted. So both variables arrive as `Some("")` however little
/// the build was told, which is why "is it set" is the wrong question and "does
/// it have a value" is the right one.
///
/// Found by running the built image rather than by reasoning about it: the
/// first container built from this change reported a commit it had been given
/// correctly, and `-dirty` from a spotlessly clean tree.
#[test]
fn empty_environment_variables_are_not_provenance() {
assert_eq!(
probe_version(&[
("GIT_COLLAB_BUILD_COMMIT", FULL),
("GIT_COLLAB_BUILD_DIRTY", ""),
]),
format!("0.1.0 ({FULL})"),
"an empty dirty variable must not make a clean build claim to be dirty"
);
assert_eq!(
probe_version(&[
("GIT_COLLAB_BUILD_COMMIT", ""),
("GIT_COLLAB_BUILD_DIRTY", ""),
]),
"0.1.0",
"an empty commit variable must degrade, not report an empty commit"
);
}
/// The filter itself, at the boundary the constants use.
#[test]
fn a_variable_set_to_nothing_declares_nothing() {
assert_eq!(declared(Some("")), None);
assert_eq!(declared(None), None);
assert_eq!(declared(Some("29768e2")), Some("29768e2"));
}