| 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 | |
| 30 | use 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. |
| 39 | pub 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. |
| 52 | pub 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. |
| 72 | pub 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`. |
| 98 | pub 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] |
| 110 | pub 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 | |
| 117 | fn 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). |
| 123 | fn 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 | |
| 129 | fn 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 | |
| 137 | fn 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)] |
| 146 | mod 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 | } |
| 231 |