codewhale_build_support/lib.rs
1//! Shared build-script helpers for the `codewhale-cli`, `codewhale-tui`, and
2//! `codewhale-telemetry` build scripts: rerun-condition declarations, the
3//! embedded `DEEPSEEK_BUILD_VERSION` metadata, and the release-only build sha.
4//! Only call these functions from a build script — they emit `cargo:`
5//! directives on stdout.
6//!
7//! Two different shas live here and they are not interchangeable.
8//! `DEEPSEEK_BUILD_VERSION`/`CODEWHALE_BUILD_COMMIT` describe *the build the
9//! environment asked for* (`DEEPSEEK_BUILD_SHA`/`GITHUB_SHA`); an unstamped
10//! local build renders a `(dev)` marker instead.
11//! `CODEWHALE_RELEASE_BUILD_SHA` describes a *published* binary and has no
12//! fallback at all, because it leaves the machine.
13//!
14//! ## Why the stamp never reads the local checkout (#5245)
15//!
16//! These helpers used to watch `.git/HEAD`/refs and fall back to
17//! `git rev-parse HEAD`, so every local commit invalidated the two largest
18//! compile units in the workspace (a ~14-minute release rebuild with zero
19//! code changes). And the alternative — resolving the sha at *runtime* —
20//! would lie: the binary runs inside users' repositories, and a stale binary
21//! would report whatever the checkout's HEAD is *now*, which breaks the
22//! dogfood-receipt identity `scripts/release/install-dogfood.sh` verifies.
23//! So the contract is: a sha appears in the version string only when the
24//! build environment supplied one (`DEEPSEEK_BUILD_SHA` wins over
25//! `GITHUB_SHA`), the build script reruns only when those variables change,
26//! and a build nobody stamped says `(dev)`. CI and release builds are
27//! byte-identical to the old behavior; dogfood builds pass the sha
28//! explicitly (the install script prints the exact command).
29
30use std::path::Path;
31
32/// Declare the rerun conditions for the build-metadata directives: the two
33/// SHA-override environment variables, and deliberately nothing about the
34/// local checkout — watching `.git` files is what made every local commit
35/// rebuild the whole crate (#5245).
36///
37/// `manifest_dir` is accepted (and ignored) so build scripts keep one call
38/// shape; it documents that the decision is per-crate, not global state.
39pub fn declare_rerun_conditions(_manifest_dir: &Path) {
40 println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
41 println!("cargo:rerun-if-env-changed=GITHUB_SHA");
42}
43
44/// Emit `cargo:rustc-env=DEEPSEEK_BUILD_VERSION=...` — the package version,
45/// suffixed with the short build SHA when the environment supplied one
46/// (`DEEPSEEK_BUILD_SHA`, then `GITHUB_SHA`), or with the literal `dev`
47/// marker when it did not. `CODEWHALE_BUILD_COMMIT` is emitted only in the
48/// stamped case.
49///
50/// `package_version` is the calling build script's `CARGO_PKG_VERSION`;
51/// `manifest_dir` is accepted for call-shape stability.
52pub fn emit_build_version(_manifest_dir: &Path, package_version: &str) {
53 let commit = build_commit();
54 let build_version = commit
55 .as_ref()
56 .and_then(|sha| short_sha(sha.clone()))
57 .map(|sha| format!("{package_version} ({sha})"))
58 .unwrap_or_else(|| format!("{package_version} (dev)"));
59
60 println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
61 if let Some(commit) = commit {
62 println!("cargo:rustc-env=CODEWHALE_BUILD_COMMIT={commit}");
63 }
64}
65
66/// Declare the rerun conditions for [`emit_release_build_sha`] alone: the two
67/// release-CI SHA variables, and nothing about the local checkout.
68///
69/// Deliberately not [`declare_rerun_conditions`]: watching `.git/HEAD` would
70/// make the build script rerun on every local commit, for a value that is
71/// `None` on every local build by design.
72pub fn declare_release_sha_rerun() {
73 println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
74 println!("cargo:rerun-if-env-changed=GITHUB_SHA");
75}
76
77/// Emit `cargo:rustc-env=CODEWHALE_RELEASE_BUILD_SHA=...` — the first 12 hex
78/// characters of the build sha — **only** when the build environment supplied
79/// one.
80///
81/// This is provenance for a *published* binary, and it is the only sha a
82/// telemetry payload may carry. There is deliberately no fallback to the local
83/// checkout:
84///
85/// - `CODEWHALE_BUILD_COMMIT` historically fell back to the builder's own
86/// private `HEAD` on every local build; since #5245 it is env-only too,
87/// but this value keeps its own name and rule because it is the only sha
88/// a telemetry payload may carry.
89/// - The "was this a published release" gate proposed earlier cannot be built:
90/// `codewhale_release::latest_release_tag_{async,blocking}` are **network
91/// calls** to `api.github.com` that return *tag names*, not shas, so the only
92/// available comparison is version-vs-version — and a private tree at the
93/// same version compares equal.
94///
95/// Build-time provenance is deterministic, network-free, and verifiable from
96/// the repository. Absent the release environment the value is simply absent,
97/// and `option_env!` in the consuming crate yields `None`.
98pub fn emit_release_build_sha() {
99 if let Some(sha) = release_build_sha(|name| std::env::var(name).ok()) {
100 println!("cargo:rustc-env=CODEWHALE_RELEASE_BUILD_SHA={sha}");
101 }
102}
103
104/// The decision behind [`emit_release_build_sha`], with the environment
105/// injected so it can be tested without mutating the process.
106///
107/// `DEEPSEEK_BUILD_SHA` wins over `GITHUB_SHA`; both must be a full 40-hex sha
108/// to be believed, and the result is the first 12 characters.
109#[must_use]
110pub fn release_build_sha(read_env: impl Fn(&str) -> Option<String>) -> Option<String> {
111 read_env("DEEPSEEK_BUILD_SHA")
112 .and_then(full_sha)
113 .or_else(|| read_env("GITHUB_SHA").and_then(full_sha))
114 .and_then(short_sha)
115}
116
117fn build_commit() -> Option<String> {
118 build_commit_with(|name| std::env::var(name).ok())
119}
120
121/// The stamping decision with the environment injected, so the no-local-
122/// fallback contract is testable without mutating the process (#5245).
123fn build_commit_with(read_env: impl Fn(&str) -> Option<String>) -> Option<String> {
124 read_env("DEEPSEEK_BUILD_SHA")
125 .and_then(full_sha)
126 .or_else(|| read_env("GITHUB_SHA").and_then(full_sha))
127}
128
129fn full_sha(value: String) -> Option<String> {
130 let trimmed = value.trim().to_ascii_lowercase();
131 if trimmed.len() != 40 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
132 return None;
133 }
134 Some(trimmed)
135}
136
137fn short_sha(value: String) -> Option<String> {
138 let trimmed = value.trim();
139 if trimmed.is_empty() {
140 return None;
141 }
142 Some(trimmed.chars().take(12).collect())
143}
144
145#[cfg(test)]
146mod tests {
147 use super::{full_sha, release_build_sha, short_sha};
148
149 #[test]
150 fn full_commit_requires_exact_forty_hex_characters() {
151 assert_eq!(
152 full_sha("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
153 Some("abcdef0123456789abcdef0123456789abcdef01".to_string())
154 );
155 assert_eq!(full_sha("abc123".to_string()), None);
156 assert_eq!(
157 full_sha("gggggggggggggggggggggggggggggggggggggggg".to_string()),
158 None
159 );
160 assert_eq!(
161 short_sha("abcdef0123456789abcdef0123456789abcdef01".to_string()),
162 Some("abcdef012345".to_string())
163 );
164 }
165
166 #[test]
167 fn the_release_build_sha_is_absent_for_every_local_build() {
168 // No release environment: nothing is emitted, so `option_env!` in the
169 // consuming crate is `None` and a telemetry payload carries `git_sha:
170 // null`. This is the property that keeps a maintainer's private HEAD
171 // out of a shipped binary.
172 assert_eq!(release_build_sha(|_| None), None);
173 }
174
175 #[test]
176 fn the_release_build_sha_comes_only_from_a_release_environment() {
177 let ci = "abcdef0123456789abcdef0123456789abcdef01";
178 assert_eq!(
179 release_build_sha(|name| (name == "GITHUB_SHA").then(|| ci.to_string())),
180 Some("abcdef012345".to_string())
181 );
182 // The Codewhale variable wins over the GitHub one.
183 assert_eq!(
184 release_build_sha(|name| match name {
185 "DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
186 "GITHUB_SHA" => Some(ci.to_string()),
187 _ => None,
188 }),
189 Some("f".repeat(12))
190 );
191 // A value that is not a full sha is not believed, and does not fall
192 // through to the local checkout.
193 assert_eq!(
194 release_build_sha(|name| (name == "DEEPSEEK_BUILD_SHA").then(|| "abc123".to_string())),
195 None
196 );
197 // `CODEWHALE_BUILD_COMMIT` is a different value with a different rule
198 // and is never a source here.
199 assert_eq!(
200 release_build_sha(|name| (name == "CODEWHALE_BUILD_COMMIT").then(|| ci.to_string())),
201 None
202 );
203 }
204
205 /// #5245 contract: the version stamp reads ONLY the two environment
206 /// variables. There is no fallback to the local checkout, so a plain
207 /// local build renders `(dev)` and — the actual point — the build script
208 /// declares no `.git` rerun paths, meaning `git commit` no longer
209 /// invalidates the two largest compile units in the workspace.
210 #[test]
211 fn the_build_commit_never_reads_the_local_checkout() {
212 // This test runs inside the real repository; if a git fallback still
213 // existed it would resolve a sha here. Absent env vars must mean
214 // absent commit, in the repo or out of it.
215 assert_eq!(super::build_commit_with(|_| None), None);
216 let ci = "abcdef0123456789abcdef0123456789abcdef01";
217 assert_eq!(
218 super::build_commit_with(|name| (name == "GITHUB_SHA").then(|| ci.to_string())),
219 Some(ci.to_string())
220 );
221 assert_eq!(
222 super::build_commit_with(|name| match name {
223 "DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
224 "GITHUB_SHA" => Some(ci.to_string()),
225 _ => None,
226 }),
227 Some("f".repeat(40))
228 );
229 }
230}