fix(ci): staging builds resolve to prod API URL — bake VITE vars into build.yml (#1371)

This commit is contained in:
YellowSnnowmann
2026-05-08 19:04:00 -07:00
committed by GitHub
parent 0a749f5cec
commit dcd4f97f00
2 changed files with 140 additions and 16 deletions
+5
View File
@@ -68,6 +68,11 @@ jobs:
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles deb
env:
NODE_ENV: production
# CI builds should point at staging, not production.
# Without these, APP_ENV is undefined in config.ts and
# DEFAULT_BACKEND_URL falls through to api.tinyhumans.ai.
VITE_OPENHUMAN_APP_ENV: staging
VITE_BACKEND_URL: https://staging-api.tinyhumans.ai
CARGO_PROFILE_RELEASE_OPT_LEVEL: "1"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
CARGO_PROFILE_RELEASE_LTO: "false"
+135 -16
View File
@@ -11,10 +11,12 @@ pub const VITE_APP_ENV_VAR: &str = "VITE_OPENHUMAN_APP_ENV";
/// Resolves the hosted API base URL (no path suffix).
///
/// Order: non-empty `api_url` from config → [`api_base_from_env`] (`BACKEND_URL`, then `VITE_BACKEND_URL`)
/// → [`default_api_base_url_for_env`](`app_env_from_env().as_deref()`), which selects the
/// environment-aware default host based on the current app environment instead of always using
/// [`DEFAULT_API_BASE_URL`].
/// Order:
/// 1. Non-empty `api_url` from config (user explicitly set it)
/// 2. `BACKEND_URL` / `VITE_BACKEND_URL` runtime env vars (each checked independently)
/// 3. `BACKEND_URL` / `VITE_BACKEND_URL` baked in at compile time via `option_env!`
/// 4. Environment-aware default: `app_env_from_env()` == `staging` →
/// [`DEFAULT_STAGING_API_BASE_URL`], otherwise [`DEFAULT_API_BASE_URL`]
pub fn effective_api_url(api_url: &Option<String>) -> String {
if let Some(u) = api_url.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
return normalize_api_base_url(u);
@@ -30,22 +32,67 @@ pub fn normalize_api_base_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
/// Resolve API base from process environment (`BACKEND_URL` first, then `VITE_BACKEND_URL`).
/// Resolve API base URL from the environment.
///
/// Each key is checked independently so that an empty `BACKEND_URL` does not
/// shadow a valid `VITE_BACKEND_URL`. Runtime vars are checked first, then
/// compile-time values baked in via `option_env!`. The compile-time path is
/// what makes a shipped DMG/installer resolve to the correct environment —
/// at runtime the process has no shell env vars set.
pub fn api_base_from_env() -> Option<String> {
std::env::var("BACKEND_URL")
.or_else(|_| std::env::var("VITE_BACKEND_URL"))
.ok()
.map(|s| normalize_api_base_url(&s))
.filter(|s| !s.is_empty())
// 1. Runtime — each key checked independently; empty values are skipped
// so VITE_BACKEND_URL is still reachable when BACKEND_URL="" is set.
for key in ["BACKEND_URL", "VITE_BACKEND_URL"] {
if let Ok(v) = std::env::var(key) {
let url = normalize_api_base_url(&v);
if !url.is_empty() {
return Some(url);
}
}
}
// 2. Compile-time fallback — baked in by build-desktop.yml.
// Each key checked independently for the same reason as above.
for v in [option_env!("BACKEND_URL"), option_env!("VITE_BACKEND_URL")]
.into_iter()
.flatten()
{
let url = normalize_api_base_url(v);
if !url.is_empty() {
return Some(url);
}
}
None
}
/// Resolve the app environment from process environment.
/// Resolve the app environment, checking runtime env first then compile-time.
///
/// Each key is checked independently so that an empty primary key does not
/// shadow a valid secondary key. The compile-time fallback (`option_env!`)
/// mirrors what the Tauri shell already does for its Sentry environment tag.
pub fn app_env_from_env() -> Option<String> {
std::env::var(APP_ENV_VAR)
.or_else(|_| std::env::var(VITE_APP_ENV_VAR))
.ok()
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
// 1. Runtime — each key checked independently
for key in [APP_ENV_VAR, VITE_APP_ENV_VAR] {
if let Ok(v) = std::env::var(key) {
let s = v.trim().to_ascii_lowercase();
if !s.is_empty() {
return Some(s);
}
}
}
// 2. Compile-time fallback — each key checked independently
for v in [
option_env!("OPENHUMAN_APP_ENV"),
option_env!("VITE_OPENHUMAN_APP_ENV"),
]
.into_iter()
.flatten()
{
let s = v.trim().to_ascii_lowercase();
if !s.is_empty() {
return Some(s);
}
}
None
}
pub fn is_staging_app_env(app_env: Option<&str>) -> bool {
@@ -62,8 +109,14 @@ pub fn default_api_base_url_for_env(app_env: Option<&str>) -> &'static str {
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use super::*;
// Serialise all env-mutating tests to prevent flaky failures under
// parallel test execution (std::env is process-global).
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[test]
fn staging_app_env_uses_staging_default_api() {
assert_eq!(
@@ -82,4 +135,70 @@ mod tests {
assert_eq!(default_api_base_url_for_env(None), DEFAULT_API_BASE_URL);
assert!(!is_staging_app_env(Some("development")));
}
#[test]
fn app_env_from_env_reads_runtime_var() {
let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap();
let key = APP_ENV_VAR;
let prev = std::env::var(key).ok();
std::env::set_var(key, "staging");
let result = app_env_from_env();
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert_eq!(result.as_deref(), Some("staging"));
}
#[test]
fn app_env_from_env_falls_through_empty_primary_to_secondary() {
let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap();
let prev_primary = std::env::var(APP_ENV_VAR).ok();
let prev_secondary = std::env::var(VITE_APP_ENV_VAR).ok();
std::env::set_var(APP_ENV_VAR, ""); // empty — must not block secondary
std::env::set_var(VITE_APP_ENV_VAR, "staging");
let result = app_env_from_env();
match prev_primary {
Some(v) => std::env::set_var(APP_ENV_VAR, v),
None => std::env::remove_var(APP_ENV_VAR),
}
match prev_secondary {
Some(v) => std::env::set_var(VITE_APP_ENV_VAR, v),
None => std::env::remove_var(VITE_APP_ENV_VAR),
}
assert_eq!(result.as_deref(), Some("staging"));
}
#[test]
fn api_base_from_env_reads_runtime_var() {
let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap();
let key = "BACKEND_URL";
let prev = std::env::var(key).ok();
std::env::set_var(key, "https://staging-api.tinyhumans.ai/");
let result = api_base_from_env();
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
assert_eq!(result.as_deref(), Some("https://staging-api.tinyhumans.ai"));
}
#[test]
fn api_base_from_env_falls_through_empty_primary_to_secondary() {
let _guard = ENV_LOCK.get_or_init(Mutex::default).lock().unwrap();
let prev_primary = std::env::var("BACKEND_URL").ok();
let prev_secondary = std::env::var("VITE_BACKEND_URL").ok();
std::env::set_var("BACKEND_URL", ""); // empty — must not block secondary
std::env::set_var("VITE_BACKEND_URL", "https://staging-api.tinyhumans.ai/");
let result = api_base_from_env();
match prev_primary {
Some(v) => std::env::set_var("BACKEND_URL", v),
None => std::env::remove_var("BACKEND_URL"),
}
match prev_secondary {
Some(v) => std::env::set_var("VITE_BACKEND_URL", v),
None => std::env::remove_var("VITE_BACKEND_URL"),
}
assert_eq!(result.as_deref(), Some("https://staging-api.tinyhumans.ai"));
}
}