mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
aa1a5cd98b
## Problem
`cargo install worktrunk` fails to build from crates.io:
```
error: environment variable `VERGEN_GIT_DESCRIBE` not defined at compile time
--> src/cli/mod.rs:168:27
|
168 | let git_version = env!("VERGEN_GIT_DESCRIBE");
```
`version_str()` reads the git-describe version with the `env!` macro,
which **requires** the variable to exist at compile time. The
`vergen_gitcl` build script can only set `VERGEN_GIT_DESCRIBE` when
building inside a git worktree. The crates.io package archive has no
`.git`, so the build script emits `Unable to set VERGEN_GIT_DESCRIBE`
and never defines the variable — and `env!` then fails to compile.
## Solution
Switch `env!` → `option_env!`, which yields `None` when the variable is
unset instead of failing to compile, and fall back to the cargo package
version. That fallback is exactly what the existing `IDEMPOTENT` branch
already does, so the behavior is unchanged for normal builds. The choice
is extracted into a pure `resolve_version(Option<&str>, &str)` helper
with unit tests.
## Testing
Reproduced the failure the same way crates.io builds the package —
`cargo package` produces the source archive, which I extracted to a
non-git directory and built:
- **Before:** build fails with `environment variable VERGEN_GIT_DESCRIBE
not defined at compile time`.
- **After:** build succeeds (the harmless `Unable to set
VERGEN_GIT_DESCRIBE` warning remains), and `wt --version` reports
`0.59.0` (the cargo version fallback).
New unit tests cover the three `resolve_version` cases: git-describe
available, vergen `IDEMPOTENT` placeholder, and absent (`None` — the
crates.io case).
---
Closes #3123 — automated triage
---------
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
170 lines
7.5 KiB
Rust
170 lines
7.5 KiB
Rust
//! Guard test: build-script env vars must be read with `option_env!`, never `env!`
|
|
//!
|
|
//! `cargo install worktrunk` builds the crates.io source archive, which has no
|
|
//! `.git`. The `vergen_gitcl` build script can only set `VERGEN_GIT_DESCRIBE`
|
|
//! when `git describe` succeeds, so on that archive the variable is undefined
|
|
//! at compile time.
|
|
//!
|
|
//! `env!("VERGEN_GIT_DESCRIBE")` then **fails to compile** ("environment
|
|
//! variable not defined at compile time"), which broke `cargo install`
|
|
//! (#3123). `option_env!` yields `None` instead, letting `version_str()` fall
|
|
//! back to the cargo package version.
|
|
//!
|
|
//! This couldn't be caught by a build test that compiles inside the repo:
|
|
//! `git describe` searches ancestors, so a `cargo package` / `cargo publish`
|
|
//! verify build run from the worktree finds the *outer* `.git` and succeeds —
|
|
//! exactly why the broken release shipped. The failure only reproduces where
|
|
//! no ancestor is a git repo (a user's `~/.cargo/registry`). Rather than
|
|
//! recreate that (slow, requires building outside the tree), this guards the
|
|
//! mechanism directly: forbid `env!` on any build-script-provided `VERGEN_*`
|
|
//! variable, since none are guaranteed to exist on the crates.io archive.
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
use path_slash::PathExt as _;
|
|
|
|
#[test]
|
|
fn vergen_env_vars_are_read_optionally() {
|
|
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
|
|
|
let mut violations = Vec::new();
|
|
scan_directory(&src_dir, &src_dir, &mut violations);
|
|
|
|
assert!(
|
|
violations.is_empty(),
|
|
"Found `env!` on a build-script `VERGEN_*` variable:\n\n{}\n\n\
|
|
These variables are absent when building the crates.io source archive \
|
|
(no `.git`), so `env!` fails to compile and breaks `cargo install` \
|
|
(#3123). Use `option_env!` and fall back to a default instead.",
|
|
violations.join("\n")
|
|
);
|
|
}
|
|
|
|
fn scan_directory(dir: &Path, src_dir: &Path, violations: &mut Vec<String>) {
|
|
let Ok(entries) = fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
scan_directory(&path, src_dir, violations);
|
|
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
|
check_file(&path, src_dir, violations);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_file(path: &Path, src_dir: &Path, violations: &mut Vec<String>) {
|
|
let Ok(contents) = fs::read_to_string(path) else {
|
|
return;
|
|
};
|
|
let relative = path.strip_prefix(src_dir).unwrap_or(path).to_slash_lossy();
|
|
|
|
for (i, line) in contents.lines().enumerate() {
|
|
if has_bare_env_vergen(line) {
|
|
violations.push(format!("{relative}:{}: {}", i + 1, line.trim()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// True if the line reads a `VERGEN_*` variable with `env!` but not the safe
|
|
/// `option_env!` form. Matching `env!("VERGEN_` alone would also flag every
|
|
/// `option_env!("VERGEN_` since it ends in that substring, so we require the
|
|
/// `env!` to not be the tail of `option_env!`.
|
|
fn has_bare_env_vergen(line: &str) -> bool {
|
|
line.match_indices("env!(\"VERGEN_")
|
|
.any(|(idx, _)| !line[..idx].ends_with("option_"))
|
|
}
|
|
|
|
/// End-to-end reproduction of #3123: build the *actual* crates.io source
|
|
/// archive in a directory with no ancestor `.git`, and confirm the `wt` binary
|
|
/// both compiles and reports the cargo package version (the fallback).
|
|
///
|
|
/// The sibling `vergen_env_vars_are_read_optionally` guard is fast and runs on
|
|
/// every PR, but it only checks the *mechanism* (`env!` vs `option_env!`) by
|
|
/// scanning source. This test is the faithful integration version the
|
|
/// maintainer asked for: it packages the crate exactly as `cargo publish`
|
|
/// would, extracts it where `git describe` can find no repository, and builds
|
|
/// it — the precise condition of a user's `~/.cargo/registry`. Before the fix
|
|
/// this failed to *compile* (`VERGEN_GIT_DESCRIBE` not defined); after it, the
|
|
/// build succeeds and `wt --version` falls back to `CARGO_PKG_VERSION`.
|
|
///
|
|
/// `#[ignore]` because it runs a full from-scratch crate build (several
|
|
/// minutes, downloads every dependency). CI runs it in the `crate-build`
|
|
/// nightly job via `cargo test … -- --ignored`. Linux/macOS only — it shells
|
|
/// out to `tar` and relies on `GIT_CEILING_DIRECTORIES` path semantics.
|
|
#[test]
|
|
#[ignore = "slow: full from-scratch crate build; run in the nightly workflow via --ignored"]
|
|
fn crate_io_archive_builds_and_versions_without_git() {
|
|
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
// `CARGO` points at the cargo that launched the test; fall back for manual
|
|
// `--ignored` runs that don't set it.
|
|
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
|
|
let pkg_version = env!("CARGO_PKG_VERSION");
|
|
|
|
// 1. Produce the crates.io source archive exactly as `cargo publish` does.
|
|
// `--no-verify`: the default verify build compiles *in-tree*, where
|
|
// `git describe` ascends to the outer repo's `.git` and masks the bug —
|
|
// the very reason the broken release shipped. We run our own no-git
|
|
// build below instead. `--allow-dirty`: tolerate local edits while
|
|
// iterating on the branch.
|
|
let status = Command::new(&cargo)
|
|
.args(["package", "-p", "worktrunk", "--no-verify", "--allow-dirty"])
|
|
.current_dir(manifest_dir)
|
|
.status()
|
|
.expect("spawn cargo package");
|
|
assert!(status.success(), "cargo package failed");
|
|
|
|
let crate_file = manifest_dir
|
|
.join("target/package")
|
|
.join(format!("worktrunk-{pkg_version}.crate"));
|
|
assert!(crate_file.exists(), "expected archive at {crate_file:?}");
|
|
|
|
// 2. Extract into a temp dir that has no ancestor `.git`.
|
|
let temp = tempfile::tempdir().expect("create tempdir");
|
|
let untar = Command::new("tar")
|
|
.arg("xzf")
|
|
.arg(&crate_file)
|
|
.arg("-C")
|
|
.arg(temp.path())
|
|
.status()
|
|
.expect("spawn tar");
|
|
assert!(untar.success(), "tar extraction failed");
|
|
let extracted = temp.path().join(format!("worktrunk-{pkg_version}"));
|
|
assert!(extracted.is_dir(), "extracted dir missing: {extracted:?}");
|
|
|
|
// 3. Build `wt` (default features → `cli` is on) with no `.git` reachable.
|
|
// `GIT_CEILING_DIRECTORIES` stops `git describe` from ascending past the
|
|
// temp dir, so the build sees the same no-repository state as
|
|
// `~/.cargo/registry` even if the temp root happened to sit under one.
|
|
// `VERGEN_GIT_DESCRIBE` is removed so a packager-style override (the
|
|
// workaround Homebrew/conda-forge/nix all ship) can't mask the failure.
|
|
let build = Command::new(&cargo)
|
|
.args(["build", "--bin", "wt"])
|
|
.current_dir(&extracted)
|
|
.env("GIT_CEILING_DIRECTORIES", temp.path())
|
|
.env_remove("VERGEN_GIT_DESCRIBE")
|
|
.status()
|
|
.expect("spawn cargo build");
|
|
assert!(
|
|
build.success(),
|
|
"building the crates.io archive without `.git` failed (this is #3123)"
|
|
);
|
|
|
|
// 4. The built binary must report the cargo package version — proof the
|
|
// `option_env!` fallback fired rather than baking a git-describe string.
|
|
let wt = extracted.join("target/debug/wt");
|
|
let version = Command::new(&wt)
|
|
.arg("--version")
|
|
.output()
|
|
.expect("spawn wt --version");
|
|
assert!(version.status.success(), "`wt --version` exited non-zero");
|
|
let stdout = String::from_utf8_lossy(&version.stdout);
|
|
assert!(
|
|
stdout.contains(pkg_version),
|
|
"expected `wt --version` to fall back to {pkg_version}, got: {stdout:?}"
|
|
);
|
|
}
|