feat(workout): verify mobile release kit

Add a workout-mobile verify command that checks the generated release kit and server artifact, then document it beside release/doctor so the mobile path has a recurring gate.

req: examples/006
This commit is contained in:
slhx agent
2026-06-12 11:12:18 +02:00
parent f2b6aa1aef
commit 5a42f2664a
5 changed files with 146 additions and 6 deletions
+139 -4
View File
@@ -24,13 +24,14 @@ fn main() -> ExitCode {
fn print_help() {
println!(
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- workout-mobile release\n cargo run -p hemx-xtask -- workout-mobile doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- workout-mobile release\n cargo run -p hemx-xtask -- workout-mobile verify\n cargo run -p hemx-xtask -- workout-mobile doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
);
}
fn run_workout_mobile(command: Option<&str>) -> ExitCode {
match command.unwrap_or("release") {
"release" => run_workout_mobile_release(),
"verify" => run_workout_mobile_verify(),
"doctor" => {
let config = WorkoutMobileConfig::from_env();
let blockers = mobile_external_blockers(&config);
@@ -50,7 +51,7 @@ fn run_workout_mobile(command: Option<&str>) -> ExitCode {
}
fn run_workout_mobile_release() -> ExitCode {
// req: examples/001 req: host/002 req: local/001
// req: examples/001 req: examples/006 req: host/002 req: local/001
let budget = Budget::detect();
budget.report();
if let Err(code) = Step::new(
@@ -83,6 +84,30 @@ fn run_workout_mobile_release() -> ExitCode {
}
}
fn run_workout_mobile_verify() -> ExitCode {
// req: examples/006
let config = WorkoutMobileConfig::from_env();
let failures = verify_workout_mobile_release(&config, true);
let blockers = mobile_external_blockers(&config);
if failures.is_empty() {
println!(
"workout-mobile-verified\tout={}\tblockers={}",
config.out_dir.display(),
blockers.len()
);
for blocker in &blockers {
println!("workout-mobile-blocker\t{}", blocker);
}
ExitCode::SUCCESS
} else {
eprintln!("Workout mobile release kit is not verifiable:");
for failure in failures {
eprintln!("- {failure}");
}
ExitCode::FAILURE
}
}
#[derive(Clone, Debug)]
struct WorkoutMobileConfig {
app_id: String,
@@ -190,6 +215,78 @@ fn write_workout_mobile_release(
Ok(())
}
fn verify_workout_mobile_release(
config: &WorkoutMobileConfig,
require_server_binary: bool,
) -> Vec<String> {
let mut failures = Vec::new();
if require_server_binary && !Path::new("target/release/hemx-workout-example").exists() {
failures.push(
"target/release/hemx-workout-example is missing; run workout-mobile release first"
.into(),
);
}
let manifest_path = config.out_dir.join("release-manifest.json");
let android_path = config.out_dir.join("android/twa-release.json");
let ios_path = config.out_dir.join("ios/webview-release.json");
let blockers_path = config.out_dir.join("BLOCKERS.md");
for path in [&manifest_path, &android_path, &ios_path, &blockers_path] {
if !path.exists() {
failures.push(format!("{} is missing", path.display()));
}
}
check_file_contains(
&manifest_path,
&[
&config.app_id,
&config.version,
&config.origin,
"hemx_axum::runtime_js_path()",
"app-owned command/event/projection records",
"secrets and signing credentials stay outside the repo",
"rollback",
"android/twa-release.json",
"ios/webview-release.json",
],
&mut failures,
);
check_file_contains(
&android_path,
&[
&config.android_package,
&config.app_name,
origin_host(&config.origin),
"external Android keystore",
],
&mut failures,
);
check_file_contains(
&ios_path,
&[
&config.ios_bundle_id,
&config.app_name,
"share",
"haptics",
"external Apple team",
],
&mut failures,
);
failures
}
fn check_file_contains(path: &Path, needles: &[&str], failures: &mut Vec<String>) {
let Ok(contents) = fs::read_to_string(path) else {
return;
};
for needle in needles {
if !contents.contains(needle) {
failures.push(format!("{} does not contain `{}`", path.display(), needle));
}
}
}
fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) -> String {
format!(
"{{\n \"app_id\": \"{}\",\n \"name\": \"{}\",\n \"version\": \"{}\",\n \"origin\": \"{}\",\n \"server_binary\": \"target/release/hemx-workout-example\",\n \"runtime_asset_path\": \"served by hemx_axum::runtime_js_path() from the same release\",\n \"cache_policy\": \"cache only release-scoped HTML/CSS/runtime assets; never store DOM patches or UI effects as truth\",\n \"state_policy\": \"app-owned command/event/projection records\",\n \"environment_boundary\": \"public mobile shell config lives here; secrets and signing credentials stay outside the repo\",\n \"rollback\": \"redeploy the previous server binary and matching mobile shell metadata; rebuild store artifacts with the previous version/signing inputs\",\n \"android\": \"android/twa-release.json\",\n \"ios\": \"ios/webview-release.json\",\n \"external_blockers\": [{}]\n}}\n",
@@ -563,9 +660,11 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
#[cfg(test)]
mod tests {
use super::{
android_twa_release_json, mobile_external_blockers, origin_host, workout_mobile_manifest,
android_twa_release_json, mobile_external_blockers, origin_host,
verify_workout_mobile_release, workout_mobile_manifest, write_workout_mobile_release,
Budget, WorkoutMobileConfig,
};
use std::fs;
use std::path::PathBuf;
#[test]
@@ -635,7 +734,43 @@ mod tests {
.any(|blocker| blocker.contains("production HTTPS origin")));
}
#[test]
fn workout_mobile_verify_accepts_generated_release_kit() {
// req: examples/006
let config = workout_mobile_config_at(
"https://workout.example.com",
"target/test-workout-mobile-accepts",
);
let _ = fs::remove_dir_all(&config.out_dir);
write_workout_mobile_release(&config, &["Android signing key not configured".into()])
.expect("write release kit");
assert!(verify_workout_mobile_release(&config, false).is_empty());
let _ = fs::remove_dir_all(&config.out_dir);
}
#[test]
fn workout_mobile_verify_rejects_missing_release_kit() {
// req: examples/006
let config = workout_mobile_config_at(
"https://workout.example.com",
"target/test-workout-mobile-missing",
);
let _ = fs::remove_dir_all(&config.out_dir);
let failures = verify_workout_mobile_release(&config, false);
assert!(failures
.iter()
.any(|failure| failure.contains("release-manifest.json is missing")));
}
fn workout_mobile_config(origin: &str) -> WorkoutMobileConfig {
workout_mobile_config_at(origin, "target/test-workout-mobile")
}
fn workout_mobile_config_at(origin: &str, out_dir: &str) -> WorkoutMobileConfig {
WorkoutMobileConfig {
app_id: "com.hemx.workout".into(),
app_name: "hemx Workout Copilot".into(),
@@ -643,7 +778,7 @@ mod tests {
origin: origin.into(),
android_package: "com.hemx.workout".into(),
ios_bundle_id: "com.hemx.workout".into(),
out_dir: PathBuf::from("target/test-workout-mobile"),
out_dir: PathBuf::from(out_dir),
}
}
}