feat(workout): add mobile release kit command

Add one canonical Workout mobile release command that builds the production server and writes Android/iOS shell metadata, explicit production policy, and honest external signing/toolchain blockers.

req: examples/001

req: examples/006

req: host/002

req: local/001
This commit is contained in:
slhx agent
2026-06-12 11:02:31 +02:00
parent a28eb78ded
commit f2b6aa1aef
6 changed files with 403 additions and 5 deletions
+304 -3
View File
@@ -1,5 +1,6 @@
use std::env;
use std::path::Path;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::Instant;
@@ -8,6 +9,7 @@ fn main() -> ExitCode {
match args.next().as_deref() {
Some("test") | None => run_test_plan(),
Some("bench") => run_bench_plan(),
Some("workout-mobile") => run_workout_mobile(args.next().as_deref()),
Some("help") | Some("--help") | Some("-h") => {
print_help();
ExitCode::SUCCESS
@@ -22,10 +24,257 @@ 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\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"
"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"
);
}
fn run_workout_mobile(command: Option<&str>) -> ExitCode {
match command.unwrap_or("release") {
"release" => run_workout_mobile_release(),
"doctor" => {
let config = WorkoutMobileConfig::from_env();
let blockers = mobile_external_blockers(&config);
print_mobile_doctor(&config, &blockers);
ExitCode::SUCCESS
}
"help" | "--help" | "-h" => {
print_help();
ExitCode::SUCCESS
}
other => {
eprintln!("unknown workout-mobile command `{other}`\n");
print_help();
ExitCode::from(2)
}
}
}
fn run_workout_mobile_release() -> ExitCode {
// req: examples/001 req: host/002 req: local/001
let budget = Budget::detect();
budget.report();
if let Err(code) = Step::new(
"workout-mobile-server-release",
["build", "--release", "--bin", "hemx-workout-example"],
)
.run(&budget)
{
return code;
}
let config = WorkoutMobileConfig::from_env();
let blockers = mobile_external_blockers(&config);
match write_workout_mobile_release(&config, &blockers) {
Ok(()) => {
println!(
"workout-mobile\tout={}\tblockers={}",
config.out_dir.display(),
blockers.len()
);
for blocker in &blockers {
println!("workout-mobile-blocker\t{}", blocker);
}
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("failed to write Workout mobile release kit: {err}");
ExitCode::FAILURE
}
}
}
#[derive(Clone, Debug)]
struct WorkoutMobileConfig {
app_id: String,
app_name: String,
version: String,
origin: String,
android_package: String,
ios_bundle_id: String,
out_dir: PathBuf,
}
impl WorkoutMobileConfig {
fn from_env() -> Self {
let app_id = env::var("HEMX_WORKOUT_APP_ID").unwrap_or_else(|_| "com.hemx.workout".into());
Self {
android_package: env::var("HEMX_WORKOUT_ANDROID_PACKAGE")
.unwrap_or_else(|_| app_id.clone()),
ios_bundle_id: env::var("HEMX_WORKOUT_IOS_BUNDLE_ID")
.unwrap_or_else(|_| app_id.clone()),
app_id,
app_name: env::var("HEMX_WORKOUT_APP_NAME")
.unwrap_or_else(|_| "hemx Workout Copilot".into()),
version: env::var("HEMX_WORKOUT_VERSION")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
origin: env::var("HEMX_WORKOUT_ORIGIN")
.unwrap_or_else(|_| "https://workout.example.invalid".into()),
out_dir: env::var_os("HEMX_WORKOUT_MOBILE_OUT")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("target/hemx-mobile/workout")),
}
}
}
fn mobile_external_blockers(config: &WorkoutMobileConfig) -> Vec<String> {
let mut blockers = Vec::new();
if !config.origin.starts_with("https://") {
blockers.push("HEMX_WORKOUT_ORIGIN must be the production HTTPS origin used by Android and iOS shells".into());
}
if env::var_os("ANDROID_HOME").is_none() && env::var_os("ANDROID_SDK_ROOT").is_none() {
blockers.push("Android SDK not found: set ANDROID_HOME or ANDROID_SDK_ROOT before producing signed Android artifacts".into());
}
if !has_command("java") {
blockers.push("Java runtime not found: Android packaging requires a JDK".into());
}
if env::var_os("HEMX_WORKOUT_ANDROID_KEYSTORE").is_none() {
blockers.push("Android signing key not configured: set HEMX_WORKOUT_ANDROID_KEYSTORE for store-ready signing".into());
}
if !has_command("xcodebuild") {
blockers.push(
"Xcode command line tools not found: iOS archive/export requires xcodebuild on macOS"
.into(),
);
}
if env::var_os("HEMX_WORKOUT_IOS_TEAM_ID").is_none() {
blockers.push("iOS signing team not configured: set HEMX_WORKOUT_IOS_TEAM_ID for App Store/TestFlight export".into());
}
blockers
}
fn print_mobile_doctor(config: &WorkoutMobileConfig, blockers: &[String]) {
println!("Workout mobile release doctor");
println!(
"app_id={} version={} origin={}",
config.app_id, config.version, config.origin
);
if blockers.is_empty() {
println!("ready: Android SDK/signing and iOS Xcode/signing inputs are visible");
} else {
println!("blocked external steps:");
for blocker in blockers {
println!("- {blocker}");
}
}
}
fn write_workout_mobile_release(
config: &WorkoutMobileConfig,
blockers: &[String],
) -> std::io::Result<()> {
let android_dir = config.out_dir.join("android");
let ios_dir = config.out_dir.join("ios");
fs::create_dir_all(&android_dir)?;
fs::create_dir_all(&ios_dir)?;
fs::write(
config.out_dir.join("release-manifest.json"),
workout_mobile_manifest(config, blockers),
)?;
fs::write(
config.out_dir.join("BLOCKERS.md"),
workout_mobile_blockers_md(blockers),
)?;
fs::write(
android_dir.join("twa-release.json"),
android_twa_release_json(config),
)?;
fs::write(
android_dir.join("README.md"),
android_release_readme(config),
)?;
fs::write(
ios_dir.join("webview-release.json"),
ios_webview_release_json(config),
)?;
fs::write(ios_dir.join("README.md"), ios_release_readme(config))?;
Ok(())
}
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",
json_escape(&config.app_id),
json_escape(&config.app_name),
json_escape(&config.version),
json_escape(&config.origin),
json_string_list(blockers),
)
}
fn android_twa_release_json(config: &WorkoutMobileConfig) -> String {
format!(
"{{\n \"package\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"host\": \"{}\",\n \"version\": \"{}\",\n \"signing\": \"external Android keystore; never commit credentials\"\n}}\n",
json_escape(&config.android_package),
json_escape(&config.app_name),
json_escape(config.origin.trim_end_matches('/')),
json_escape(origin_host(&config.origin)),
json_escape(&config.version),
)
}
fn ios_webview_release_json(config: &WorkoutMobileConfig) -> String {
format!(
"{{\n \"bundle_id\": \"{}\",\n \"name\": \"{}\",\n \"start_url\": \"{}/\",\n \"version\": \"{}\",\n \"host_capabilities\": [\"share\", \"haptics\"],\n \"signing\": \"external Apple team/provisioning profile; never commit credentials\"\n}}\n",
json_escape(&config.ios_bundle_id),
json_escape(&config.app_name),
json_escape(config.origin.trim_end_matches('/')),
json_escape(&config.version),
)
}
fn workout_mobile_blockers_md(blockers: &[String]) -> String {
if blockers.is_empty() {
"# Workout mobile external blockers\n\nNo external blocker was detected locally. Store submission still remains a human/vendor step.\n".into()
} else {
let mut out = String::from("# Workout mobile external blockers\n\nThe hemx release kit is generated, but these external inputs are still required for signed store artifacts:\n\n");
for blocker in blockers {
out.push_str("- ");
out.push_str(blocker);
out.push('\n');
}
out
}
}
fn android_release_readme(config: &WorkoutMobileConfig) -> String {
format!(
"# Workout Android release\n\nUse `twa-release.json` as the Android shell authority for `{}`. Build the hemx server with the same release and serve `{}/` over HTTPS. Android SDK, Java, and signing credentials are external inputs; this repository does not own them.\n",
config.android_package, config.origin
)
}
fn ios_release_readme(config: &WorkoutMobileConfig) -> String {
format!(
"# Workout iOS release\n\nUse `webview-release.json` as the iOS shell authority for `{}`. Archive with Xcode against `{}/` and route share/haptics through the typed host adapter. Apple team IDs, provisioning profiles, and App Store submission are external inputs; this repository does not own them.\n",
config.ios_bundle_id, config.origin
)
}
fn json_string_list(values: &[String]) -> String {
values
.iter()
.map(|value| format!("\"{}\"", json_escape(value)))
.collect::<Vec<_>>()
.join(", ")
}
fn json_escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
fn origin_host(origin: &str) -> &str {
origin
.strip_prefix("https://")
.or_else(|| origin.strip_prefix("http://"))
.unwrap_or(origin)
.split('/')
.next()
.unwrap_or(origin)
}
fn run_test_plan() -> ExitCode {
// req: test/004
let budget = Budget::detect();
@@ -313,7 +562,11 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
#[cfg(test)]
mod tests {
use super::Budget;
use super::{
android_twa_release_json, mobile_external_blockers, origin_host, workout_mobile_manifest,
Budget, WorkoutMobileConfig,
};
use std::path::PathBuf;
#[test]
fn budget_is_capped_by_available_memory() {
@@ -345,4 +598,52 @@ mod tests {
assert_eq!(super::bench_values(1), vec![1]);
assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]);
}
#[test]
fn workout_mobile_manifest_names_production_boundaries() {
// req: examples/001 req: local/001 req: host/002
let config = workout_mobile_config("https://workout.example.com");
let blockers = vec!["Android signing key not configured".to_string()];
let manifest = workout_mobile_manifest(&config, &blockers);
assert!(manifest.contains("target/release/hemx-workout-example"));
assert!(manifest.contains("hemx_axum::runtime_js_path()"));
assert!(manifest.contains("app-owned command/event/projection records"));
assert!(manifest.contains("secrets and signing credentials stay outside the repo"));
assert!(manifest.contains("Android signing key not configured"));
}
#[test]
fn workout_mobile_release_uses_https_origin_and_host_metadata() {
// req: examples/001 req: host/002
let config = workout_mobile_config("https://workout.example.com/app");
let android = android_twa_release_json(&config);
assert_eq!(origin_host(&config.origin), "workout.example.com");
assert!(android.contains("\"start_url\": \"https://workout.example.com/app/\""));
assert!(android.contains("\"host\": \"workout.example.com\""));
}
#[test]
fn workout_mobile_doctor_rejects_non_https_production_origin() {
// req: examples/001
let config = workout_mobile_config("http://workout.example.com");
let blockers = mobile_external_blockers(&config);
assert!(blockers
.iter()
.any(|blocker| blocker.contains("production HTTPS origin")));
}
fn workout_mobile_config(origin: &str) -> WorkoutMobileConfig {
WorkoutMobileConfig {
app_id: "com.hemx.workout".into(),
app_name: "hemx Workout Copilot".into(),
version: "1.2.3".into(),
origin: origin.into(),
android_package: "com.hemx.workout".into(),
ios_bundle_id: "com.hemx.workout".into(),
out_dir: PathBuf::from("target/test-workout-mobile"),
}
}
}