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
+4 -2
View File
@@ -87,8 +87,10 @@ and integrate at explicit boundaries. req: laws/002 req: auth/001
cookies, and normal SameSite/browser semantics. hemx preserves submitted form
fields and credentials semantics. See `docs/recipes/auth-session-csrf.md`. req: auth/004 req: auth/005
- **Observability, feature flags, killswitches, deploy:** use explicit platform
integrations around handlers, routes, and runtime assets. Core hemx must not
vendor providers or add framework-specific magic. See `docs/recipes/observability-flags.md` and `docs/recipes/deploy-versioning.md`.
integrations around handlers, routes, runtime assets, and mobile shells. Core
hemx must not vendor providers or add framework-specific magic. See
`docs/recipes/observability-flags.md`, `docs/recipes/deploy-versioning.md`,
and `docs/recipes/mobile-release.md`. req: examples/006
- **PWA/offline/sync:** optional adapters may reuse generated targets/effects,
but core hemx must not gain a mandatory client state graph or local app
runtime. Local truth is commands/events/projections, not stored DOM patches or
+3
View File
@@ -809,6 +809,9 @@ what a valid business email is.
### req: examples/005
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
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.
---
## milestone
+74
View File
@@ -0,0 +1,74 @@
# Recipe: Workout mobile release
The Workout exemplar is the production-shaped mobile path for hemx. It stays
boring on purpose: hemx builds the server app and writes mobile shell metadata;
Android/iOS SDKs, store signing, provisioning, and submission remain external
vendor work. req: examples/006
## Command surface
```sh
cargo run -p hemx-xtask -- workout-mobile release
cargo run -p hemx-xtask -- workout-mobile doctor
```
`release` builds `target/release/hemx-workout-example` and writes a release kit
under `target/hemx-mobile/workout` by default:
```text
target/hemx-mobile/workout/
release-manifest.json
BLOCKERS.md
android/twa-release.json
android/README.md
ios/webview-release.json
ios/README.md
```
Use `doctor` when you only want to see missing external inputs.
## Production configuration
Set these explicitly for a real app release:
```sh
HEMX_WORKOUT_APP_ID=com.example.workout
HEMX_WORKOUT_APP_NAME="Workout Copilot"
HEMX_WORKOUT_VERSION=1.0.0
HEMX_WORKOUT_ORIGIN=https://workout.example.com
HEMX_WORKOUT_ANDROID_PACKAGE=com.example.workout
HEMX_WORKOUT_IOS_BUNDLE_ID=com.example.workout
HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout
```
The generated manifest records:
- app identity and version;
- the production HTTPS origin used by Android and iOS shells;
- `target/release/hemx-workout-example` as the server artifact;
- runtime assets served by the same release through `hemx_axum::runtime_js_path()`;
- cache policy: release-scoped HTML/CSS/runtime assets only;
- offline truth policy: app-owned command/event/projection records, never DOM
patches or UI effect payloads;
- environment/secrets boundary: public shell config in the kit, signing secrets
outside the repo;
- rollback: redeploy the previous server binary and rebuild store artifacts from
the previous shell metadata/signing inputs. req: local/001 req: host/002
## Android and iOS artifacts
The command writes release-ready metadata, not store-signed binaries. That is the
honest boundary: producing `.aab`/`.apk` and `.ipa` files requires vendor SDKs,
signing credentials, and store accounts on the release machine.
Android blockers are reported when the Android SDK/JDK/signing key are not
visible. iOS blockers are reported when Xcode or the Apple signing team is not
visible. These blockers are copied into `BLOCKERS.md` so the release kit can be
reviewed without guessing what is still external. req: examples/006
## What this does not add
This is not a `hemx-mobile` framework, sync layer, client database, or native UI
runtime. The mobile shells load the production Workout web app and route host
capabilities such as share/haptics through the typed host boundary before UI
effects are produced. req: host/002 req: local/002
+1
View File
@@ -98,6 +98,7 @@ Evidence:
- observability/metrics + feature flags/killswitches:
`docs/recipes/observability-flags.md`
- deploy/versioning: `docs/recipes/deploy-versioning.md`
- mobile release: `docs/recipes/mobile-release.md`
- optional PWA/offline: `docs/recipes/pwa-offline.md`
### Public examples
+17
View File
@@ -57,6 +57,23 @@ serves the shared hemx runtime through `hemx-axum`; there is no frontend build,
Node runtime, Expo/Ionic/Tauri shell, or handwritten selector UI JavaScript to
ship. req: axum_integration/005 req: examples/005
## Mobile release kit
Use the canonical release command to build the production server binary and
write Android/iOS shell metadata:
```sh
HEMX_WORKOUT_ORIGIN=https://workout.example.com \
cargo run -p hemx-xtask -- workout-mobile release
```
The kit lands in `target/hemx-mobile/workout` unless
`HEMX_WORKOUT_MOBILE_OUT` is set. It records app identity, version, production
origin, runtime asset policy, cache/offline state policy, secrets/signing
boundaries, rollback expectations, Android TWA metadata, iOS WebView metadata,
and any external blocker such as missing Android SDK, Xcode, or store signing
credentials. See `docs/recipes/mobile-release.md`. req: examples/006
## Boundaries proven
- Core workout logging works without network availability once the page/runtime
+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"),
}
}
}