| 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 |