diff --git a/CHANGELOG.md b/CHANGELOG.md index 083d6e746..847914204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER_GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. - **Breaking:** Auction providers and bidder routes now use the configuration-first `[auction.providers.]` and `[auction.bidders.]` maps. The removed `[auction].providers = [...]` list and removed server fields under `[integrations.prebid]` and `[integrations.aps]` are rejected even when those integrations are disabled, and `ts config push` rejects the old shape before publication. Move PBS `server_url` to provider `endpoint`, server timeout to provider `timeout_ms`, request controls and bidder-parameter overrides to the `prebid-server` `profile_config`, notification suppression to `notifications`, and each former server bidder to an `[auction.bidders.]` route. Move APS endpoint, timeout, account, inventory, debug, and creative controls to an `aps` provider and its `profile_config`. Browser Prebid settings remain under `[integrations.prebid]`; values such as timeout and debug that previously affected both browser and server behavior must now be configured for each owner. Provider endpoints must be absolute HTTPS URLs. Only bidder codes present in `[auction.bidders]` are folded into Trusted Server requests; unlisted publisher bids remain native browser demand. Provider response names now use the configured provider ID, such as `pbs-main`, instead of the legacy literal `prebid`; audit consumers that match `AuctionResponse.provider`. This schema has no mixed-version-safe deployment order: old binaries reject the maps and new binaries reject the retired fields, so activate the new binary and config blob together. Rollbacks must restore an old-schema blob together with the old binary. - **Breaking** — Admin Basic-auth coverage now includes `GET /_ts/admin/ec`, `GET /_ts/admin/ec/{id}`, and `GET /_ts/admin/eids`. Existing configurations whose `[[handlers]]` patterns protect only the key-management endpoints now fail startup; broaden coverage before deploying, preferably with a namespace-boundary pattern such as `^/_ts/admin(?:/|$)`. Coverage of the dynamic `/_ts/admin/ec/{id}` route is no longer inferred from ID-shaped samples: the router accepts any segment after `/_ts/admin/ec/` and Basic Auth runs on the raw path before routing, so patterns anchored to the EC ID grammar (for example `^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$`) are rejected in favor of a prefix-level matcher. Placeholder and well-known weak handler passwords (`changeme`, `password`, `admin`, `replace-with-…`) now fail startup on every handler rather than only on handlers inferred to cover an admin endpoint, because first-match-wins handler selection lets a narrow handler shadow the admin namespace. - Prebid Server provider endpoints now normalize origin-only legacy `server_url` values to `/openrtb2/auction`. Query parameters are preserved, the canonical path loses a trailing slash, and configured non-root custom paths remain exact. diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index fd11d7728..f099bc15b 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -53,8 +53,9 @@ impl Middleware for SanitizeRequestMiddleware { /// Response-finalization middleware: injects all standard TS response headers. /// /// Geo lookup is unavailable in the Axum dev server — `X-Geo-Info-Available: false` -/// is always emitted. Fastly-specific headers (`X-TS-Version`, `X-TS-ENV`) are -/// skipped because the corresponding env vars are not set in a local dev context. +/// is always emitted. `X-TS-Version` carries the compiled-in git version. The +/// Fastly-specific headers (`X-TS-Fastly-Version`, `X-TS-ENV`) are skipped +/// because the corresponding env vars are not set in a local dev context. /// /// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of /// [`AuthMiddleware`] so that every outgoing response — including auth-rejected @@ -125,7 +126,8 @@ impl Middleware for AuthMiddleware { /// Applies standard Trusted Server response headers to the given response. /// /// Unlike the Fastly variant, geo is always unavailable so `X-Geo-Info-Available: false` -/// is unconditionally emitted. Fastly-specific headers are omitted. +/// is unconditionally emitted, followed by the compiled-in `X-TS-Version`. +/// Fastly-specific headers are omitted. /// Operator-configured `settings.response_headers` are applied last (with the /// shared cookie cache-privacy hardening) and can override any managed header. pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { @@ -134,6 +136,8 @@ pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Respons HeaderValue::from_static("false"), ); + trusted_server_core::version_header::apply_git_version_header(response); + // Cookie-bearing responses stay private to shared caches and operator // headers cannot re-enable caching for uncacheable per-user payloads. trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy( @@ -287,4 +291,18 @@ mod tests { "should remove both configured trust headers before the handler" ); } + + #[test] + fn emits_git_version_header() { + let mut response = empty_response(); + apply_finalize_headers(&settings_with_response_headers(vec![]), &mut response); + assert_eq!( + response + .headers() + .get("x-ts-version") + .and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version as x-ts-version" + ); + } } diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 7126b2a71..bcb31b7e4 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -938,3 +938,35 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() { "should not leak generated placeholders: {html}" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn health_reports_git_version() { + let mut service = make_service(); + let request = Request::builder() + .method("GET") + .uri("/health") + .body(AxumBody::empty()) + .expect("should build health request"); + + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should serve health"); + + assert_eq!( + response.status().as_u16(), + 200, + "should return 200 on /health" + ); + assert_eq!( + response + .headers() + .get("x-ts-version") + .and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version on /health" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 14efed56a..a22b51982 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -146,6 +146,8 @@ pub(crate) fn apply_finalize_headers( HeaderValue::from_static(if geo_available { "true" } else { "false" }), ); + trusted_server_core::version_header::apply_git_version_header(response); + // Cloudflare is a real shared cache: cookie-bearing responses must stay // private and operator headers must not re-enable caching for uncacheable // per-user payloads. @@ -320,4 +322,22 @@ mod tests { "should remove both configured trust headers before the handler" ); } + + #[test] + fn emits_git_version_header() { + let mut response = empty_response(); + apply_finalize_headers( + &settings_with_response_headers(vec![]), + false, + &mut response, + ); + assert_eq!( + response + .headers() + .get("x-ts-version") + .and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version as x-ts-version" + ); + } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 367a3e33f..798db7925 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -14,6 +14,7 @@ use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::constants::HEADER_X_TS_VERSION; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; @@ -28,6 +29,7 @@ use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; +use trusted_server_core::version_header::git_version_header_value; mod app; mod backend; @@ -64,7 +66,12 @@ fn open_trusted_server_config_store(store_name: &str) -> Result Option { if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { - return Some(FastlyResponse::from_status(200).with_body_text_plain("ok")); + let mut response = FastlyResponse::from_status(200).with_body_text_plain("ok"); + // Compiled-in constant: keeps the probe free of settings and app construction. + if let Some(version) = git_version_header_value() { + response.set_header(HEADER_X_TS_VERSION, version); + } + return Some(response); } None @@ -558,6 +565,23 @@ mod tests { ); } + #[test] + fn health_response_reports_git_version_only() { + let req = FastlyRequest::get("https://example.com/health"); + + let response = health_response(&req).expect("should build health response"); + + assert_eq!( + response.get_header_str("x-ts-version"), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version on /health" + ); + assert!( + response.get_header("x-ts-fastly-version").is_none(), + "should keep x-ts-fastly-version off the /health fast path" + ); + } + #[test] fn health_response_ignores_non_health_paths() { let req = FastlyRequest::get("https://example.com/auction"); diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 283f16255..e0b1791b4 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -22,11 +22,12 @@ use std::net::IpAddr; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, - HEADER_X_TS_ENV, HEADER_X_TS_VERSION, + HEADER_X_TS_ENV, HEADER_X_TS_FASTLY_VERSION, }; use trusted_server_core::geo::GeoInfo; use trusted_server_core::platform::{ClientInfo, PlatformGeo}; use trusted_server_core::settings::Settings; +use trusted_server_core::version_header::apply_git_version_header; pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; @@ -49,7 +50,8 @@ pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; /// /// Headers are written in this order (last write wins): /// 1. Geo headers (or `X-Geo-Info-Available: false` when geo is unavailable) -/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var +/// 2. `X-TS-Version` from the compiled-in git version (`TS_GIT_VERSION`), and +/// `X-TS-Fastly-Version` from the `FASTLY_SERVICE_VERSION` env var /// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` /// 4. Operator-configured `settings.response_headers` (can override any managed header) pub struct FinalizeResponseMiddleware { @@ -187,7 +189,8 @@ where /// /// Header write order (last write wins): /// 1. Geo headers (`x-geo-*`) — or `X-Geo-Info-Available: false` when absent -/// 2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var +/// 2. `X-TS-Version` from the compiled-in git version (`TS_GIT_VERSION`), and +/// `X-TS-Fastly-Version` from the `FASTLY_SERVICE_VERSION` env var /// 3. `X-TS-ENV: staging` when `FASTLY_IS_STAGING == "1"` /// 4. Set-Cookie cache privacy — strip surrogate cache headers and downgrade /// `Cache-Control` to `private, max-age=0` on cookie-bearing responses @@ -208,9 +211,13 @@ pub(crate) fn apply_finalize_headers( ); } + apply_git_version_header(response); + if let Ok(v) = std::env::var(ENV_FASTLY_SERVICE_VERSION) { if let Ok(value) = HeaderValue::from_str(&v) { - response.headers_mut().insert(HEADER_X_TS_VERSION, value); + response + .headers_mut() + .insert(HEADER_X_TS_FASTLY_VERSION, value); } else { log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); } @@ -813,4 +820,24 @@ mod tests { "should reach the handler when auth is not required" ); } + + #[test] + fn version_headers_split_git_and_fastly_versions() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, None, &mut response); + + let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()); + assert_eq!( + header("x-ts-version"), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version as x-ts-version" + ); + assert_eq!( + header("x-ts-fastly-version"), + std::env::var(ENV_FASTLY_SERVICE_VERSION).ok().as_deref(), + "should report FASTLY_SERVICE_VERSION as x-ts-fastly-version" + ); + } } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 821b52646..d248f2f15 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1367,6 +1367,13 @@ mod tests { 200, "GET /health must return 200 from the startup fallback" ); + assert_eq!( + resp.headers() + .get("x-ts-version") + .and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "startup-fallback /health should report the compiled-in git version" + ); let body = resp.into_body().into_bytes().unwrap_or_default(); assert_eq!( &body[..], diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index d7a09987a..fc1b939bd 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -174,6 +174,8 @@ pub(crate) fn apply_finalize_headers( HeaderValue::from_static(if geo_available { "true" } else { "false" }), ); + trusted_server_core::version_header::apply_git_version_header(response); + // Cookie-bearing responses stay private to shared caches and operator // headers cannot re-enable caching for uncacheable per-user payloads. trusted_server_core::response_privacy::apply_response_headers_with_cache_privacy( @@ -347,4 +349,22 @@ mod tests { "should remove both configured trust headers before the handler" ); } + + #[test] + fn emits_git_version_header() { + let mut response = empty_response(); + apply_finalize_headers( + &settings_with_response_headers(vec![]), + false, + &mut response, + ); + assert_eq!( + response + .headers() + .get("x-ts-version") + .and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version as x-ts-version" + ); + } } diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index c2bce4fe2..e762f66a6 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -1,3 +1,114 @@ +//! Compiles the deployed git version into `trusted-server-core` as `TS_GIT_VERSION`. + +#[path = "build_support/git_version.rs"] +mod git_version; + +use std::path::Path; +use std::process::Command; + +use git_version::{Candidates, is_usable, resolve_git_version}; + +/// Set by the deploy pipeline. Its CI checkout is shallow and detached, so local git +/// cannot see the tag or branch there. +const OVERRIDE_ENV: &str = "TRUSTED_SERVER_GIT_VERSION"; + +/// Runs `git` in the crate directory; `None` if git is missing or fails. +fn git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_owned()) +} + +/// Re-runs this script when `path` changes. Skips missing paths: Cargo treats +/// them as always changed, which would rebuild core on every build. +fn rerun_if_exists(path: &Path) { + if path.exists() { + println!("cargo:rerun-if-changed={}", path.display()); + } +} + +/// Resolves the version from local git, watching the paths that move with it. +/// +/// `None` unless git's top level is this workspace, so an exported tree nested +/// in an unrelated repository does not report that repository's version. +fn resolve_from_local_git() -> Option { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + // This crate lives at `/crates/trusted-server-core`. + let workspace_root = Path::new(&manifest_dir).parent()?.parent()?; + let repository_root = git(&["rev-parse", "--show-toplevel"])?; + if Path::new(&repository_root).canonicalize().ok()? != workspace_root.canonicalize().ok()? { + return None; + } + + // HEAD is per-worktree; refs are shared in the common dir. They differ in a + // linked worktree and coincide in a plain clone. Only the current branch's + // ref (its commit decides the exact-tag match) and tags can change the + // result, so other branches and `refs/remotes` are not watched: a commit + // elsewhere or a fetch must not rebuild core. When the branch ref is only + // in `packed-refs`, its nearest existing ancestor (usually `refs/heads`) is + // watched instead, so the loose ref the next commit writes is noticed. + if let Some(git_dir) = git(&["rev-parse", "--absolute-git-dir"]) { + rerun_if_exists(&Path::new(&git_dir).join("HEAD")); + } + if let Some(common_dir) = git(&["rev-parse", "--path-format=absolute", "--git-common-dir"]) { + let common_dir = Path::new(&common_dir); + if let Some(branch_ref) = git(&["symbolic-ref", "-q", "HEAD"]) { + let branch_path = common_dir.join(branch_ref); + // Stop below the common dir: watching all of it would rebuild core + // on every object write. + if let Some(watched_path) = branch_path + .ancestors() + .take_while(|path| *path != common_dir) + .find(|path| path.exists()) + { + rerun_if_exists(watched_path); + } + } + rerun_if_exists(&common_dir.join("refs/tags")); + rerun_if_exists(&common_dir.join("packed-refs")); + } + + let exact_tag = git(&["describe", "--tags", "--exact-match"]); + let branch = git(&["symbolic-ref", "--short", "-q", "HEAD"]); + let commit = git(&["rev-parse", "HEAD"]); + resolve_git_version(&Candidates { + override_value: None, + exact_tag: exact_tag.as_deref(), + branch: branch.as_deref(), + commit: commit.as_deref(), + }) +} + fn main() { println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=build_support/git_version.rs"); + println!("cargo:rerun-if-env-changed={OVERRIDE_ENV}"); + + let override_value = std::env::var(OVERRIDE_ENV).ok(); + let resolved = match override_value.as_deref() { + Some(value) if is_usable(value) => resolve_git_version(&Candidates { + override_value: Some(value), + exact_tag: None, + branch: None, + commit: None, + }), + Some(value) => { + if !value.trim().is_empty() { + println!( + "cargo:warning={OVERRIDE_ENV}={value:?} is not visible ASCII; \ + falling back to local git for x-ts-version" + ); + } + resolve_from_local_git() + } + None => resolve_from_local_git(), + }; + + if let Some(version) = resolved { + println!("cargo:rustc-env=TS_GIT_VERSION={version}"); + } } diff --git a/crates/trusted-server-core/build_support/git_version.rs b/crates/trusted-server-core/build_support/git_version.rs new file mode 100644 index 000000000..981434b1b --- /dev/null +++ b/crates/trusted-server-core/build_support/git_version.rs @@ -0,0 +1,42 @@ +//! Resolution rule for the deployed git version reported in `x-ts-version`. +//! +//! Shared by `build.rs` and `tests/git_version_resolve.rs` via `#[path]`, so it +//! must stay dependency-free. + +/// Raw candidates for the deployed git version, in priority order. +pub struct Candidates<'a> { + /// `TRUSTED_SERVER_GIT_VERSION`, supplied by the deploy pipeline. + pub override_value: Option<&'a str>, + /// `git describe --tags --exact-match`. + pub exact_tag: Option<&'a str>, + /// `git symbolic-ref --short -q HEAD`. + pub branch: Option<&'a str>, + /// `git rev-parse HEAD`. + pub commit: Option<&'a str>, +} + +/// Whether `value`, once trimmed, is non-empty visible ASCII (`0x21`–`0x7E`). +/// +/// Stricter than git, which also allows non-ASCII UTF-8 in ref names. Such a +/// ref is skipped so every compiled-in value is a `to_str()`-able header value. +#[must_use] +pub fn is_usable(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.is_empty() && trimmed.bytes().all(|b| (0x21..=0x7E).contains(&b)) +} + +/// Picks the first usable candidate: override, tag, branch, then the first 6 +/// characters of the commit. `None` when nothing usable is known. +#[must_use] +pub fn resolve_git_version(candidates: &Candidates<'_>) -> Option { + usable(candidates.override_value) + .or_else(|| usable(candidates.exact_tag)) + .or_else(|| usable(candidates.branch)) + .map(str::to_owned) + .or_else(|| usable(candidates.commit).map(|c| c.chars().take(6).collect())) +} + +/// The trimmed candidate, if it is usable. +fn usable(value: Option<&str>) -> Option<&str> { + value.filter(|v| is_usable(v)).map(str::trim) +} diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index d444238e8..633c0a57b 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -30,9 +30,16 @@ pub const HEADER_X_COMPRESS_HINT: HeaderName = HeaderName::from_static("x-compre pub const HEADER_X_DEBUG_FASTLY_POP: HeaderName = HeaderName::from_static("x-debug-fastly-pop"); // Staging / version identification headers +/// Deployed git version: tag, else branch, else 6-char commit (see [`TS_GIT_VERSION`]). pub const HEADER_X_TS_VERSION: HeaderName = HeaderName::from_static("x-ts-version"); +/// Fastly service version (`FASTLY_SERVICE_VERSION`), formerly sent as `x-ts-version`. +pub const HEADER_X_TS_FASTLY_VERSION: HeaderName = HeaderName::from_static("x-ts-fastly-version"); pub const HEADER_X_TS_ENV: HeaderName = HeaderName::from_static("x-ts-env"); +/// Deployed git version compiled in by `build.rs`, from the deploy pipeline's +/// `TRUSTED_SERVER_GIT_VERSION` or local git. `None` when unknown. +pub const TS_GIT_VERSION: Option<&str> = option_env!("TS_GIT_VERSION"); + // Fastly environment variables pub const ENV_FASTLY_SERVICE_VERSION: &str = "FASTLY_SERVICE_VERSION"; pub const ENV_FASTLY_IS_STAGING: &str = "FASTLY_IS_STAGING"; diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 76621baf7..704903848 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -73,6 +73,7 @@ pub mod streaming_replacer; pub mod test_support; pub mod tester_cookie; pub mod tsjs; +pub mod version_header; #[cfg(test)] mod migration_guards; diff --git a/crates/trusted-server-core/src/version_header.rs b/crates/trusted-server-core/src/version_header.rs new file mode 100644 index 000000000..658c31f68 --- /dev/null +++ b/crates/trusted-server-core/src/version_header.rs @@ -0,0 +1,176 @@ +//! `x-ts-version`: the deployed git version compiled in by `build.rs`. + +use edgezero_core::http::{HeaderValue, Response}; + +use crate::constants::{HEADER_X_TS_VERSION, TS_GIT_VERSION}; + +/// Returns the compiled-in git version as a header value. +/// +/// `None` when no version is known or the value is not a valid header value. +#[must_use] +pub fn git_version_header_value() -> Option { + header_value_from(TS_GIT_VERSION) +} + +/// Converts `version` to a header value, logging and returning `None` when it +/// is not a valid header value. +#[must_use] +pub fn header_value_from(version: Option<&str>) -> Option { + let version = version?; + match HeaderValue::from_str(version) { + Ok(value) => Some(value), + Err(_) => { + log::warn!("Skipping invalid TS_GIT_VERSION response header value"); + None + } + } +} + +/// Sets `x-ts-version` to the compiled-in git version, if one is known. +pub fn apply_git_version_header(response: &mut Response) { + apply_git_version_header_from(TS_GIT_VERSION, response); +} + +/// Sets `x-ts-version` to `version`, or removes it when `version` is unknown +/// or invalid, so an origin's `x-ts-version` is never reported as ours. +pub fn apply_git_version_header_from(version: Option<&str>, response: &mut Response) { + if let Some(value) = header_value_from(version) { + response.headers_mut().insert(HEADER_X_TS_VERSION, value); + } else { + response.headers_mut().remove(HEADER_X_TS_VERSION); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::body::Body; + use edgezero_core::http::response_builder; + + fn empty_response() -> Response { + response_builder() + .body(Body::empty()) + .expect("should build empty test response") + } + + fn response_with_upstream_version() -> Response { + response_builder() + .header(HEADER_X_TS_VERSION, "upstream-v1") + .body(Body::empty()) + .expect("should build test response with an upstream x-ts-version") + } + + fn version_of(response: &Response) -> Option<&str> { + response + .headers() + .get(HEADER_X_TS_VERSION) + .and_then(|v| v.to_str().ok()) + } + + #[test] + fn header_value_from_valid_version() { + assert_eq!( + header_value_from(Some("v1.2.3")), + Some(HeaderValue::from_static("v1.2.3")), + "should convert a valid version" + ); + } + + #[test] + fn header_value_from_unknown_or_invalid_version() { + assert_eq!(header_value_from(None), None, "should be None when unknown"); + assert_eq!( + header_value_from(Some("bad\nvalue")), + None, + "should be None for a non-header-safe version" + ); + } + + #[test] + fn git_version_header_value_matches_compiled_in_version() { + assert_eq!( + git_version_header_value() + .as_ref() + .and_then(|v| v.to_str().ok()), + TS_GIT_VERSION, + "should convert the compiled-in TS_GIT_VERSION" + ); + } + + #[test] + fn sets_header_from_version() { + let mut response = empty_response(); + apply_git_version_header_from(Some("v1.2.3"), &mut response); + assert_eq!( + version_of(&response), + Some("v1.2.3"), + "should set x-ts-version" + ); + } + + #[test] + fn omits_header_when_version_unknown() { + let mut response = empty_response(); + apply_git_version_header_from(None, &mut response); + assert_eq!( + version_of(&response), + None, + "should omit x-ts-version when unknown" + ); + } + + #[test] + fn skips_invalid_header_value() { + let mut response = empty_response(); + apply_git_version_header_from(Some("bad\nvalue"), &mut response); + assert_eq!( + version_of(&response), + None, + "should skip a non-header-safe version" + ); + } + + #[test] + fn replaces_upstream_header_with_version() { + let mut response = response_with_upstream_version(); + apply_git_version_header_from(Some("v2.0.0"), &mut response); + assert_eq!( + version_of(&response), + Some("v2.0.0"), + "should replace an upstream x-ts-version with ours" + ); + } + + #[test] + fn removes_upstream_header_when_version_unknown() { + let mut response = response_with_upstream_version(); + apply_git_version_header_from(None, &mut response); + assert_eq!( + version_of(&response), + None, + "should remove an upstream x-ts-version when ours is unknown" + ); + } + + #[test] + fn removes_upstream_header_when_version_invalid() { + let mut response = response_with_upstream_version(); + apply_git_version_header_from(Some("bad\nvalue"), &mut response); + assert_eq!( + version_of(&response), + None, + "should remove an upstream x-ts-version when ours is invalid" + ); + } + + #[test] + fn default_uses_compiled_in_version() { + let mut response = empty_response(); + apply_git_version_header(&mut response); + assert_eq!( + version_of(&response), + TS_GIT_VERSION, + "should report the compiled-in TS_GIT_VERSION" + ); + } +} diff --git a/crates/trusted-server-core/tests/git_version_resolve.rs b/crates/trusted-server-core/tests/git_version_resolve.rs new file mode 100644 index 000000000..d65cd4d42 --- /dev/null +++ b/crates/trusted-server-core/tests/git_version_resolve.rs @@ -0,0 +1,141 @@ +//! The version-resolution rule `build.rs` uses for `TS_GIT_VERSION`. + +#[path = "../build_support/git_version.rs"] +mod git_version; + +use git_version::{Candidates, resolve_git_version}; + +const COMMIT: &str = "abcdef0123456789abcdef0123456789abcdef01"; + +fn local(exact_tag: Option<&'static str>, branch: Option<&'static str>) -> Candidates<'static> { + Candidates { + override_value: None, + exact_tag, + branch, + commit: Some(COMMIT), + } +} + +#[test] +fn override_wins_over_local_git() { + let candidates = Candidates { + override_value: Some("v1.2.3"), + ..local(Some("v9.9.9"), Some("main")) + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("v1.2.3"), + "should prefer the pipeline-supplied TRUSTED_SERVER_GIT_VERSION" + ); +} + +#[test] +fn override_is_trimmed() { + let candidates = Candidates { + override_value: Some(" feature/x\n"), + ..local(None, None) + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("feature/x"), + "should trim surrounding whitespace from the override" + ); +} + +#[test] +fn tag_wins_over_branch() { + assert_eq!( + resolve_git_version(&local(Some("v1.2.3"), Some("main"))).as_deref(), + Some("v1.2.3"), + "should prefer an exact tag over the branch" + ); +} + +#[test] +fn branch_when_no_tag() { + assert_eq!( + resolve_git_version(&local(None, Some("feature/x"))).as_deref(), + Some("feature/x"), + "should use the branch when HEAD is not tagged" + ); +} + +#[test] +fn six_char_hash_when_no_tag_or_branch() { + assert_eq!( + resolve_git_version(&local(None, None)).as_deref(), + Some("abcdef"), + "should use exactly the first 6 characters of the commit" + ); +} + +#[test] +fn blank_candidates_are_skipped() { + let candidates = Candidates { + override_value: Some(" "), + exact_tag: Some(""), + branch: Some(" "), + commit: Some(COMMIT), + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("abcdef"), + "should skip empty or whitespace-only candidates" + ); +} + +#[test] +fn non_ascii_override_falls_back_to_local_git() { + let candidates = Candidates { + override_value: Some("v1-été"), + ..local(None, None) + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("abcdef"), + "should skip a non-ASCII override and fall back to local git" + ); +} + +#[test] +fn override_with_inner_space_falls_back_to_local_git() { + let candidates = Candidates { + override_value: Some("v1 2"), + ..local(None, Some("main")) + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("main"), + "should skip an override containing a space" + ); +} + +#[test] +fn none_when_nothing_is_known() { + let candidates = Candidates { + override_value: None, + exact_tag: None, + branch: None, + commit: None, + }; + assert_eq!( + resolve_git_version(&candidates), + None, + "should leave the version unset so the header is omitted" + ); +} + +#[test] +fn is_usable_accepts_only_visible_ascii() { + assert!(git_version::is_usable("v1.2.3"), "should accept a tag"); + assert!( + git_version::is_usable(" main "), + "should accept after trimming" + ); + assert!(!git_version::is_usable(""), "should reject empty"); + assert!( + !git_version::is_usable("a\tb"), + "should reject a control character" + ); + assert!(!git_version::is_usable("v1-été"), "should reject non-ASCII"); +} diff --git a/docs/guide/first-party-proxy.md b/docs/guide/first-party-proxy.md index 8b9056f0e..69d4ed7c6 100644 --- a/docs/guide/first-party-proxy.md +++ b/docs/guide/first-party-proxy.md @@ -716,7 +716,7 @@ Add custom headers for debugging: ```toml [response_headers] X-Proxy-Mode = "rewrite" -X-TS-Version = "1.0" +X-Debug-Build = "canary" ``` ### Metrics to Track diff --git a/docs/superpowers/plans/2026-09-25-git-version-header.md b/docs/superpowers/plans/2026-09-25-git-version-header.md new file mode 100644 index 000000000..43b666403 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-git-version-header.md @@ -0,0 +1,959 @@ +# Trusted Server: `x-ts-version` = git version, `x-ts-fastly-version` = Fastly version — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Responses carry `x-ts-version` = the deployed git tag, else branch, else +6-char commit hash. The Fastly service version moves to `x-ts-fastly-version`. + +**Architecture:** `trusted-server-core/build.rs` compiles the version in as +`TS_GIT_VERSION`. It takes the value from the build-time env +`TRUSTED_SERVER_GIT_VERSION` (set by the deploy pipeline), or falls back to local `git`. +The choice itself is a pure function in `build_support/git_version.rs`, shared by +`build.rs` and an integration test through `#[path]`. A core module +`version_header` owns the header value and the write. Every adapter's +`apply_finalize_headers` calls it before operator `response_headers` are +applied, and the Fastly `/health` fast path sets it directly. + +**Tech Stack:** Rust 2024, Cargo build scripts, `edgezero_core::http`, `fastly` 0.12, Viceroy (`cargo test-fastly`). + +**Spec:** [`docs/superpowers/specs/2026-09-25-git-version-header-design.md`](../specs/2026-09-25-git-version-header-design.md) + +## Global Constraints + +- Build env var: exactly `TRUSTED_SERVER_GIT_VERSION`. Compiled-in rustc env: exactly `TS_GIT_VERSION`. +- Resolution order: usable env override → exact tag → branch → first **6** chars of the commit → none. +- Usable = trimmed, non-empty, and only bytes `0x21`–`0x7E`. An unusable override prints a `cargo:warning` and falls back to local git. +- No version known: **omit** `x-ts-version`. Never emit `unknown`. +- `build.rs` must never fail the build over version metadata. +- `build.rs` must print `cargo:rerun-if-env-changed=TRUSTED_SERVER_GIT_VERSION`, and `rerun-if-changed` only for git paths that exist (`/HEAD`, the current branch's ref file under ``, `/refs/tags`, `/packed-refs`). +- Header names: `x-ts-version` (git), `x-ts-fastly-version` (the `FASTLY_SERVICE_VERSION` value). Fastly `/health` gets `x-ts-version` only. +- Operator `settings.response_headers` still apply last. +- Test messages use the repo's `"should …"` style. Run the relevant `cargo test-*` / `cargo clippy-*` alias per task. +- Commits: sentence case, imperative, no prefixes. + +--- + +### Task 1: Version resolution in `build.rs` + +**Files:** + +- Create: `crates/trusted-server-core/build_support/git_version.rs` +- Modify: `crates/trusted-server-core/build.rs` +- Test: `crates/trusted-server-core/tests/git_version_resolve.rs` + +**Interfaces:** + +- Produces: `git_version::is_usable(value: &str) -> bool` and + `git_version::resolve_git_version(candidates: &Candidates<'_>) -> Option`, + where `Candidates { override_value, exact_tag, branch, commit }` are all `Option<&str>`. +- Produces: the compile-time env `TS_GIT_VERSION`, set only when a version is known. + +- [ ] **Step 1: Write the failing test** + +`crates/trusted-server-core/tests/git_version_resolve.rs`: + +```rust +//! The version-resolution rule `build.rs` uses for `TS_GIT_VERSION`. + +#[path = "../build_support/git_version.rs"] +mod git_version; + +use git_version::{Candidates, resolve_git_version}; + +const COMMIT: &str = "abcdef0123456789abcdef0123456789abcdef01"; + +fn local(exact_tag: Option<&'static str>, branch: Option<&'static str>) -> Candidates<'static> { + Candidates { + override_value: None, + exact_tag, + branch, + commit: Some(COMMIT), + } +} + +#[test] +fn override_wins_over_local_git() { + let candidates = Candidates { + override_value: Some("v1.2.3"), + ..local(Some("v9.9.9"), Some("main")) + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("v1.2.3"), + "should prefer the pipeline-supplied TRUSTED_SERVER_GIT_VERSION" + ); +} + +#[test] +fn override_is_trimmed() { + let candidates = Candidates { + override_value: Some(" feature/x\n"), + ..local(None, None) + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("feature/x"), + "should trim surrounding whitespace from the override" + ); +} + +#[test] +fn tag_wins_over_branch() { + assert_eq!( + resolve_git_version(&local(Some("v1.2.3"), Some("main"))).as_deref(), + Some("v1.2.3"), + "should prefer an exact tag over the branch" + ); +} + +#[test] +fn branch_when_no_tag() { + assert_eq!( + resolve_git_version(&local(None, Some("feature/x"))).as_deref(), + Some("feature/x"), + "should use the branch when HEAD is not tagged" + ); +} + +#[test] +fn six_char_hash_when_no_tag_or_branch() { + assert_eq!( + resolve_git_version(&local(None, None)).as_deref(), + Some("abcdef"), + "should use exactly the first 6 characters of the commit" + ); +} + +#[test] +fn blank_candidates_are_skipped() { + let candidates = Candidates { + override_value: Some(" "), + exact_tag: Some(""), + branch: Some(" "), + commit: Some(COMMIT), + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("abcdef"), + "should skip empty or whitespace-only candidates" + ); +} + +#[test] +fn non_ascii_override_falls_back_to_local_git() { + let candidates = Candidates { + override_value: Some("v1-été"), + ..local(None, None) + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("abcdef"), + "should skip a non-ASCII override and fall back to local git" + ); +} + +#[test] +fn override_with_inner_space_falls_back_to_local_git() { + let candidates = Candidates { + override_value: Some("v1 2"), + ..local(None, Some("main")) + }; + assert_eq!( + resolve_git_version(&candidates).as_deref(), + Some("main"), + "should skip an override containing a space" + ); +} + +#[test] +fn none_when_nothing_is_known() { + let candidates = Candidates { + override_value: None, + exact_tag: None, + branch: None, + commit: None, + }; + assert_eq!( + resolve_git_version(&candidates), + None, + "should leave the version unset so the header is omitted" + ); +} + +#[test] +fn is_usable_accepts_only_visible_ascii() { + assert!(git_version::is_usable("v1.2.3"), "should accept a tag"); + assert!(git_version::is_usable(" main "), "should accept after trimming"); + assert!(!git_version::is_usable(""), "should reject empty"); + assert!(!git_version::is_usable("a\tb"), "should reject a control character"); + assert!(!git_version::is_usable("v1-été"), "should reject non-ASCII"); +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cargo test -p trusted-server-core --test git_version_resolve --target wasm32-wasip1` +Expected: FAIL to compile, with `couldn't read …/build_support/git_version.rs`. + +- [ ] **Step 3: Implement the pure function** + +`crates/trusted-server-core/build_support/git_version.rs`: + +```rust +//! Resolution rule for the deployed git version reported in `x-ts-version`. +//! +//! Shared by `build.rs` and `tests/git_version_resolve.rs` via `#[path]`, so it +//! must stay dependency-free. + +/// Raw candidates for the deployed git version, in priority order. +pub struct Candidates<'a> { + /// `TRUSTED_SERVER_GIT_VERSION`, supplied by the deploy pipeline. + pub override_value: Option<&'a str>, + /// `git describe --tags --exact-match`. + pub exact_tag: Option<&'a str>, + /// `git symbolic-ref --short -q HEAD`. + pub branch: Option<&'a str>, + /// `git rev-parse HEAD`. + pub commit: Option<&'a str>, +} + +/// Whether `value`, once trimmed, is non-empty visible ASCII (`0x21`–`0x7E`). +/// +/// Stricter than git, which also allows non-ASCII UTF-8 in ref names. Such a +/// ref is skipped so every compiled-in value is a `to_str()`-able header value. +pub fn is_usable(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.is_empty() && trimmed.bytes().all(|b| (0x21..=0x7E).contains(&b)) +} + +/// Picks the first usable candidate: override, tag, branch, then the first 6 +/// characters of the commit. `None` when nothing usable is known. +pub fn resolve_git_version(candidates: &Candidates<'_>) -> Option { + let usable = |value: Option<&str>| value.filter(|v| is_usable(v)).map(str::trim); + + usable(candidates.override_value) + .or_else(|| usable(candidates.exact_tag)) + .or_else(|| usable(candidates.branch)) + .map(str::to_owned) + .or_else(|| usable(candidates.commit).map(|c| c.chars().take(6).collect())) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p trusted-server-core --test git_version_resolve --target wasm32-wasip1` +Expected: 10 passed. + +- [ ] **Step 5: Wire up `build.rs`** + +Replace `crates/trusted-server-core/build.rs` with: + +```rust +//! Compiles the deployed git version into `trusted-server-core` as `TS_GIT_VERSION`. + +#[path = "build_support/git_version.rs"] +mod git_version; + +use std::path::Path; +use std::process::Command; + +use git_version::{Candidates, is_usable, resolve_git_version}; + +/// Set by the deploy pipeline. Its CI checkout is shallow and detached, so local git +/// cannot see the tag or branch there. +const OVERRIDE_ENV: &str = "TRUSTED_SERVER_GIT_VERSION"; + +/// Runs `git` in the crate directory; `None` if git is missing or fails. +fn git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_owned()) +} + +/// Re-runs this script when `path` changes. Skips missing paths: Cargo treats +/// them as always changed, which would rebuild core on every build. +fn rerun_if_exists(path: &Path) { + if path.exists() { + println!("cargo:rerun-if-changed={}", path.display()); + } +} + +/// Resolves the version from local git, watching the paths that move with it. +fn resolve_from_local_git() -> Option { + // HEAD is per-worktree; refs are shared in the common dir. They differ in a + // linked worktree and coincide in a plain clone. + if let Some(git_dir) = git(&["rev-parse", "--absolute-git-dir"]) { + rerun_if_exists(&Path::new(&git_dir).join("HEAD")); + } + if let Some(common_dir) = git(&["rev-parse", "--path-format=absolute", "--git-common-dir"]) { + let common_dir = Path::new(&common_dir); + if let Some(branch_ref) = git(&["symbolic-ref", "-q", "HEAD"]) { + rerun_if_exists(&common_dir.join(branch_ref)); + } + rerun_if_exists(&common_dir.join("refs/tags")); + rerun_if_exists(&common_dir.join("packed-refs")); + } + + let exact_tag = git(&["describe", "--tags", "--exact-match"]); + let branch = git(&["symbolic-ref", "--short", "-q", "HEAD"]); + let commit = git(&["rev-parse", "HEAD"]); + resolve_git_version(&Candidates { + override_value: None, + exact_tag: exact_tag.as_deref(), + branch: branch.as_deref(), + commit: commit.as_deref(), + }) +} + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=build_support/git_version.rs"); + println!("cargo:rerun-if-env-changed={OVERRIDE_ENV}"); + + let override_value = std::env::var(OVERRIDE_ENV).ok(); + let resolved = match override_value.as_deref() { + Some(value) if is_usable(value) => resolve_git_version(&Candidates { + override_value: Some(value), + exact_tag: None, + branch: None, + commit: None, + }), + Some(value) => { + if !value.trim().is_empty() { + println!( + "cargo:warning={OVERRIDE_ENV}={value:?} is not visible ASCII; \ + falling back to local git for x-ts-version" + ); + } + resolve_from_local_git() + } + None => resolve_from_local_git(), + }; + + if let Some(version) = resolved { + println!("cargo:rustc-env=TS_GIT_VERSION={version}"); + } +} +``` + +- [ ] **Step 6: Verify the override reaches the compile and re-runs on change** + +Run: +`TRUSTED_SERVER_GIT_VERSION=v0.0.0-check cargo build -p trusted-server-core --target wasm32-wasip1 -vv 2>&1 | grep -F 'TS_GIT_VERSION=v0.0.0-check'` +Expected: one matching `cargo:rustc-env=TS_GIT_VERSION=v0.0.0-check` line. + +Run the same command again with `v0.0.0-check2`. Expected: the build script +re-runs and prints `…check2`. + +Run: `cargo build -p trusted-server-core --target wasm32-wasip1 -vv 2>&1 | grep -F 'cargo:rerun-if-changed='` +Expected: `…/.git/worktrees//HEAD` and `…/.git/refs` (when in a linked +worktree), no `packed-refs` line unless that file exists. + +- [ ] **Step 7: Clippy and commit** + +Run: `cargo clippy-fastly` +Expected: no warnings. + +```bash +git add crates/trusted-server-core/build.rs crates/trusted-server-core/build_support/git_version.rs crates/trusted-server-core/tests/git_version_resolve.rs +git commit --signoff -S -m "Compile deployed git version into trusted-server-core as TS_GIT_VERSION" +``` + +--- + +### Task 2: Core constants and the `version_header` module + +**Files:** + +- Modify: `crates/trusted-server-core/src/constants.rs` (the `// Staging / version identification headers` block) +- Create: `crates/trusted-server-core/src/version_header.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` (add `pub mod version_header;` after `pub mod tsjs;`) + +**Interfaces:** + +- Consumes: `TS_GIT_VERSION` compile-time env (Task 1). +- Produces: `constants::HEADER_X_TS_FASTLY_VERSION: HeaderName`, `constants::TS_GIT_VERSION: Option<&'static str>`. +- Produces: `version_header::git_version_header_value() -> Option`, + `version_header::header_value_from(version: Option<&str>) -> Option`, + `version_header::apply_git_version_header(response: &mut Response)`, + `version_header::apply_git_version_header_from(version: Option<&str>, response: &mut Response)`. + `HeaderValue`/`Response` are `edgezero_core::http`'s. + +- [ ] **Step 1: Write the failing tests** + +In `constants.rs`, replace the block under `// Staging / version identification headers` with: + +```rust +// Staging / version identification headers +/// Deployed git version: tag, else branch, else 6-char commit (see [`TS_GIT_VERSION`]). +pub const HEADER_X_TS_VERSION: HeaderName = HeaderName::from_static("x-ts-version"); +/// Fastly service version (`FASTLY_SERVICE_VERSION`), formerly sent as `x-ts-version`. +pub const HEADER_X_TS_FASTLY_VERSION: HeaderName = HeaderName::from_static("x-ts-fastly-version"); +pub const HEADER_X_TS_ENV: HeaderName = HeaderName::from_static("x-ts-env"); + +/// Deployed git version compiled in by `build.rs`, from the deploy pipeline's +/// `TRUSTED_SERVER_GIT_VERSION` or local git. `None` when unknown. +pub const TS_GIT_VERSION: Option<&str> = option_env!("TS_GIT_VERSION"); +``` + +Create `crates/trusted-server-core/src/version_header.rs`: + +```rust +//! `x-ts-version`: the deployed git version compiled in by `build.rs`. + +use edgezero_core::http::{HeaderValue, Response}; + +use crate::constants::{HEADER_X_TS_VERSION, TS_GIT_VERSION}; + +/// Returns the compiled-in git version as a header value. +/// +/// `None` when no version is known or the value is not a valid header value. +#[must_use] +pub fn git_version_header_value() -> Option { + header_value_from(TS_GIT_VERSION) +} + +/// Converts `version` to a header value, logging and returning `None` when it +/// is not a valid header value. +#[must_use] +pub fn header_value_from(version: Option<&str>) -> Option { + todo!() +} + +/// Sets `x-ts-version` to the compiled-in git version, if one is known. +pub fn apply_git_version_header(response: &mut Response) { + apply_git_version_header_from(TS_GIT_VERSION, response); +} + +/// Sets `x-ts-version` to `version`, or leaves it unset when `version` is +/// unknown or invalid. +pub fn apply_git_version_header_from(version: Option<&str>, response: &mut Response) { + todo!() +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::body::Body; + use edgezero_core::http::response_builder; + + fn empty_response() -> Response { + response_builder() + .body(Body::empty()) + .expect("should build empty test response") + } + + fn version_of(response: &Response) -> Option<&str> { + response + .headers() + .get(HEADER_X_TS_VERSION) + .and_then(|v| v.to_str().ok()) + } + + #[test] + fn header_value_from_valid_version() { + assert_eq!( + header_value_from(Some("v1.2.3")), + Some(HeaderValue::from_static("v1.2.3")), + "should convert a valid version" + ); + } + + #[test] + fn header_value_from_unknown_or_invalid_version() { + assert_eq!(header_value_from(None), None, "should be None when unknown"); + assert_eq!( + header_value_from(Some("bad\nvalue")), + None, + "should be None for a non-header-safe version" + ); + } + + #[test] + fn git_version_header_value_matches_compiled_in_version() { + assert_eq!( + git_version_header_value() + .as_ref() + .and_then(|v| v.to_str().ok()), + TS_GIT_VERSION, + "should convert the compiled-in TS_GIT_VERSION" + ); + } + + #[test] + fn sets_header_from_version() { + let mut response = empty_response(); + apply_git_version_header_from(Some("v1.2.3"), &mut response); + assert_eq!(version_of(&response), Some("v1.2.3"), "should set x-ts-version"); + } + + #[test] + fn omits_header_when_version_unknown() { + let mut response = empty_response(); + apply_git_version_header_from(None, &mut response); + assert_eq!(version_of(&response), None, "should omit x-ts-version when unknown"); + } + + #[test] + fn skips_invalid_header_value() { + let mut response = empty_response(); + apply_git_version_header_from(Some("bad\nvalue"), &mut response); + assert_eq!(version_of(&response), None, "should skip a non-header-safe version"); + } + + #[test] + fn default_uses_compiled_in_version() { + let mut response = empty_response(); + apply_git_version_header(&mut response); + assert_eq!( + version_of(&response), + TS_GIT_VERSION, + "should report the compiled-in TS_GIT_VERSION" + ); + } +} +``` + +Add `pub mod version_header;` to `lib.rs` after `pub mod tsjs;`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p trusted-server-core --lib --target wasm32-wasip1 version_header` +Expected: the 7 tests FAIL, panicking with `not yet implemented`. + +- [ ] **Step 3: Implement** + +Replace the two `todo!()` bodies: + +```rust +pub fn header_value_from(version: Option<&str>) -> Option { + let version = version?; + match HeaderValue::from_str(version) { + Ok(value) => Some(value), + Err(_) => { + log::warn!("Skipping invalid TS_GIT_VERSION response header value"); + None + } + } +} +``` + +```rust +pub fn apply_git_version_header_from(version: Option<&str>, response: &mut Response) { + if let Some(value) = header_value_from(version) { + response.headers_mut().insert(HEADER_X_TS_VERSION, value); + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test -p trusted-server-core --lib --target wasm32-wasip1 version_header` +Expected: 7 passed. + +- [ ] **Step 5: Clippy and commit** + +Run: `cargo clippy-fastly` +Expected: no warnings. + +```bash +git add crates/trusted-server-core/src/constants.rs crates/trusted-server-core/src/version_header.rs crates/trusted-server-core/src/lib.rs +git commit --signoff -S -m "Add x-ts-fastly-version constant and git version header helpers" +``` + +--- + +### Task 3: Fastly adapter: split the headers and cover `/health` + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/middleware.rs` (imports, the two header-order doc comments, the `FASTLY_SERVICE_VERSION` block in `apply_finalize_headers`, tests) +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (`health_response`, tests) + +**Interfaces:** + +- Consumes: `HEADER_X_TS_FASTLY_VERSION`, `HEADER_X_TS_VERSION`, `TS_GIT_VERSION`, + `version_header::{apply_git_version_header, git_version_header_value}` (Task 2). + +- [ ] **Step 1: Write the failing tests** + +Add to `mod tests` in `middleware.rs`: + +```rust + #[test] + fn version_headers_split_git_and_fastly_versions() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + + apply_finalize_headers(&settings, None, &mut response); + + let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()); + assert_eq!( + header("x-ts-version"), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version as x-ts-version" + ); + assert_eq!( + header("x-ts-fastly-version"), + std::env::var(ENV_FASTLY_SERVICE_VERSION).ok().as_deref(), + "should report FASTLY_SERVICE_VERSION as x-ts-fastly-version" + ); + } +``` + +Add to `mod tests` in `main.rs`: + +```rust + #[test] + fn health_response_reports_git_version_only() { + let req = FastlyRequest::get("https://example.com/health"); + + let response = health_response(&req).expect("should build health response"); + + assert_eq!( + response.get_header_str("x-ts-version"), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version on /health" + ); + assert!( + response.get_header("x-ts-fastly-version").is_none(), + "should keep x-ts-fastly-version off the /health fast path" + ); + } +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cargo test-fastly version_headers_split_git_and_fastly_versions health_response_reports_git_version_only` +Expected: both FAIL on the `x-ts-version` assertion. Local builds have git, so +`TS_GIT_VERSION` is `Some(…)`, but neither path sets it yet. + +If `cargo test` rejects two filters in this toolchain, run each name separately. + +- [ ] **Step 3: Implement** + +`middleware.rs` imports: + +```rust +use trusted_server_core::constants::{ + ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, + HEADER_X_TS_ENV, HEADER_X_TS_FASTLY_VERSION, +}; +use trusted_server_core::version_header::apply_git_version_header; +``` + +Replace the `FASTLY_SERVICE_VERSION` block in `apply_finalize_headers` with: + +```rust + apply_git_version_header(response); + + if let Ok(v) = std::env::var(ENV_FASTLY_SERVICE_VERSION) { + if let Ok(value) = HeaderValue::from_str(&v) { + response + .headers_mut() + .insert(HEADER_X_TS_FASTLY_VERSION, value); + } else { + log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); + } + } +``` + +In **both** header-order doc comments (on `FinalizeResponseMiddleware` and on +`apply_finalize_headers`), replace +``2. `X-TS-Version` from `FASTLY_SERVICE_VERSION` env var`` with: + +```rust +/// 2. `X-TS-Version` from the compiled-in git version (`TS_GIT_VERSION`), and +/// `X-TS-Fastly-Version` from the `FASTLY_SERVICE_VERSION` env var +``` + +`main.rs` — add imports: + +```rust +use trusted_server_core::constants::HEADER_X_TS_VERSION; +use trusted_server_core::version_header::git_version_header_value; +``` + +and replace `health_response` with: + +```rust +fn health_response(req: &FastlyRequest) -> Option { + if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { + let mut response = FastlyResponse::from_status(200).with_body_text_plain("ok"); + // Compiled-in constant: keeps the probe free of settings and app construction. + if let Some(version) = git_version_header_value() { + response.set_header(HEADER_X_TS_VERSION, version); + } + return Some(response); + } + + None +} +``` + +- [ ] **Step 4: Run the adapter tests and clippy** + +Run: `cargo test-fastly && cargo clippy-fastly` +Expected: all tests pass. Clippy reports no warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/middleware.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit --signoff -S -m "Send git version as x-ts-version and Fastly version as x-ts-fastly-version on Fastly" +``` + +--- + +### Task 4: Axum, Cloudflare, Spin: emit `x-ts-version` + +**Files:** + +- Modify: `crates/trusted-server-adapter-axum/src/middleware.rs` (the `FinalizeResponseMiddleware` and `apply_finalize_headers` doc comments, `apply_finalize_headers`, tests) +- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` (new `/health` test) +- Modify: `crates/trusted-server-adapter-cloudflare/src/middleware.rs` (`apply_finalize_headers`, tests) +- Modify: `crates/trusted-server-adapter-spin/src/middleware.rs` (`apply_finalize_headers`, tests) +- Modify: `crates/trusted-server-adapter-spin/src/app.rs` (`startup_error_router_answers_health_with_200`) + +**Interfaces:** + +- Consumes: `version_header::apply_git_version_header`, `constants::TS_GIT_VERSION` (Task 2). + +- [ ] **Step 1: Write the failing tests** + +Axum `middleware.rs` `mod tests` (signature `apply_finalize_headers(&Settings, &mut Response)`): + +```rust + #[test] + fn emits_git_version_header() { + let mut response = empty_response(); + apply_finalize_headers(&settings_with_response_headers(vec![]), &mut response); + assert_eq!( + response.headers().get("x-ts-version").and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version as x-ts-version" + ); + } +``` + +Cloudflare and Spin `middleware.rs` `mod tests` (signature +`apply_finalize_headers(&Settings, bool, &mut Response)`), in each: + +```rust + #[test] + fn emits_git_version_header() { + let mut response = empty_response(); + apply_finalize_headers(&settings_with_response_headers(vec![]), false, &mut response); + assert_eq!( + response.headers().get("x-ts-version").and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version as x-ts-version" + ); + } +``` + +Axum `tests/routes.rs`: + +```rust +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn health_reports_git_version() { + let mut service = make_service(); + let request = Request::builder() + .method("GET") + .uri("/health") + .body(AxumBody::empty()) + .expect("should build health request"); + + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should serve health"); + + assert_eq!(response.status().as_u16(), 200, "should return 200 on /health"); + assert_eq!( + response + .headers() + .get("x-ts-version") + .and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "should report the compiled-in git version on /health" + ); +} +``` + +Spin `app.rs`, at the end of `startup_error_router_answers_health_with_200`, +capture the header before `resp.into_body()` consumes the response. Insert +directly after the status assertion: + +```rust + assert_eq!( + resp.headers() + .get("x-ts-version") + .and_then(|v| v.to_str().ok()), + trusted_server_core::constants::TS_GIT_VERSION, + "startup-fallback /health should report the compiled-in git version" + ); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cargo test-axum emits_git_version_header; cargo test-axum --test routes health_reports_git_version; cargo test-cloudflare emits_git_version_header; cargo test-spin emits_git_version_header; cargo test-spin startup_error_router_answers_health_with_200` +Expected: each FAILs, with left `None` and right `Some(…)`. + +- [ ] **Step 3: Implement** + +In each of the three `apply_finalize_headers` functions, directly after the +`HEADER_X_GEO_INFO_AVAILABLE` insert and before +`apply_response_headers_with_cache_privacy`, add: + +```rust + trusted_server_core::version_header::apply_git_version_header(response); +``` + +In the Axum `FinalizeResponseMiddleware` doc comment, replace +``is always emitted. Fastly-specific headers (`X-TS-Version`, `X-TS-ENV`) are`` +and the following line with: + +```rust +/// is always emitted. `X-TS-Version` carries the compiled-in git version. The +/// Fastly-specific headers (`X-TS-Fastly-Version`, `X-TS-ENV`) are skipped +/// because the corresponding env vars are not set in a local dev context. +``` + +In the Axum `apply_finalize_headers` doc comment, replace +`is unconditionally emitted. Fastly-specific headers are omitted.` with: + +```rust +/// is unconditionally emitted, followed by the compiled-in `X-TS-Version`. +/// Fastly-specific headers are omitted. +``` + +- [ ] **Step 4: Run the tests and clippy** + +Run: `cargo test-axum && cargo test-cloudflare && cargo test-spin && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm` +Expected: all pass, with no warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-adapter-axum crates/trusted-server-adapter-cloudflare/src/middleware.rs crates/trusted-server-adapter-spin/src +git commit --signoff -S -m "Emit x-ts-version git version on Axum, Cloudflare, and Spin" +``` + +--- + +### Task 5: Docs and changelog + +**Files:** + +- Modify: `docs/guide/first-party-proxy.md` (`[response_headers]` example, around line 719) +- Modify: `CHANGELOG.md` (`## [Unreleased]` → `### Changed`) + +- [ ] **Step 1: Stop the docs example from overriding the managed header** + +In `docs/guide/first-party-proxy.md`, replace `X-TS-Version = "1.0"` with: + +```toml +X-Debug-Build = "canary" +``` + +- [ ] **Step 2: Changelog entry** + +Add as the first bullet under `## [Unreleased]` → `### Changed`: + +```markdown +- **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER_GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. +``` + +- [ ] **Step 3: Format checks** + +Run: `cd docs && npm run format` then `git diff --stat` to confirm only intended files changed. +Expected: no unrelated reformatting. + +- [ ] **Step 4: Commit** + +```bash +git add docs/guide/first-party-proxy.md CHANGELOG.md +git commit --signoff -S -m "Document the x-ts-version and x-ts-fastly-version split" +``` + +--- + +### Task 6: Offline end-to-end verification under Viceroy + +No repository changes. Record every command and its observed output for the +PR test plan. Work in the scratchpad directory (`$SCRATCH` below). + +- [ ] **Step 1: Override build, served under Viceroy** + +```bash +TRUSTED_SERVER_GIT_VERSION=v9.9.9-test cargo build --release -p trusted-server-adapter-fastly --target wasm32-wasip1 +``` + +Serve it with a pushed local config, reusing the setup in +`scripts/smoke-fastly.sh` (`smoke-common.sh`: stub origin, +`smoke_initialize_config`, `ts config push --adapter fastly --local`, the three +`ts_secrets` entries, then `fastly compute serve --dir --file `). +Then: + +```bash +curl -s -o /dev/null -D - http://127.0.0.1:$PORT/health | grep -iE '^HTTP|^x-ts-' +curl -s -o /dev/null -D - http://127.0.0.1:$PORT/ | grep -iE '^HTTP|^x-ts-|^x-geo-info' +``` + +Expected: both `200`. `/health` shows only `x-ts-version: v9.9.9-test`. `/` +shows `x-ts-version: v9.9.9-test`, `x-ts-fastly-version: `, and +`x-geo-info-available` (proof it went through `apply_finalize_headers`). Record +Viceroy's `FASTLY_SERVICE_VERSION`. No `x-ts-version` is numeric. + +- [ ] **Step 2: Warm-cache rebuild with a changed value** + +```bash +TRUSTED_SERVER_GIT_VERSION=v9.9.9-test2 cargo build --release -p trusted-server-adapter-fastly --target wasm32-wasip1 -vv 2>&1 | grep -F 'TS_GIT_VERSION=' +``` + +Expected: `cargo:rustc-env=TS_GIT_VERSION=v9.9.9-test2`. Re-serve and curl +`/health`: `x-ts-version: v9.9.9-test2`. + +- [ ] **Step 3: No-env fallback** + +Rebuild with `TRUSTED_SERVER_GIT_VERSION` unset on branch +`feat/git-version-header`: `/health` shows `x-ts-version: feat/git-version-header`. +Then `git switch --detach` and rebuild: `x-ts-version` is the first 6 chars of +`git rev-parse HEAD`. Switch back to the branch afterwards. + +- [ ] **Step 4: Non-git build** + +```bash +git archive --format=tar HEAD | (mkdir -p "$SCRATCH/nogit" && tar -x -C "$SCRATCH/nogit") +cd "$SCRATCH/nogit" && env -u TRUSTED_SERVER_GIT_VERSION cargo build --release -p trusted-server-adapter-fastly --target wasm32-wasip1 +``` + +Confirm `$SCRATCH` is not inside a git repository first +(`git -C "$SCRATCH/nogit" rev-parse` fails). Expected: the build succeeds; +serving it, `/health` has no `x-ts-version`. + +--- + +### Task 7: Full CI gate + +- [ ] **Step 1: Run every gate from `CLAUDE.md`** + +```bash +cargo fmt --all -- --check +cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-cloudflare-wasm && cargo clippy-spin-native && cargo clippy-spin-wasm && cargo clippy-cli && cargo clippy-codegen +cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +(cd crates/trusted-server-js/lib && npx vitest run && npm run format) +(cd docs && npm run format) +``` + +Expected: all green, and `git status` clean afterwards (format scripts may +rewrite files; commit any intended changes). diff --git a/docs/superpowers/specs/2026-09-25-git-version-header-design.md b/docs/superpowers/specs/2026-09-25-git-version-header-design.md new file mode 100644 index 000000000..f8e09dd83 --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-git-version-header-design.md @@ -0,0 +1,392 @@ +# Deployed git version in `x-ts-version` + +**Issues:** not filed yet + +**Date:** 2026-09-25 + +**Plan:** [../plans/2026-09-25-git-version-header.md](../plans/2026-09-25-git-version-header.md) + +**Status:** Pending maintainer review + +## Problem + +Nothing in a Trusted Server response says which Trusted Server code is +running. The Fastly adapter does send `x-ts-version`, but it fills it from +`FASTLY_SERVICE_VERSION`. That is the Fastly service version number (for +example `42`). It changes on every activation, config-only or not, and it +cannot be mapped back to a commit without the Fastly console. The name suggests +a Trusted Server version, but the value is a Fastly one. + +This matters for deploy pipelines that restrict production to published +releases. Operators and +monitors need to see from a response that production runs a release, and which +one. Staging keeps deploying branches and commits, so it needs to be +identifiable too. + +## Goals + +- Report the deployed git version in `x-ts-version`: the tag when the deployed + ref is a tag, else the branch name, else the first 6 characters of the commit + hash. +- Keep the Fastly service version available under a correct name, + `x-ts-fastly-version`. +- Make the value follow the Wasm binary. A Fastly rollback to an earlier + version must report that version's git version, with no extra step. +- Accept the value from the deploy pipeline, and fall back to local git so + local and ad-hoc builds still report something useful. +- Send `x-ts-version` from every adapter, since it does not depend on the + platform, including on the Fastly `GET /health` fast path that deploy health + checks probe right after a deploy. + +## Non-goals + +- Do not change `x-ts-env` or how staging is detected. +- Do not mark dirty working trees. The local fallback reports the tag or branch + even when the tree has uncommitted changes, so a local build of a modified + `v1.3.0` checkout reports `v1.3.0`. CI checkouts are always clean; a local + header is not proof of a clean release build. +- Do not normalize tag names (for example to semver). Report the ref as + deployed, subject only to the header-safety rule in §2. +- Do not add a runtime or versionless source for the version, such as the + `edgezero_runtime_env` Config Store or a config-blob field. +- Do not change the precedence of operator `settings.response_headers`. Whether + managed `x-ts-*` headers should be protected from operator overrides is a + separate decision (see Open Questions). +- Do not add `x-ts-fastly-version` to the Fastly `/health` fast path. It stays + minimal and only gains the compiled-in `x-ts-version`. +- Do not implement a production release gate or the pipeline-side version + resolution here. Those belong to the deploy pipeline; this repository only + defines the `TRUSTED_SERVER_GIT_VERSION` build input. + +## Current behavior + +`crates/trusted-server-core/src/constants.rs` defines `HEADER_X_TS_VERSION` +(`x-ts-version`), `HEADER_X_TS_ENV`, and the env names +`ENV_FASTLY_SERVICE_VERSION` and `ENV_FASTLY_IS_STAGING`. + +`apply_finalize_headers` in +`crates/trusted-server-adapter-fastly/src/middleware.rs` reads +`FASTLY_SERVICE_VERSION` from the Compute runtime env and inserts it as +`x-ts-version`. It skips an invalid value with a warning. Two doc comments on the +header write order describe this (on `FinalizeResponseMiddleware` and on +`apply_finalize_headers`). + +`health_response` in `crates/trusted-server-adapter-fastly/src/main.rs` answers +`GET /health` before logging, settings, app construction, and middleware, so it +carries no `x-ts-*` header at all. + +The Axum, Cloudflare, and Spin `apply_finalize_headers` functions do not send +any version header. The Axum middleware doc comment calls `X-TS-Version` +Fastly-specific. Axum and Spin register `/health` as router routes, so their +`FinalizeResponseMiddleware` already runs on it. Cloudflare has no `/health` +route; the path falls through to the publisher fallback, which also runs +through its middleware. + +`crates/trusted-server-core/build.rs` only prints `rerun-if-changed=build.rs`. +The workspace compiles in no git metadata. + +`docs/guide/first-party-proxy.md` shows `X-TS-Version = "1.0"` as an example +`[response_headers]` entry. Operator headers apply last, so copying that example +overwrites the managed value. + +## Design + +### 1. The value is compiled in + +Fastly Compute only exposes platform variables (`FASTLY_SERVICE_VERSION`, +`FASTLY_IS_STAGING`, and so on) through the process env at runtime. There is no +way to add a deploy-time variable. The only EdgeZero channel for runtime values +is the `edgezero_runtime_env` Config Store. It is versionless and holds only +`EDGEZERO__*` store mappings, so a Fastly rollback would leave the newer +deploy's version in it. + +So the git version is compiled into the binary. A compiled-in value is +immutable per Fastly version, so it always matches the code being served, +including after a rollback. + +### 2. Resolution order + +`crates/trusted-server-core/build.rs` resolves the version once per build and +emits it as the rustc env `TS_GIT_VERSION`: + +1. `TRUSTED_SERVER_GIT_VERSION` from the build environment, if it is usable + (see below). The deploy pipeline sets this. +2. Otherwise the exact tag at `HEAD`, from `git describe --tags --exact-match`. +3. Otherwise the current branch, from `git symbolic-ref --short -q HEAD`. +4. Otherwise the first 6 characters of `git rev-parse HEAD`. Take exactly 6 + characters. Do not use `--short`, which may lengthen the hash to keep it + unique. +5. Otherwise leave it unset. + +A candidate is **usable** when, after trimming surrounding whitespace, it is +non-empty and consists only of visible ASCII (bytes `0x21`–`0x7E`). An +unusable candidate is skipped and the next step is tried. + +This is stricter than git. `git check-ref-format` forbids control characters, +space, and `~^:?*[\`, but it allows non-ASCII UTF-8, so a tag such as `v1-été` +is a valid ref, and a pipeline may pass such a ref through. Under this rule it +is skipped: `build.rs` prints a `cargo:warning` naming +`TRUSTED_SERVER_GIT_VERSION`, and resolution falls back to local git. In a CI +checkout that yields the 6-char hash, which still identifies the code. Falling +back was preferred over omitting the header, because a missing header is harder +to diagnose. The visible-ASCII rule keeps every compiled-in value a valid +`HeaderValue` whose `to_str()` succeeds, so consumers never see opaque bytes. + +The deploy pipeline must supply the value because CI checkouts are shallow and +detached (`actions/checkout`, `fetch-depth: 1`, no tags). In such a checkout +steps 2 and 3 always fail, and only the hash is available. The pipeline knows +the ref the operator asked for and can resolve the tag or branch against +`origin` before the build. + +The choice between these candidates is a pure function in +`crates/trusted-server-core/build_support/git_version.rs`. `build.rs` and an +integration test both include it through `#[path]`, so the rule is unit-tested +without running a build script. + +### 3. Build caching + +`build.rs` prints `cargo:rerun-if-env-changed=TRUSTED_SERVER_GIT_VERSION`. +CI deploys commonly restore a cached `target/` (EdgeZero's `deploy-fastly` +action does). Without this line a warm cache could keep the previous deploy's +version. + +It also prints `cargo:rerun-if-changed` for `build.rs` and +`build_support/git_version.rs`, so editing the resolver re-runs the script. + +When it uses the local git fallback, `build.rs` also prints `rerun-if-changed` +for the paths that move when the checkout does, so local branch switches and +new tags show up without `cargo clean`: + +- `/HEAD`, from `git rev-parse --absolute-git-dir`; +- the current branch's ref file, `/` (for + example `refs/heads/feature/x`), only when `HEAD` is on a branch; +- `/refs/tags` and `/packed-refs`, from + `git rev-parse --git-common-dir`. + +The current branch's ref matters because a new commit on a tagged branch must +switch the result from the tag to the branch name, so a commit on the current +branch re-runs the script and recompiles core in local builds. That is +inherent to exact-tag detection. Commits on other branches and `refs/remotes` +are deliberately not watched: they never change the result, and every +`git fetch` rewrites `refs/remotes`. + +The two directories differ in a linked worktree: `HEAD` lives in +`.git/worktrees/`, while refs live in the main `.git`. In a plain clone +they are the same directory. + +Only paths that exist are printed. Cargo treats a missing `rerun-if-changed` +path as changed, so printing an absent `packed-refs` would re-run the script +and recompile core on every build. The accepted cost: a `packed-refs` file +created after the last run is not noticed until something else triggers a +re-run. Watching the branch ref and `refs/tags` still catches new loose refs. If the current branch's ref exists only in `packed-refs` (after `git pack-refs`), its nearest existing ancestor below the common dir (usually `refs/heads`) is watched instead, so the loose ref written by the next commit still re-runs the script. The tradeoff: until that loose ref exists, commits on sibling branches also re-run it. + +### 4. Headers + +Constants in `trusted-server-core::constants`: + +- `HEADER_X_TS_VERSION` keeps the name `x-ts-version`, which now means the git + version. +- Add `HEADER_X_TS_FASTLY_VERSION`, named `x-ts-fastly-version`. +- Add `TS_GIT_VERSION: Option<&str> = option_env!("TS_GIT_VERSION")`. + +A new module, `trusted-server-core::version_header`, owns the value and the +write: + +- `git_version_header_value() -> Option` converts + `TS_GIT_VERSION`. It returns `None` when no version is known, or when the + value is not a valid header value (logged at `warn`). §2 makes the latter + unreachable for values from `build.rs`, but the check stays as a backstop. +- `apply_git_version_header(response)` inserts `x-ts-version` from + `git_version_header_value()`. +- Testable variants take the version as an argument. + +Keeping this in core means all four adapters, and the Fastly health fast path, +use the same rule. + +Fastly `apply_finalize_headers` calls `apply_git_version_header` and writes +`FASTLY_SERVICE_VERSION` to `x-ts-fastly-version` instead of `x-ts-version`. +Axum, Cloudflare, and Spin `apply_finalize_headers` call +`apply_git_version_header` right after the geo-availability header. In every +adapter the call runs before `apply_response_headers_with_cache_privacy`, so the +existing order is kept: operator headers still apply last. + +Fastly `health_response` sets `x-ts-version` from `git_version_header_value()`. +The `fastly` crate's `set_header` accepts the `http` 1.x `HeaderValue` that +`edgezero_core::http` re-exports, so no conversion is needed and nothing can +panic. The fast path stays free of settings, logging, and app construction. + +A Fastly production response from release `v1.3.0`, served as Fastly version 42: + +```text +x-ts-version: v1.3.0 +x-ts-fastly-version: 42 +``` + +A staged deploy of branch `feature/x`: + +```text +x-ts-version: feature/x +x-ts-fastly-version: 43 +x-ts-env: staging +``` + +`GET /health` on either: + +```text +x-ts-version: v1.3.0 +``` + +## Error Handling + +`build.rs` never fails the build over version metadata. If git is missing, the +directory is not a repository (for example an exported source tree), or git +fails, the build leaves `TS_GIT_VERSION` unset and `x-ts-version` is omitted. +The same applies when git's top level (`git rev-parse --show-toplevel`) is not +the workspace root: git searches parent directories, so an exported tree nested +in an unrelated repository would otherwise report that repository's tag or +branch. When the version is unknown, an `x-ts-version` already on the response +(for example from a proxied origin) is removed rather than passed through. +Sending a placeholder such as `unknown` was rejected: a missing header is easier +to tell apart from a real ref. + +An unusable `TRUSTED_SERVER_GIT_VERSION` (§2) produces a `cargo:warning` and +falls back to local git. It does not fail the build. + +No new public error type is introduced. + +## Compatibility and Rollout + +This is a **breaking change** for anything that reads `x-ts-version` as the +Fastly version number. Dashboards, monitors, and scripts must switch to +`x-ts-fastly-version`. The CHANGELOG entry calls this out. + +The rollout does not depend on the deploy pipeline: + +- A pipeline that does not set `TRUSTED_SERVER_GIT_VERSION`: CI deploys report + the 6-char hash from the local fallback. This is still correct, just less + readable. +- A pipeline that sets it before this change ships: the variable is in the + build env but not read, so behavior is unchanged. + +Rollback needs nothing extra. Reactivating an earlier Fastly version serves that +version's compiled-in value. + +## Alternatives and Decision + +- **`edgezero_runtime_env` Config Store.** Rejected. It is versionless, so it is + wrong after a rollback. It holds only `EDGEZERO__*` mappings, and writing it + per deploy would need EdgeZero changes. +- **Operator `[response_headers]` in the pushed config blob.** Rejected. Config + push is versionless and not reverted by rollback, and it depends on every + operator keeping it in sync. +- **Local git only, in `build.rs`.** Rejected as the only source. CI checkouts + are shallow and detached, so it would always produce the hash and never the + tag or branch. It stays as the fallback. +- **A new header, keeping `x-ts-version` as the Fastly version.** Rejected. The + existing name is the one people expect to carry the Trusted Server version, + and keeping it wrong would keep misleading them. +- **Omitting the header on an unusable override.** Rejected in favor of falling + back to local git (§2). + +Decision: compile in the pipeline-supplied value, with a local git fallback. + +## Testing + +Unit and integration tests follow red-green-refactor and cover: + +- resolution: override wins; tag beats branch; branch when untagged; exactly 6 + characters of the commit; blank candidates skipped; a non-ASCII override + (`v1-été`) and one with an inner space are skipped in favor of local git; + `None` when nothing is known; +- `version_header`: value from a version; `None` when unknown; `None` for an + invalid value; the header is set, omitted, or skipped accordingly; an + upstream `x-ts-version` is replaced by a valid version and removed when the + version is unknown or invalid; the + default path reports the compiled-in `TS_GIT_VERSION`; +- Fastly `apply_finalize_headers`: `x-ts-version` equals `TS_GIT_VERSION`, and + `x-ts-fastly-version` equals `FASTLY_SERVICE_VERSION` when set; +- Fastly `health_response`: `x-ts-version` equals `TS_GIT_VERSION`, and + `x-ts-fastly-version` is absent; +- Axum, Cloudflare, and Spin `apply_finalize_headers`: `x-ts-version` equals + `TS_GIT_VERSION`; +- Axum and Spin `GET /health` through the router: `x-ts-version` equals + `TS_GIT_VERSION`. + +End-to-end, offline, with Viceroy and no live Fastly service: + +1. Build the release Wasm with `TRUSTED_SERVER_GIT_VERSION=v9.9.9-test`, run it + under `fastly compute serve --file `, and `curl -sI` both `GET /health` + and a route that passes through `FinalizeResponseMiddleware`, with a local + `trusted_server_config` pushed as `scripts/smoke-fastly.sh` does so the route + loads settings. Assert the status codes (`200` for both) so the check cannot + pass on an error page. Both carry + `x-ts-version: v9.9.9-test`. The finalized route carries + `x-ts-fastly-version` with whatever Viceroy reports, and no response carries + a numeric `x-ts-version`. +2. Warm-cache rebuild, without cleaning, with `v9.9.9-test2`. The build script + re-runs (visible in `cargo build -vv`), and the header changes. +3. Rebuild with the variable unset, on a branch and then on a detached `HEAD`. + The header reports the branch, then the 6-char hash. +4. `git archive` the tree into a scratch directory outside any repository and + build there with the variable unset. The build succeeds and `x-ts-version` + is absent. + +Final verification runs every CI gate in `CLAUDE.md`: `cargo fmt --all -- +--check`; all eight clippy aliases; the Fastly, Axum, Cloudflare, and Spin test +aliases; the parity integration test; and the JS and docs format checks. The +handoff reports the commands run and their observed results. + +## Documentation + +- In `docs/guide/first-party-proxy.md`, replace the `X-TS-Version = "1.0"` + `[response_headers]` example with a header that is not managed + (`X-Debug-Build = "canary"`). +- Add a breaking-change entry to `CHANGELOG.md` under `## [Unreleased]` → + `### Changed`. +- Update the header write-order doc comments in the Fastly middleware, and the + Axum middleware comment that calls `X-TS-Version` Fastly-specific. + +## Acceptance Criteria + +- A Fastly deploy built with `TRUSTED_SERVER_GIT_VERSION=v1.3.0` returns + `x-ts-version: v1.3.0` and `x-ts-fastly-version: ` on + finalized responses, and `x-ts-version: v1.3.0` on `GET /health`. +- A staging deploy of branch `feature/x` returns `x-ts-version: feature/x`. A + deploy of a bare SHA returns the first 6 characters of that commit. +- A local build without the override reports the local tag, branch, or 6-char + hash, in that order of preference, including from a linked worktree. +- A build with neither the override nor git omits `x-ts-version` and still + succeeds. +- An override that is not visible ASCII produces a `cargo:warning` and the + local git fallback. +- Changing `TRUSTED_SERVER_GIT_VERSION` between builds changes the header even + with a warm `target/` cache. +- Axum, Cloudflare, and Spin responses carry `x-ts-version`. +- No response carries the Fastly service version as `x-ts-version`. + +## Open Questions + +- Should operator `settings.response_headers` be able to override + `x-ts-version` and `x-ts-fastly-version`? This spec keeps current precedence. + Protecting managed diagnostic headers, as cache-control is already protected + on uncacheable responses, can follow separately. + +## Expected Files + +- `crates/trusted-server-core/build.rs` +- `crates/trusted-server-core/build_support/git_version.rs` +- `crates/trusted-server-core/tests/git_version_resolve.rs` +- `crates/trusted-server-core/src/constants.rs` +- `crates/trusted-server-core/src/version_header.rs` +- `crates/trusted-server-core/src/lib.rs` +- `crates/trusted-server-adapter-fastly/src/main.rs` +- `crates/trusted-server-adapter-fastly/src/middleware.rs` +- `crates/trusted-server-adapter-axum/src/middleware.rs` +- `crates/trusted-server-adapter-axum/tests/routes.rs` +- `crates/trusted-server-adapter-cloudflare/src/middleware.rs` +- `crates/trusted-server-adapter-spin/src/middleware.rs` +- `crates/trusted-server-adapter-spin/src/app.rs` +- `docs/guide/first-party-proxy.md` +- `CHANGELOG.md` +- `docs/superpowers/specs/2026-09-25-git-version-header-design.md` +- `docs/superpowers/plans/2026-09-25-git-version-header.md`