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