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