diff --git a/Cargo.lock b/Cargo.lock index e9a97c3..b23de17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,9 @@ dependencies = [ [[package]] name = "hemx-xtask" version = "0.1.0" +dependencies = [ + "hemx-js", +] [[package]] name = "html5ever" diff --git a/docs/recipes/mobile-release.md b/docs/recipes/mobile-release.md index 5d1cec4..956f884 100644 --- a/docs/recipes/mobile-release.md +++ b/docs/recipes/mobile-release.md @@ -56,7 +56,8 @@ 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()`; +- the exact runtime asset path and SHA-256 digest served by the same release; +- `asset-integrity.tsv` as a plain-text integrity receipt for mobile shell review; - 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; diff --git a/hemx-xtask/Cargo.toml b/hemx-xtask/Cargo.toml index b6b3abd..fceab27 100644 --- a/hemx-xtask/Cargo.toml +++ b/hemx-xtask/Cargo.toml @@ -7,3 +7,6 @@ publish = false [[bin]] name = "hemx-ci" path = "src/main.rs" + +[dependencies] +hemx-js = { path = "../hemx-js" } diff --git a/hemx-xtask/src/main.rs b/hemx-xtask/src/main.rs index 3afc71c..c51a84e 100644 --- a/hemx-xtask/src/main.rs +++ b/hemx-xtask/src/main.rs @@ -373,6 +373,10 @@ fn write_workout_mobile_release( config.out_dir.join("release-manifest.json"), workout_mobile_manifest(config, blockers), )?; + fs::write( + config.out_dir.join("asset-integrity.tsv"), + workout_asset_integrity(), + )?; fs::write( config.out_dir.join("BLOCKERS.md"), workout_mobile_blockers_md(blockers), @@ -409,7 +413,14 @@ fn verify_workout_mobile_release( 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] { + let integrity_path = config.out_dir.join("asset-integrity.tsv"); + for path in [ + &manifest_path, + &android_path, + &ios_path, + &blockers_path, + &integrity_path, + ] { if !path.exists() { failures.push(format!("{} is missing", path.display())); } @@ -421,7 +432,9 @@ fn verify_workout_mobile_release( &config.app_id, &config.version, &config.origin, - "hemx_axum::runtime_js_path()", + hemx_js::RUNTIME_JS_PATH, + hemx_js::RUNTIME_JS_HASH, + "asset-integrity.tsv", "app-owned command/event/projection records", "secrets and signing credentials stay outside the repo", "rollback", @@ -466,6 +479,12 @@ fn verify_workout_mobile_release( &mut failures, ); + check_file_contains( + &integrity_path, + &[hemx_js::RUNTIME_JS_PATH, hemx_js::RUNTIME_JS_HASH, "sha256"], + &mut failures, + ); + for blocker in mobile_external_blockers(config) { check_file_contains(&manifest_path, &[&blocker], &mut failures); check_file_contains(&blockers_path, &[&blocker], &mut failures); @@ -496,13 +515,23 @@ fn check_file_contains(path: &Path, needles: &[&str], failures: &mut Vec } } +fn workout_asset_integrity() -> String { + format!( + "path\talgorithm\tdigest\n{}\tsha256\t{}\n", + hemx_js::RUNTIME_JS_PATH, + hemx_js::RUNTIME_JS_HASH + ) +} + 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 \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\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\": \"{}\",\n \"runtime_asset_sha256\": \"{}\",\n \"asset_integrity\": \"asset-integrity.tsv\",\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 \"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"],\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_escape(hemx_js::RUNTIME_JS_PATH), + json_escape(hemx_js::RUNTIME_JS_HASH), json_string_list(blockers), ) } @@ -936,7 +965,9 @@ mod tests { 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(hemx_js::RUNTIME_JS_PATH)); + assert!(manifest.contains(hemx_js::RUNTIME_JS_HASH)); + assert!(manifest.contains("asset-integrity.tsv")); assert!(manifest.contains("app-owned command/event/projection records")); assert!(manifest.contains( "\"host_result_kinds\": [\"denied\", \"timeout\", \"unavailable\", \"error\"]"