Skip to main content

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}