返回 CodeWhale
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 `CODEWHALE_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 //! `CODEWHALE_BUILD_VERSION`/`CODEWHALE_BUILD_COMMIT` describe *the build the
9 //! environment asked for* (`CODEWHALE_BUILD_SHA`/`DEEPSEEK_BUILD_SHA`/`GITHUB_SHA`); an unstamped
10 //! local checkout renders `(dev)`; an unstamped Cargo source package displays
11 //! its package version without claiming a release-binary SHA.
12 //! `CODEWHALE_RELEASE_BUILD_SHA` describes a *published* binary: it reads only
13 //! the explicit release variables (never the ambient `GITHUB_SHA`) and has no
14 //! fallback at all, because it leaves the machine.
15 //!
16 //! ## Why the stamp never reads the local checkout (#5245)
17 //!
18 //! These helpers used to watch `.git/HEAD`/refs and fall back to
19 //! `git rev-parse HEAD`, so every local commit invalidated the two largest
20 //! compile units in the workspace (a ~14-minute release rebuild with zero
21 //! code changes). And the alternative — resolving the sha at *runtime* —
22 //! would lie: the binary runs inside users' repositories, and a stale binary
23 //! would report whatever the checkout's HEAD is *now*, which breaks the
24 //! dogfood-receipt identity `scripts/release/install-dogfood.sh` verifies.
25 //! So the contract is: a sha appears in the version string only when the
26 //! build environment supplied one (`CODEWHALE_BUILD_SHA` wins over
27 //! `GITHUB_SHA`), the build script reruns only when those variables change,
28 //! and an unstamped checkout says `(dev)`. Cargo source packages use the plain
29 //! package version. CI and release builds are
30 //! byte-identical to the old behavior; dogfood builds pass the sha
31 //! explicitly (the install script prints the exact command).
32
33 use std::path::Path;
34
35 /// Main-thread stack reserve shared by the Windows CLI and TUI entrypoints.
36 /// `RUST_MIN_STACK` only sizes spawned threads; the CLI's default 1 MiB main
37 /// stack overflowed in `model resolve`. Reuse the TUI's existing 8 MiB reserve.
38 pub const WINDOWS_MAIN_STACK_BYTES: u64 = 8 * 1024 * 1024;
39
40 /// The linker directive that reserves [`WINDOWS_MAIN_STACK_BYTES`] for
41 /// `bin_name`, or `None` when the target is not Windows.
42 ///
43 /// The environment is injected so the decision is testable on any host without
44 /// mutating the process, matching [`release_build_sha`].
45 ///
46 /// `cargo:rustc-link-arg-bin` only reaches binaries in the *calling* package,
47 /// so each package that ships an entrypoint must emit its own — which is why
48 /// this lives here instead of being stated once.
49 #[must_use]
50 pub fn windows_main_stack_link_arg(
51 bin_name: &str,
52 read_env: impl Fn(&str) -> Option<String>,
53 ) -> Option<String> {
54 if read_env("CARGO_CFG_TARGET_OS").as_deref() != Some("windows") {
55 return None;
56 }
57 let bytes = WINDOWS_MAIN_STACK_BYTES;
58 match read_env("CARGO_CFG_TARGET_ENV").as_deref() {
59 Some("msvc") => Some(format!(
60 "cargo:rustc-link-arg-bin={bin_name}=/STACK:{bytes}"
61 )),
62 Some("gnu") => Some(format!(
63 "cargo:rustc-link-arg-bin={bin_name}=-Wl,--stack,{bytes}"
64 )),
65 _ => None,
66 }
67 }
68
69 /// Emit the reserve for one binary in the calling package.
70 pub fn configure_windows_main_stack(bin_name: &str) {
71 if let Some(directive) = windows_main_stack_link_arg(bin_name, |name| std::env::var(name).ok())
72 {
73 println!("{directive}");
74 }
75 }
76
77 /// Declare the rerun conditions for the build-metadata directives: the two
78 /// SHA-override environment variables, and deliberately nothing about the
79 /// local checkout — watching `.git` files is what made every local commit
80 /// rebuild the whole crate (#5245).
81 ///
82 /// `manifest_dir` is accepted (and ignored) so build scripts keep one call
83 /// shape; it documents that the decision is per-crate, not global state.
84 pub fn declare_rerun_conditions(_manifest_dir: &Path) {
85 println!("cargo:rerun-if-env-changed=CODEWHALE_BUILD_SHA");
86 println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
87 println!("cargo:rerun-if-env-changed=GITHUB_SHA");
88 }
89
90 /// Emit `cargo:rustc-env=CODEWHALE_BUILD_VERSION=...` — the package version,
91 /// suffixed with the short build SHA when the environment supplied one
92 /// (`CODEWHALE_BUILD_SHA`, then `DEEPSEEK_BUILD_SHA`, then `GITHUB_SHA`).
93 /// Unstamped Cargo source packages show the plain package version; unpackaged
94 /// checkouts retain `(dev)`. `CODEWHALE_BUILD_COMMIT` is emitted only when stamped.
95 ///
96 /// `package_version` is the calling build script's `CARGO_PKG_VERSION`;
97 /// Cargo writes `Cargo.toml.orig` when normalizing a distributable package.
98 /// Its presence classifies the source layout, not release provenance: no VCS
99 /// metadata is read and no additional commit value is emitted.
100 pub fn emit_build_version(manifest_dir: &Path, package_version: &str) {
101 let commit = build_commit();
102 let build_version = format_build_version(
103 package_version,
104 commit.as_deref(),
105 manifest_dir.join("Cargo.toml.orig").is_file(),
106 );
107
108 println!("cargo:rustc-env=CODEWHALE_BUILD_VERSION={build_version}");
109 // Keep the pre-rebrand compile-time name through the 0.9.x compatibility
110 // window for downstream crates that still use `env!` with it.
111 println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
112 if let Some(commit) = commit {
113 println!("cargo:rustc-env=CODEWHALE_BUILD_COMMIT={commit}");
114 }
115 }
116
117 fn format_build_version(
118 package_version: &str,
119 commit: Option<&str>,
120 packaged_source: bool,
121 ) -> String {
122 match commit.and_then(|sha| short_sha(sha.to_string())) {
123 Some(sha) => format!("{package_version} ({sha})"),
124 None if packaged_source => package_version.to_string(),
125 None => format!("{package_version} (dev)"),
126 }
127 }
128
129 /// Declare the rerun conditions for [`emit_release_build_sha`] alone: the two
130 /// explicit release-build SHA variables, and nothing about the local checkout.
131 ///
132 /// Deliberately not [`declare_rerun_conditions`]: watching `.git/HEAD` would
133 /// make the build script rerun on every local commit, for a value that is
134 /// `None` on every local build by design.
135 pub fn declare_release_sha_rerun() {
136 println!("cargo:rerun-if-env-changed=CODEWHALE_BUILD_SHA");
137 println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
138 }
139
140 /// Emit `cargo:rustc-env=CODEWHALE_RELEASE_BUILD_SHA=...` — the first 12 hex
141 /// characters of the build sha — **only** when the build environment supplied
142 /// one.
143 ///
144 /// This is provenance for a *published* binary, and it is the only sha a
145 /// telemetry payload may carry. There is deliberately no fallback to the local
146 /// checkout:
147 ///
148 /// - `CODEWHALE_BUILD_COMMIT` historically fell back to the builder's own
149 /// private `HEAD` on every local build; since #5245 it is env-only too,
150 /// but this value keeps its own name and rule because it is the only sha
151 /// a telemetry payload may carry.
152 /// - The "was this a published release" gate proposed earlier cannot be built:
153 /// `codewhale_release::latest_release_tag_{async,blocking}` are **network
154 /// calls** to `api.github.com` that return *tag names*, not shas, so the only
155 /// available comparison is version-vs-version — and a private tree at the
156 /// same version compares equal.
157 ///
158 /// Build-time provenance is deterministic, network-free, and verifiable from
159 /// the repository. Absent the release environment the value is simply absent,
160 /// and `option_env!` in the consuming crate yields `None`.
161 pub fn emit_release_build_sha() {
162 if let Some(sha) = release_build_sha(|name| std::env::var(name).ok()) {
163 println!("cargo:rustc-env=CODEWHALE_RELEASE_BUILD_SHA={sha}");
164 }
165 }
166
167 /// The decision behind [`emit_release_build_sha`], with the environment
168 /// injected so it can be tested without mutating the process.
169 ///
170 /// `CODEWHALE_BUILD_SHA` wins over the legacy `DEEPSEEK_BUILD_SHA`; each must
171 /// be a full 40-hex sha to be believed, and the result is the first 12
172 /// characters.
173 ///
174 /// Never `GITHUB_SHA`. Every GitHub Actions job sets it — pull-request CI,
175 /// forks, any workflow that happens to build — so it proves only "built on
176 /// Actions", not "published release". Every release, nightly, and CNB build
177 /// exports `CODEWHALE_BUILD_SHA` explicitly, so dropping the ambient fallback
178 /// leaves published binaries unchanged. The display stamp
179 /// ([`emit_build_version`]) still accepts `GITHUB_SHA`: it is a version
180 /// string, not provenance.
181 #[must_use]
182 pub fn release_build_sha(read_env: impl Fn(&str) -> Option<String>) -> Option<String> {
183 read_env("CODEWHALE_BUILD_SHA")
184 .and_then(full_sha)
185 .or_else(|| read_env("DEEPSEEK_BUILD_SHA").and_then(full_sha))
186 .and_then(short_sha)
187 }
188
189 fn build_commit() -> Option<String> {
190 build_commit_with(|name| std::env::var(name).ok())
191 }
192
193 /// The stamping decision with the environment injected, so the no-local-
194 /// fallback contract is testable without mutating the process (#5245).
195 fn build_commit_with(read_env: impl Fn(&str) -> Option<String>) -> Option<String> {
196 read_env("CODEWHALE_BUILD_SHA")
197 .and_then(full_sha)
198 .or_else(|| read_env("DEEPSEEK_BUILD_SHA").and_then(full_sha))
199 .or_else(|| read_env("GITHUB_SHA").and_then(full_sha))
200 }
201
202 fn full_sha(value: String) -> Option<String> {
203 let trimmed = value.trim().to_ascii_lowercase();
204 if trimmed.len() != 40 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
205 return None;
206 }
207 Some(trimmed)
208 }
209
210 fn short_sha(value: String) -> Option<String> {
211 let trimmed = value.trim();
212 if trimmed.is_empty() {
213 return None;
214 }
215 Some(trimmed.chars().take(12).collect())
216 }
217
218 #[cfg(test)]
219 mod tests {
220 use super::{
221 WINDOWS_MAIN_STACK_BYTES, full_sha, release_build_sha, short_sha,
222 windows_main_stack_link_arg,
223 };
224
225 fn windows_target(env: &'static str) -> impl Fn(&str) -> Option<String> {
226 move |name| match name {
227 "CARGO_CFG_TARGET_OS" => Some("windows".to_string()),
228 "CARGO_CFG_TARGET_ENV" => Some(env.to_string()),
229 _ => None,
230 }
231 }
232
233 /// Every Codewhale entrypoint must reserve its Windows main-thread stack.
234 ///
235 /// `codewhale` shipped without it while `codewhale-tui` had it, so
236 /// `codewhale model resolve` ran `fn main` on the 1 MiB linker default and
237 /// aborted with `thread 'main' has overflowed its stack` on hosted Windows.
238 /// `cargo:rustc-link-arg-bin` reaches only the calling package's binaries,
239 /// so each entrypoint needs its own directive and neither covers the other.
240 #[test]
241 fn every_windows_entrypoint_reserves_the_same_main_stack() {
242 for bin in ["codewhale", "codewhale-tui"] {
243 assert_eq!(
244 windows_main_stack_link_arg(bin, windows_target("msvc")),
245 Some(format!(
246 "cargo:rustc-link-arg-bin={bin}=/STACK:{WINDOWS_MAIN_STACK_BYTES}"
247 ))
248 );
249 assert_eq!(
250 windows_main_stack_link_arg(bin, windows_target("gnu")),
251 Some(format!(
252 "cargo:rustc-link-arg-bin={bin}=-Wl,--stack,{WINDOWS_MAIN_STACK_BYTES}"
253 ))
254 );
255 }
256 // Keep the previously shipped TUI reserve.
257 assert_eq!(WINDOWS_MAIN_STACK_BYTES, 8 * 1024 * 1024);
258 }
259
260 /// The directive is Windows-only and names one binary. A non-Windows target
261 /// emits nothing, so this never becomes a workspace-wide stack change.
262 #[test]
263 fn no_stack_directive_is_emitted_off_windows() {
264 for os in ["linux", "macos"] {
265 assert_eq!(
266 windows_main_stack_link_arg("codewhale", |name| (name == "CARGO_CFG_TARGET_OS")
267 .then(|| os.to_string())),
268 None
269 );
270 }
271 // An unknown Windows ABI gets no guessed linker syntax.
272 assert_eq!(
273 windows_main_stack_link_arg("codewhale", windows_target("sgx")),
274 None
275 );
276 assert_eq!(windows_main_stack_link_arg("codewhale", |_| None), None);
277 }
278
279 #[test]
280 fn packaged_sources_do_not_claim_to_be_unreleased_or_stamped() {
281 assert_eq!(super::format_build_version("0.9.13", None, true), "0.9.13");
282 assert_eq!(
283 super::format_build_version("0.9.13", None, false),
284 "0.9.13 (dev)"
285 );
286 let sha = "abcdef0123456789abcdef0123456789abcdef01";
287 for packaged in [true, false] {
288 assert_eq!(
289 super::format_build_version("0.9.13", Some(sha), packaged),
290 "0.9.13 (abcdef012345)"
291 );
292 }
293 // Source packaging must not create a telemetry/release provenance SHA.
294 assert_eq!(release_build_sha(|_| None), None);
295 }
296
297 #[test]
298 fn full_commit_requires_exact_forty_hex_characters() {
299 assert_eq!(
300 full_sha("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
301 Some("abcdef0123456789abcdef0123456789abcdef01".to_string())
302 );
303 assert_eq!(full_sha("abc123".to_string()), None);
304 assert_eq!(
305 full_sha("gggggggggggggggggggggggggggggggggggggggg".to_string()),
306 None
307 );
308 assert_eq!(
309 short_sha("abcdef0123456789abcdef0123456789abcdef01".to_string()),
310 Some("abcdef012345".to_string())
311 );
312 }
313
314 #[test]
315 fn the_release_build_sha_is_absent_for_every_local_build() {
316 // No release environment: nothing is emitted, so `option_env!` in the
317 // consuming crate is `None` and a telemetry payload carries `git_sha:
318 // null`. This is the property that keeps a maintainer's private HEAD
319 // out of a shipped binary.
320 assert_eq!(release_build_sha(|_| None), None);
321 }
322
323 #[test]
324 fn the_release_build_sha_comes_only_from_a_release_environment() {
325 let ci = "abcdef0123456789abcdef0123456789abcdef01";
326 // Audit R02-m1: an ordinary Actions build (PR CI, a fork) has
327 // `GITHUB_SHA` and nothing else; that is not release provenance.
328 assert_eq!(
329 release_build_sha(|name| (name == "GITHUB_SHA").then(|| ci.to_string())),
330 None
331 );
332 // The canonical Codewhale variable wins over the legacy
333 // DeepSeek-era one; `GITHUB_SHA` never participates.
334 assert_eq!(
335 release_build_sha(|name| match name {
336 "CODEWHALE_BUILD_SHA" => Some("e".repeat(40)),
337 "DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
338 "GITHUB_SHA" => Some(ci.to_string()),
339 _ => None,
340 }),
341 Some("e".repeat(12))
342 );
343 // The legacy name still stamps during the 0.9.x compatibility
344 // window, so existing release tooling keeps working.
345 assert_eq!(
346 release_build_sha(|name| match name {
347 "DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
348 "GITHUB_SHA" => Some(ci.to_string()),
349 _ => None,
350 }),
351 Some("f".repeat(12))
352 );
353 // A value that is not a full sha is not believed, and does not fall
354 // through to the local checkout.
355 assert_eq!(
356 release_build_sha(|name| (name == "DEEPSEEK_BUILD_SHA").then(|| "abc123".to_string())),
357 None
358 );
359 // `CODEWHALE_BUILD_COMMIT` is a different value with a different rule
360 // and is never a source here.
361 assert_eq!(
362 release_build_sha(|name| (name == "CODEWHALE_BUILD_COMMIT").then(|| ci.to_string())),
363 None
364 );
365 }
366
367 /// #5245 contract: the version stamp reads ONLY the two environment
368 /// variables. There is no fallback to the local checkout, so a plain
369 /// local build renders `(dev)` and — the actual point — the build script
370 /// declares no `.git` rerun paths, meaning `git commit` no longer
371 /// invalidates the two largest compile units in the workspace.
372 #[test]
373 fn the_build_commit_never_reads_the_local_checkout() {
374 // This test runs inside the real repository; if a git fallback still
375 // existed it would resolve a sha here. Absent env vars must mean
376 // absent commit, in the repo or out of it.
377 assert_eq!(super::build_commit_with(|_| None), None);
378 let ci = "abcdef0123456789abcdef0123456789abcdef01";
379 assert_eq!(
380 super::build_commit_with(|name| (name == "GITHUB_SHA").then(|| ci.to_string())),
381 Some(ci.to_string())
382 );
383 assert_eq!(
384 super::build_commit_with(|name| match name {
385 "DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
386 "GITHUB_SHA" => Some(ci.to_string()),
387 _ => None,
388 }),
389 Some("f".repeat(40))
390 );
391 assert_eq!(
392 super::build_commit_with(|name| match name {
393 "CODEWHALE_BUILD_SHA" => Some("e".repeat(40)),
394 "DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
395 _ => None,
396 }),
397 Some("e".repeat(40))
398 );
399 }
400 }
401
401 lines RUST