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:
+139
-4
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user