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
+1
View File
@@ -50,6 +50,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot. - Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot.
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004 - Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004
- Run the workout product exemplar with `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/001 - Run the workout product exemplar with `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/001
- Generate and verify the Workout mobile release kit with `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout-mobile release` and `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout-mobile verify`; Android/iOS SDKs and signing remain external blockers, not repo-owned secrets. req: examples/006
- hemx core stays small: effects, typed ids, registries, and wire schema only. - hemx core stays small: effects, typed ids, registries, and wire schema only.
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates. - Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
- Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes. - Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes.
+1 -1
View File
@@ -810,7 +810,7 @@ what a valid business email is.
005 Canonical examples must not contain user-authored browser JavaScript. They may load the shared hemx runtime (`/hemx.js`) and may use declarative `data-hemx-*` attributes; inline `<script>`, `on*=` event handlers, and `javascript:` URLs are forbidden outside opaque leaf-widget examples. 005 Canonical examples must not contain user-authored browser JavaScript. They may load the shared hemx runtime (`/hemx.js`) and may use declarative `data-hemx-*` attributes; inline `<script>`, `on*=` event handlers, and `javascript:` URLs are forbidden outside opaque leaf-widget examples.
### req: examples/006 ### req: examples/006
006 The Workout exemplar must have one boring mobile release command that builds the production server binary and writes Android and iOS shell metadata. The command must make app identity, version, production origin, runtime asset policy, cache/offline state policy, environment/secrets boundary, rollback expectation, and external store-signing blockers explicit without adding a broad `hemx-mobile` framework. 006 The Workout exemplar must have one boring mobile release command that builds the production server binary and writes Android and iOS shell metadata, plus one verification command for that generated kit. The commands must make app identity, version, production origin, runtime asset policy, cache/offline state policy, environment/secrets boundary, rollback expectation, and external store-signing blockers explicit without adding a broad `hemx-mobile` framework.
--- ---
+3 -1
View File
@@ -9,6 +9,7 @@ vendor work. req: examples/006
```sh ```sh
cargo run -p hemx-xtask -- workout-mobile release cargo run -p hemx-xtask -- workout-mobile release
cargo run -p hemx-xtask -- workout-mobile verify
cargo run -p hemx-xtask -- workout-mobile doctor cargo run -p hemx-xtask -- workout-mobile doctor
``` ```
@@ -25,7 +26,8 @@ target/hemx-mobile/workout/
ios/README.md ios/README.md
``` ```
Use `doctor` when you only want to see missing external inputs. Use `verify` to check the generated kit and release binary. Use `doctor` when
you only want to see missing external inputs.
## Production configuration ## Production configuration
+2
View File
@@ -65,6 +65,8 @@ write Android/iOS shell metadata:
```sh ```sh
HEMX_WORKOUT_ORIGIN=https://workout.example.com \ HEMX_WORKOUT_ORIGIN=https://workout.example.com \
cargo run -p hemx-xtask -- workout-mobile release cargo run -p hemx-xtask -- workout-mobile release
HEMX_WORKOUT_ORIGIN=https://workout.example.com \
cargo run -p hemx-xtask -- workout-mobile verify
``` ```
The kit lands in `target/hemx-mobile/workout` unless The kit lands in `target/hemx-mobile/workout` unless
+139 -4
View File
@@ -24,13 +24,14 @@ fn main() -> ExitCode {
fn print_help() { fn print_help() {
println!( 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 { fn run_workout_mobile(command: Option<&str>) -> ExitCode {
match command.unwrap_or("release") { match command.unwrap_or("release") {
"release" => run_workout_mobile_release(), "release" => run_workout_mobile_release(),
"verify" => run_workout_mobile_verify(),
"doctor" => { "doctor" => {
let config = WorkoutMobileConfig::from_env(); let config = WorkoutMobileConfig::from_env();
let blockers = mobile_external_blockers(&config); let blockers = mobile_external_blockers(&config);
@@ -50,7 +51,7 @@ fn run_workout_mobile(command: Option<&str>) -> ExitCode {
} }
fn run_workout_mobile_release() -> 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(); let budget = Budget::detect();
budget.report(); budget.report();
if let Err(code) = Step::new( 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)] #[derive(Clone, Debug)]
struct WorkoutMobileConfig { struct WorkoutMobileConfig {
app_id: String, app_id: String,
@@ -190,6 +215,78 @@ fn write_workout_mobile_release(
Ok(()) 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 { fn workout_mobile_manifest(config: &WorkoutMobileConfig, blockers: &[String]) -> String {
format!( 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", "{{\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)] #[cfg(test)]
mod tests { mod tests {
use super::{ 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, Budget, WorkoutMobileConfig,
}; };
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
#[test] #[test]
@@ -635,7 +734,43 @@ mod tests {
.any(|blocker| blocker.contains("production HTTPS origin"))); .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 { 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 { WorkoutMobileConfig {
app_id: "com.hemx.workout".into(), app_id: "com.hemx.workout".into(),
app_name: "hemx Workout Copilot".into(), app_name: "hemx Workout Copilot".into(),
@@ -643,7 +778,7 @@ mod tests {
origin: origin.into(), origin: origin.into(),
android_package: "com.hemx.workout".into(), android_package: "com.hemx.workout".into(),
ios_bundle_id: "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),
} }
} }
} }