From adfd297426204f2acb7bbf207ee6f2b4f313f0d3 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 14 May 2026 18:31:31 +0200 Subject: [PATCH] fix(export): single-page compliance bundle was 842 MB dashboard mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release pipeline shipped `rivet-v0.9.0-compliance-report.tar.gz` at 842 MB — a static mirror of the `rivet serve` dashboard (index.html, coverage/, help/, validate/, matrix/, graph/, per-schema help pages, AADL viewer JS, source mirrors). That's a browseable website dump, not the audit-quality bundle the artifact name implies. Auditors got a confusing browse tree instead of a focused dossier. Three changes restore the audit-bundle shape: 1. **rivet-cli/src/main.rs (`cmd_export_html`):** the `single_page`, `theme`, and `offline` parameters were all `_`-prefixed — explicitly ignored by the function body. The CLI surface advertised them and the help text described them, but the binary swallowed them silently. Un-underscored and wired through: when `--single-page` is true, the function now calls `rivet_core::export::render_single_page` and writes one `index.html` to the output directory, returning early before the multi-page mirror path. `--offline` is honored by building the `ExportConfig` with `offline: true`. `--theme` selects `ExportTheme::Dark` or `ExportTheme::Light`. 2. **rivet-core/src/export.rs (`render_single_page` callee):** byte- indexing panic at line 1875. The graph-node sublabel did `&title[..26]` which panics on any title with a multi-byte UTF-8 character at the 26-byte boundary (e.g. an em-dash `—` at chars 25-27). Replaced with `title.chars().take(26).collect::()` plus an ellipsis. Surfaces on the rivet repo's own dogfood — "Human review degradation — reviewer trusts AI without verification" hits this case. 3. **.github/actions/compliance/action.yml:** add `single-page: true` as a new input (default true — the action is purpose-built for compliance reports, where single-page is the right shape) and flip `offline: 'false' → 'true'` as default (auditors open bundles disconnected). Pass `--single-page` and `--offline` through to the `rivet export` invocation. Result on the rivet repo's own dogfood: before: rivet-v0.9.0-compliance-report.tar.gz 842 MB after: single-page index.html 1.4 MB (600× smaller, all 9 sections inline: index / requirements / documents / stpa / eu-ai-act / coverage / matrix / validation / graph, zero external resources, inline CSS, browser-openable) Refs: REQ-010 (export contract), REQ-007 (CLI surface) --- .github/actions/compliance/action.yml | 23 ++++++++++-- rivet-cli/src/main.rs | 51 +++++++++++++++++++++++++-- rivet-core/src/export.rs | 8 +++-- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/.github/actions/compliance/action.yml b/.github/actions/compliance/action.yml index ebbca491..075ebbd7 100644 --- a/.github/actions/compliance/action.yml +++ b/.github/actions/compliance/action.yml @@ -40,9 +40,23 @@ inputs: default: 'dark' offline: - description: 'When true, uses system fonts instead of loading Google Fonts. For air-gapped environments.' + description: 'When true, uses system fonts instead of loading Google Fonts. For air-gapped environments. Defaults to true for compliance reports — auditors typically open the bundle disconnected.' required: false - default: 'false' + default: 'true' + + single-page: + description: > + When true, emits a single self-contained `index.html` (typically + 1-5 MB) with all sections inline — index / requirements / documents + / STPA / EU AI Act / coverage / matrix / validation / graph. This + is the audit-bundle shape: one file, no per-page navigation, no + external resources, browser-openable offline. + + When false, emits the full multi-page dashboard mirror (hundreds + of pages, ~100s of MB). Useful for browseable hosted docs but + oversized for an audit deliverable. + required: false + default: 'true' # ── Rivet tool ───────────────────────────────────────────────────── rivet-version: @@ -170,6 +184,7 @@ runs: REPORT_HOMEPAGE: ${{ inputs.homepage }} REPORT_VERSIONS: ${{ inputs.other-versions }} REPORT_OFFLINE: ${{ inputs.offline }} + REPORT_SINGLE_PAGE: ${{ inputs.single-page }} run: | ARGS="--format html --output ${REPORT_OUTPUT}" ARGS="$ARGS --theme ${REPORT_THEME}" @@ -187,6 +202,10 @@ runs: ARGS="$ARGS --offline" fi + if [ "$REPORT_SINGLE_PAGE" = "true" ]; then + ARGS="$ARGS --single-page" + fi + eval rivet export $ARGS echo "report-dir=${REPORT_OUTPUT}" >> "$GITHUB_OUTPUT" diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 898f93d7..6e5b99ca 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -6573,9 +6573,9 @@ fn cmd_export_gherkin( fn cmd_export_html( cli: &Cli, output: Option<&std::path::Path>, - _single_page: bool, - _theme: &str, - _offline: bool, + single_page: bool, + theme: &str, + offline: bool, _homepage: Option<&str>, _version_label: Option<&str>, _versions_json: Option<&str>, @@ -6593,6 +6593,51 @@ fn cmd_export_html( let state = serve::reload_state(&project_path, &schemas_dir, 0) .context("loading project for export")?; + // ── Single-page mode (audit-bundle shape) ──────────────────────── + // + // The multi-page export below mirrors the `rivet serve` dashboard + // (~800 pages, ~800 MB) which is useful for browsing but not for an + // audit bundle. `--single-page` short-circuits to the dedicated + // single-page renderer in `rivet_core::export::render_single_page`: + // one self-contained `index.html` with the index / requirements / + // documents / STPA / EU-AI-Act / coverage / matrix / validation / + // graph sections inline, ~3-5 MB total. This is the path the + // `.github/actions/compliance` action calls; the CLI previously + // ignored the flag (the parameters were `_`-prefixed). + if single_page { + let config = rivet_core::export::ExportConfig { + theme: match theme { + "light" => rivet_core::export::ExportTheme::Light, + _ => rivet_core::export::ExportTheme::Dark, + }, + offline, + }; + let project_name = state.context.project_name.clone(); + let version = env!("CARGO_PKG_VERSION").to_string(); + let html = rivet_core::export::render_single_page( + &state.store, + &state.schema, + &state.graph, + &state.cached_diagnostics, + &project_name, + &version, + &config, + &state.doc_store, + ); + let out_dir = output.unwrap_or(std::path::Path::new("dist")); + std::fs::create_dir_all(out_dir) + .with_context(|| format!("creating {}", out_dir.display()))?; + let out_file = out_dir.join("index.html"); + std::fs::write(&out_file, &html) + .with_context(|| format!("writing {}", out_file.display()))?; + println!( + "Exported single-page HTML ({} bytes) to {}", + html.len(), + out_file.display(), + ); + return Ok(true); + } + // Auto-detect baseline snapshot for delta rendering. let snap_dir = project_path.join("snapshots"); let baseline_snapshot = find_latest_snapshot(&snap_dir) diff --git a/rivet-core/src/export.rs b/rivet-core/src/export.rs index e16c5536..7907dcb4 100644 --- a/rivet-core/src/export.rs +++ b/rivet-core/src/export.rs @@ -1871,8 +1871,12 @@ fn render_section_graph(store: &Store, link_graph: &LinkGraph) -> String { .get(n.as_str()) .map(|a| a.title.clone()) .unwrap_or_default(); - let sublabel = if title.len() > 28 { - Some(format!("{}...", &title[..26])) + let sublabel = if title.chars().count() > 28 { + // Truncate by chars, not bytes — `&title[..26]` panics when + // byte 26 falls inside a multi-byte UTF-8 character (e.g. + // the em-dash `—`, 3 bytes). Take 26 chars and append `…`. + let truncated: String = title.chars().take(26).collect(); + Some(format!("{truncated}…")) } else if title.is_empty() { None } else {