From 097b07db2eee572865e2dc1e4c590762114c66ef Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 09:14:55 +0100 Subject: [PATCH 01/15] feat(table): harden semantic table boundaries --- Cargo.toml | 4 + UPSTREAM_MAPPING.md | 6 +- docs/src/lib.rs | 30 +++++ .../acceptance/BUI-012/independent-review.md | 38 ++++++ src/lib.rs | 11 +- src/table.rs | 61 +++++++--- tests/table_test.rs | 110 +++++++++++++++++- 7 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 docs/src/lib.rs create mode 100644 evidence/acceptance/BUI-012/independent-review.md diff --git a/Cargo.toml b/Cargo.toml index 8547601..3aff72a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,12 @@ +[workspace] +resolver = "2" + [package] name = "rusty-bubbles" exclude = ["POLICIES.md"] version = "2.1.0" edition = "2021" +rust-version = "1.92.0" description = "Cleanroom Rust port of charm.land/bubbles/v2: TUI components for Bubble Tea applications" license = "MIT" diff --git a/UPSTREAM_MAPPING.md b/UPSTREAM_MAPPING.md index fda9df4..8c6b738 100644 --- a/UPSTREAM_MAPPING.md +++ b/UPSTREAM_MAPPING.md @@ -27,7 +27,7 @@ | `progress/progress.go` | `src/progress.rs` | Progress bar with spring animation | | `spinner/spinner.go` | `src/spinner.rs` | Spinner component + presets | | `stopwatch/stopwatch.go` | `src/stopwatch.rs` | Stopwatch component | -| `table/table.go` | `src/table.rs` | Table component | +| `table/table.go` | `src/table.rs` | Table component; Rust boundary handling keeps declared columns rectangular for ragged rows and clamps outer-height arithmetic safely | | `textarea/textarea.go` | `src/textarea.rs` | Multi-line text area | | `textinput/textinput.go` | `src/textinput.rs` | Single-line text input | | `textinput/styles.go` | `src/textinput.rs` | Text input styles | @@ -96,6 +96,10 @@ dependency tree as upstream keeps them out of the bubbletea library module): `viewport.longestLineWidth`, `m.setInitialValues()`, `statusView()`) assert through the public API (documented at each call site). - `viewport::scroll_left` uses `saturating_sub` (upstream int semantics clamp to 0). +- `table::Model` uses saturating outer-height arithmetic and reapplies the + declared table shape during rendering: missing cells are empty and surplus + cells are ignored, while cursor and viewport movement remains safe for zero + and maximum inputs. ## Dependency Manifest diff --git a/docs/src/lib.rs b/docs/src/lib.rs new file mode 100644 index 0000000..d5f14ca --- /dev/null +++ b/docs/src/lib.rs @@ -0,0 +1,30 @@ +//! Cleanroom user documentation source for the generic Bubbles widgets. +//! +//! +//! rusty-bubbles provides typed model/update/view components for common +//! terminal interfaces. Components are independent and deterministic: callers +//! own the event loop and pass messages to the component they compose. +//! +//! The table component renders each declared column in order. Short rows yield +//! empty cells and surplus row values are ignored, so malformed input cannot +//! change the table shape or panic the renderer: +//! +//! ``` +//! use rusty_bubbles::table::{self, Column}; +//! +//! let table = table::new(vec![ +//! table::with_width(16), +//! table::with_columns(&[ +//! Column { title: "Name".into(), width: 8 }, +//! Column { title: "State".into(), width: 8 }, +//! ]), +//! table::with_rows(&[vec!["Bubbles".into()]]), +//! ]); +//! assert_eq!(table.selected_row().unwrap().len(), 1); +//! assert!(table.view().contains("Bubbles")); +//! ``` +//! +//! +//! Internal maintainer note: this source is the documentation-owned projection +//! for the BUI-012 target. Keep the example synchronized with the public table +//! facade and its deterministic boundary behavior. diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md new file mode 100644 index 0000000..5e669fb --- /dev/null +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -0,0 +1,38 @@ +# BUI-012 Implementation Evidence + +Status: implementation self-review complete; independent review, protected CI, and merge authorization remain lifecycle gates. + +## Result + +The rusty-bubbles table now materializes every declared column deterministically. A row with fewer values produces empty cells, surplus values cannot index past the column definition, and zero or maximum navigation inputs do not panic. Outer-height subtraction clamps at zero while preserving the existing option-application semantics. + +## Scope + +- `Cargo.toml`: declares the standalone Cargo resolver and the compiler floor used by the candidate. +- `src/lib.rs`: documents the crate facade as a user-facing component collection. +- `src/table.rs`: hardens outer-height arithmetic, cursor/viewport movement, and ragged-row rendering. +- `tests/table_test.rs`: adds deterministic ragged-row, zero-height, and maximum-input coverage while extending the upstream overflow case. +- `docs/src/lib.rs`: documents the table shape contract and contains a compiling user-facing example. +- `UPSTREAM_MAPPING.md`: records the Rust-side table boundary adaptation. + +## Contract checks + +| Check | Result | Evidence | +| --- | --- | --- | +| Declared columns remain the rendered row shape | Pass | `tests/table_test.rs::test_ragged_rows_render_against_declared_columns` | +| Height underflow is closed at zero | Pass | `tests/table_test.rs::test_height_is_saturating_at_the_boundary` | +| Cursor movement handles maximum inputs | Pass | `tests/table_test.rs::test_navigation_saturates_at_cursor_bounds` | +| Zero-height navigation is safe | Pass | `tests/table_test.rs::test_zero_height_navigation_is_safe` | +| Sibling dependency direction remains unchanged | Pass | `Cargo.toml`; path dependencies remain rusty-bubbletea, rusty-lipgloss, and rusty-x-ansi | + +## Focused validation + +- `cargo test --test table_test --no-fail-fast`: 26 passed, 2 ignored. +- `cargo test --all-targets --no-fail-fast`: all unit and integration targets passed; 2 table tests and 1 viewport benchmark remain the repository's existing ignored tests. +- `cargo fmt --all --check`: passed. +- `cargo clippy --all-targets -- -D warnings`: passed. +- `cargo doc --no-deps --all-features`: passed without warnings. +- `rustdoc --test docs/src/lib.rs`: 1 passed. +- `scripts/verify_mapping.sh`: passed; the optional local `upstream-go/` checkout was absent as documented by the script. + +This packet is implementation evidence, not an approval or merge authorization. The final independent review, exact-head protected CI, and post-merge absorb transition remain owned by the Mutate lifecycle. diff --git a/src/lib.rs b/src/lib.rs index accfb49..b40c56c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Cleanroom Rust port of upstream Go source file: `bubbles.go` //! Upstream Target Tag / Version: `v2.1.0` //! -//! +//! //! # Bubbles //! //! Components for Bubble Tea applications. These components are used in @@ -23,7 +23,14 @@ //! - [`textarea`] — multi-line text area component //! - [`filepicker`] — file picker component //! -//! +//! Each component exposes typed state and deterministic model/update/view +//! operations. Components remain independent so applications can compose them +//! without taking ownership of a downstream event loop. +//! +//! +//! Internal maintainer note: this root module is the public facade. Keep the +//! module list and the user-facing documentation projection synchronized when +//! adding or changing a component. pub mod cursor; pub mod filepicker; diff --git a/src/table.rs b/src/table.rs index c42fd49..a101193 100644 --- a/src/table.rs +++ b/src/table.rs @@ -216,11 +216,15 @@ pub fn with_rows(rows: &[Row]) -> Option { }) } -/// WithHeight sets the height of the table. +/// WithHeight sets the outer height of the table. +/// +/// The header consumes part of the requested height. Values smaller than the +/// rendered header clamp the content viewport to zero instead of wrapping +/// through usize arithmetic. pub fn with_height(h: usize) -> Option { Box::new(move |m: &mut Model| { let hh = rusty_lipgloss::size::height(&m.headers_view()); - m.viewport.set_height(h - hh); + m.viewport.set_height(h.saturating_sub(hh)); }) } @@ -392,10 +396,14 @@ impl Model { self.update_viewport(); } - /// SetHeight sets the height of the viewport of the table. + /// SetHeight sets the outer height of the table. + /// + /// The header consumes part of the requested height. Values smaller than + /// the rendered header clamp the content viewport to zero instead of + /// wrapping through usize arithmetic. pub fn set_height(&mut self, h: usize) { let hh = rusty_lipgloss::size::height(&self.headers_view()); - self.viewport.set_height(h - hh); + self.viewport.set_height(h.saturating_sub(hh)); self.update_viewport(); } @@ -431,13 +439,23 @@ impl Model { self.rows.len().saturating_sub(1), ); + if self.viewport.height() == 0 { + self.viewport.set_y_offset(0); + self.update_viewport(); + return; + } + let mut offset = self.viewport.y_offset(); if self.start == 0 { offset = clamp(offset, 0, self.cursor); } else if self.start < self.viewport.height() { - offset = clamp(clamp(offset + n, 0, self.cursor), 0, self.viewport.height()); + offset = clamp( + clamp(offset.saturating_add(n), 0, self.cursor), + 0, + self.viewport.height(), + ); } else if offset >= 1 { - offset = clamp(offset + n, 1, self.viewport.height()); + offset = clamp(offset.saturating_add(n), 1, self.viewport.height()); } self.viewport.set_y_offset(offset); self.update_viewport(); @@ -446,18 +464,27 @@ impl Model { /// MoveDown moves the selection down by any number of rows. /// It can not go below the last row. pub fn move_down(&mut self, n: usize) { - self.cursor = clamp(self.cursor + n, 0, self.rows.len().saturating_sub(1)); + self.cursor = clamp( + self.cursor.saturating_add(n), + 0, + self.rows.len().saturating_sub(1), + ); self.update_viewport(); + if self.viewport.height() == 0 { + self.viewport.set_y_offset(0); + return; + } + let mut offset = self.viewport.y_offset(); if self.end == self.rows.len() && offset > 0 { - offset = clamp(offset - n, 1, self.viewport.height()); + offset = clamp(offset.saturating_sub(n), 1, self.viewport.height()); } else if self.cursor > (self.end - self.start) / 2 && offset > 0 { - offset = clamp(offset - n, 1, self.cursor); + offset = clamp(offset.saturating_sub(n), 1, self.cursor); } else if offset > 1 { // no-op - } else if self.cursor > offset + self.viewport.height() - 1 { - offset = clamp(offset + 1, 0, 1); + } else if self.cursor > offset.saturating_add(self.viewport.height().saturating_sub(1)) { + offset = clamp(offset.saturating_add(1), 0, 1); } self.viewport.set_y_offset(offset); } @@ -509,16 +536,16 @@ impl Model { fn render_row(&self, r: usize) -> String { let mut s: Vec = Vec::with_capacity(self.cols.len()); - for (i, value) in self.rows[r].iter().enumerate() { - if self.cols[i].width == 0 { + for (i, col) in self.cols.iter().enumerate() { + if col.width == 0 { continue; } let style = rusty_lipgloss::new_style() - .width(self.cols[i].width) - .max_width(self.cols[i].width) + .width(col.width) + .max_width(col.width) .inline(true); - let rendered_cell = - style.render(&rusty_x_ansi::truncate(value, self.cols[i].width, "…")); + let value = self.rows[r].get(i).map(String::as_str).unwrap_or(""); + let rendered_cell = style.render(&rusty_x_ansi::truncate(value, col.width, "…")); s.push(self.styles.cell.clone().render(&rendered_cell)); } diff --git a/tests/table_test.rs b/tests/table_test.rs index 61d992a..b26943f 100644 --- a/tests/table_test.rs +++ b/tests/table_test.rs @@ -428,13 +428,10 @@ fn test_cursor_navigation() { assert_eq!(t.cursor(), 3, "want 3, got {}", t.cursor()); // MoveUp with overflow: the Go test moves up 5 rows from row 3, which - // clamps to row 0. NOTE: the Rust `move_up` computes `cursor - n` with - // `usize` arithmetic and panics on underflow when `n > cursor`, so we - // move up only as far as the cursor (the clamp-to-top behavior is the - // same as Go's for `n >= cursor`). + // clamps to row 0. let mut t = table::new(vec![table::with_columns(&cols), table::with_rows(&rows4)]); t.set_cursor(3); - t.move_up(3); + t.move_up(5); assert_eq!(t.cursor(), 0, "want 0, got {}", t.cursor()); // Blur does not stop movement @@ -665,3 +662,106 @@ fn test_table_options_and_navigation_update() { m.focus(); assert!(m.focused()); } + +#[test] +fn test_ragged_rows_render_against_declared_columns() { + let columns = vec![ + table::Column { + title: "one".to_string(), + width: 3, + }, + table::Column { + title: "two".to_string(), + width: 3, + }, + table::Column { + title: "three".to_string(), + width: 3, + }, + ]; + let rows = vec![ + vec!["a".to_string()], + vec!["b".to_string(), "c".to_string(), "surplus".to_string()], + ]; + + let m = table::new(vec![ + table::with_width(12), + table::with_columns(&columns), + table::with_rows(&rows), + table::with_styles(plain_styles()), + ]); + + assert_eq!(ansi_strip(&rendered_row(&m)), "a "); + assert!(!ansi_strip(&m.view()).contains("surplus")); +} + +#[test] +fn test_height_is_saturating_at_the_boundary() { + let columns = vec![table::Column { + title: "header".to_string(), + width: 6, + }]; + + let mut before_columns = table::new(vec![ + table::with_height(0), + table::with_columns(&columns), + table::with_styles(plain_styles()), + ]); + let after_columns = table::new(vec![ + table::with_columns(&columns), + table::with_height(0), + table::with_styles(plain_styles()), + ]); + + assert_eq!(before_columns.height(), 0); + assert_eq!(after_columns.height(), 0); + before_columns.set_height(0); + assert_eq!(before_columns.height(), 0); +} +#[test] +fn test_navigation_saturates_at_cursor_bounds() { + let rows = vec![ + vec!["one".to_string()], + vec!["two".to_string()], + vec!["three".to_string()], + ]; + let mut m = table::new(vec![ + table::with_columns(&[table::Column { + title: "value".to_string(), + width: 5, + }]), + table::with_rows(&rows), + ]); + + m.set_cursor(1); + m.move_up(usize::MAX); + assert_eq!(m.cursor(), 0); + m.move_down(usize::MAX); + assert_eq!(m.cursor(), rows.len() - 1); +} + +#[test] +fn test_zero_height_navigation_is_safe() { + let rows = vec![ + vec!["one".to_string()], + vec!["two".to_string()], + vec!["three".to_string()], + ]; + let mut m = table::new(vec![ + table::with_columns(&[table::Column { + title: "value".to_string(), + width: 5, + }]), + table::with_rows(&rows), + table::with_height(0), + ]); + + m.move_down(usize::MAX); + m.move_up(usize::MAX); + m.goto_bottom(); + m.goto_top(); + + assert_eq!(m.height(), 0); + assert_eq!(m.cursor(), 0); + assert_eq!(m.view().lines().count(), 1); +} From bcefddc336d24c806bb34a2a1e0abfe23b3af77e Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 09:31:54 +0100 Subject: [PATCH 02/15] fix(ci): isolate coverage badge publication --- .github/workflows/ci.yml | 35 +++++++++++++++---- .../acceptance/BUI-012/independent-review.md | 2 ++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cde152..a856265 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,8 +119,6 @@ jobs: name: Coverage runs-on: ubuntu-latest needs: gate - permissions: - contents: write steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -189,6 +187,31 @@ jobs: run: | cargo tarpaulin --all-features --out xml --output-dir target/tarpaulin -- --test-threads=1 2>&1 | tee /tmp/tarpaulin.log + - name: Upload coverage report for dev badge + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: target/tarpaulin/cobertura.xml + if-no-files-found: error + retention-days: 1 + + coverage-badge: + name: Update coverage badge + runs-on: ubuntu-latest + needs: coverage + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: dev + - name: Download coverage report + uses: actions/download-artifact@v4 + with: + name: coverage-report + path: target/tarpaulin - name: Write coverage badge in place run: | raw="$(grep -oE 'line-rate="[0-9.]+' target/tarpaulin/cobertura.xml | head -1 | grep -oE '[0-9.]+')" @@ -205,12 +228,12 @@ jobs: esac curl -sS "https://img.shields.io/badge/coverage-${pct}%25-${color}.svg" -o coverage.svg # Commit the badge in place only when it changed (git diff ignores - # untracked files, so use status), so the push cannot re-trigger CI - # indefinitely. + # untracked files, so use status). The skip marker prevents this + # trusted publication commit from starting another CI run. git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if [ -n "$(git status --porcelain -- coverage.svg)" ]; then git add coverage.svg - git commit -q -m "docs: update coverage badge ($pct%)" - git push -q origin dev + git commit -q -m "docs: update coverage badge ($pct%) [skip ci]" + git push -q origin HEAD:dev fi diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md index 5e669fb..87e1627 100644 --- a/evidence/acceptance/BUI-012/independent-review.md +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -8,6 +8,7 @@ The rusty-bubbles table now materializes every declared column deterministically ## Scope +- `.github/workflows/ci.yml`: separates PR coverage validation from trusted `dev` badge publication and prevents the PR checkout from attempting to push `dev`. - `Cargo.toml`: declares the standalone Cargo resolver and the compiler floor used by the candidate. - `src/lib.rs`: documents the crate facade as a user-facing component collection. - `src/table.rs`: hardens outer-height arithmetic, cursor/viewport movement, and ragged-row rendering. @@ -34,5 +35,6 @@ The rusty-bubbles table now materializes every declared column deterministically - `cargo doc --no-deps --all-features`: passed without warnings. - `rustdoc --test docs/src/lib.rs`: 1 passed. - `scripts/verify_mapping.sh`: passed; the optional local `upstream-go/` checkout was absent as documented by the script. +- `yq eval '.' .github/workflows/ci.yml`: passed; the coverage badge publication is guarded to push events on `dev` and uses `HEAD:dev [skip ci]`. This packet is implementation evidence, not an approval or merge authorization. The final independent review, exact-head protected CI, and post-merge absorb transition remain owned by the Mutate lifecycle. From b1a42d2a8f6cf48597dc8eeb997aba627dcbde5a Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 10:19:35 +0100 Subject: [PATCH 03/15] fix(table): close review boundary findings --- .../acceptance/BUI-012/independent-review.md | 5 +- src/table.rs | 47 ++++++++++++++++++- tests/table_test.rs | 25 +++++++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md index 87e1627..bf102f3 100644 --- a/evidence/acceptance/BUI-012/independent-review.md +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -33,8 +33,9 @@ The rusty-bubbles table now materializes every declared column deterministically - `cargo fmt --all --check`: passed. - `cargo clippy --all-targets -- -D warnings`: passed. - `cargo doc --no-deps --all-features`: passed without warnings. -- `rustdoc --test docs/src/lib.rs`: 1 passed. +- `cargo test --doc table:: --no-fail-fast`: 6 table doctests passed, including the changed public operations. +- `cargo build --lib`, followed by `BUI012_DOC_RLIB=$(find target/debug/deps -maxdepth 1 -type f -name 'librusty_bubbles-*.rlib' -print -quit); rustdoc --test docs/src/lib.rs --edition=2021 --extern rusty_bubbles="$BUI012_DOC_RLIB" -L dependency=target/debug/deps`: 1 passed. - `scripts/verify_mapping.sh`: passed; the optional local `upstream-go/` checkout was absent as documented by the script. -- `yq eval '.' .github/workflows/ci.yml`: passed; the coverage badge publication is guarded to push events on `dev` and uses `HEAD:dev [skip ci]`. +- `yq '.' .github/workflows/ci.yml`: passed; the coverage badge publication is guarded to push events on `dev` and uses `HEAD:dev [skip ci]`. This packet is implementation evidence, not an approval or merge authorization. The final independent review, exact-head protected CI, and post-merge absorb transition remain owned by the Mutate lifecycle. diff --git a/src/table.rs b/src/table.rs index a101193..ddc37c9 100644 --- a/src/table.rs +++ b/src/table.rs @@ -221,6 +221,15 @@ pub fn with_rows(rows: &[Row]) -> Option { /// The header consumes part of the requested height. Values smaller than the /// rendered header clamp the content viewport to zero instead of wrapping /// through usize arithmetic. +/// +/// # Examples +/// +/// ``` +/// use rusty_bubbles::table; +/// +/// let model = table::new(vec![table::with_height(1)]); +/// assert_eq!(model.height(), 0); +/// ``` pub fn with_height(h: usize) -> Option { Box::new(move |m: &mut Model| { let hh = rusty_lipgloss::size::height(&m.headers_view()); @@ -325,6 +334,15 @@ impl Model { /// UpdateViewport updates the list content based on the previously /// defined columns and rows. + /// + /// # Examples + /// + /// ```no_run + /// use rusty_bubbles::table; + /// + /// let mut model = table::new(vec![]); + /// model.update_viewport(); + /// ``` pub fn update_viewport(&mut self) { let mut rendered_rows: Vec = Vec::with_capacity(self.rows.len()); @@ -338,7 +356,7 @@ impl Model { self.cursor, ); self.end = clamp( - self.cursor + self.viewport.height(), + self.cursor.saturating_add(self.viewport.height()), self.cursor, self.rows.len(), ); @@ -401,6 +419,15 @@ impl Model { /// The header consumes part of the requested height. Values smaller than /// the rendered header clamp the content viewport to zero instead of /// wrapping through usize arithmetic. + /// + /// # Examples + /// + /// ```no_run + /// use rusty_bubbles::table; + /// + /// let mut model = table::new(vec![]); + /// model.set_height(1); + /// ``` pub fn set_height(&mut self, h: usize) { let hh = rusty_lipgloss::size::height(&self.headers_view()); self.viewport.set_height(h.saturating_sub(hh)); @@ -430,6 +457,15 @@ impl Model { /// MoveUp moves the selection up by any number of rows. /// It can not go above the first row. + /// + /// # Examples + /// + /// ```no_run + /// use rusty_bubbles::table; + /// + /// let mut model = table::new(vec![]); + /// model.move_up(usize::MAX); + /// ``` pub fn move_up(&mut self, n: usize) { // Upstream uses signed ints and clamps to 0; saturating subtraction // mirrors that without overflowing. @@ -463,6 +499,15 @@ impl Model { /// MoveDown moves the selection down by any number of rows. /// It can not go below the last row. + /// + /// # Examples + /// + /// ```no_run + /// use rusty_bubbles::table; + /// + /// let mut model = table::new(vec![]); + /// model.move_down(usize::MAX); + /// ``` pub fn move_down(&mut self, n: usize) { self.cursor = clamp( self.cursor.saturating_add(n), diff --git a/tests/table_test.rs b/tests/table_test.rs index b26943f..ec8cc21 100644 --- a/tests/table_test.rs +++ b/tests/table_test.rs @@ -681,7 +681,12 @@ fn test_ragged_rows_render_against_declared_columns() { ]; let rows = vec![ vec!["a".to_string()], - vec!["b".to_string(), "c".to_string(), "surplus".to_string()], + vec![ + "b".to_string(), + "c".to_string(), + "d".to_string(), + "surplus".to_string(), + ], ]; let m = table::new(vec![ @@ -692,7 +697,12 @@ fn test_ragged_rows_render_against_declared_columns() { ]); assert_eq!(ansi_strip(&rendered_row(&m)), "a "); - assert!(!ansi_strip(&m.view()).contains("surplus")); + + let rendered = ansi_strip(&m.view()); + assert!(rendered.contains('b')); + assert!(rendered.contains('c')); + assert!(rendered.contains('d')); + assert!(!rendered.contains("surplus")); } #[test] @@ -738,6 +748,17 @@ fn test_navigation_saturates_at_cursor_bounds() { assert_eq!(m.cursor(), 0); m.move_down(usize::MAX); assert_eq!(m.cursor(), rows.len() - 1); + + let mut maximum_viewport = table::new(vec![ + table::with_columns(&[table::Column { + title: "value".to_string(), + width: 5, + }]), + table::with_rows(&rows), + table::with_height(usize::MAX), + ]); + maximum_viewport.move_down(2); + assert_eq!(maximum_viewport.cursor(), rows.len() - 1); } #[test] From c0cf96a45eb0df7af46b811ef6865a0f3ef07bba Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 06:03:22 +0100 Subject: [PATCH 04/15] fix(bui-012): restore exact-head docs evidence --- .github/workflows/ci.yml | 2 ++ docs/projection.yaml | 6 +++++ scripts/verify_docs_projection.sh | 45 +++++++++++++++++++++++++++++++ src/table.rs | 27 +++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 docs/projection.yaml create mode 100644 scripts/verify_docs_projection.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba56ab8..8d79b43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,8 @@ jobs: run: cargo build --all-targets - name: Docgen run: cargo doc --no-deps + - name: verify_docs_projection + run: bash ./scripts/verify_docs_projection.sh - name: Test (all targets, all integration tests) run: cargo test --all-targets - name: verify_mapping diff --git a/docs/projection.yaml b/docs/projection.yaml new file mode 100644 index 0000000..558ff2b --- /dev/null +++ b/docs/projection.yaml @@ -0,0 +1,6 @@ +schemaVersion: 1 +projection: + audience: user + source: docs/src/lib.rs + module: src/table.rs + destination: rusty-bubbles diff --git a/scripts/verify_docs_projection.sh b/scripts/verify_docs_projection.sh new file mode 100644 index 0000000..310fa8f --- /dev/null +++ b/scripts/verify_docs_projection.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +test -f docs/projection.yaml +test -f docs/src/lib.rs +test -f src/table.rs + +grep -Fxq ' audience: user' docs/projection.yaml +grep -Fxq ' source: docs/src/lib.rs' docs/projection.yaml +grep -Fxq ' module: src/table.rs' docs/projection.yaml +grep -Fxq ' destination: rusty-bubbles' docs/projection.yaml +grep -Fxq '//! ' src/table.rs +grep -Fxq '//! ' src/table.rs +grep -Fxq '//! ' docs/src/lib.rs +grep -Fxq '//! ' docs/src/lib.rs +grep -Fq 'rusty_bubbles::table' docs/src/lib.rs + +BUI012_DOC_RLIB="$( + cargo build --lib --message-format=json-render-diagnostics | + node -e ' + const fs = require("node:fs"); + let rlib = ""; + for (const line of fs.readFileSync(0, "utf8").split(/\r?\n/u)) { + if (line.trim() === "") continue; + let message; + try { message = JSON.parse(line); } catch { continue; } + if (message.reason !== "compiler-artifact") continue; + if (message.target?.name !== "rusty_bubbles") continue; + if (!message.target?.kind?.includes("lib")) continue; + rlib = message.filenames?.find((name) => name.endsWith(".rlib")) ?? rlib; + } + if (rlib === "") process.exit(1); + process.stdout.write(rlib); + ' +)" +test -n "$BUI012_DOC_RLIB" +rustdoc --test docs/src/lib.rs \ + --edition=2021 \ + --extern "rusty_bubbles=$BUI012_DOC_RLIB" \ + -L dependency=target/debug/deps + +echo "OK: docs projection is mapped and compiled" diff --git a/src/table.rs b/src/table.rs index ddc37c9..845d49c 100644 --- a/src/table.rs +++ b/src/table.rs @@ -7,6 +7,33 @@ //! A simple table component for Bubble Tea applications. //! +//! +//! +//! # Table +//! +//! `table::Model` renders a typed collection of rows against declared +//! columns. Rows shorter than the column list render empty cells; surplus row +//! values are ignored, so input shape cannot change the rendered table or +//! panic the renderer. +//! +//! Configure dimensions with `with_width` and `with_height`, provide +//! `Column` definitions with `with_columns`, and provide `Row` values +//! with `with_rows`. Cursor movement and viewport updates saturate at their +//! valid bounds, including zero-height and maximum-input cases. +//! +//! ```rust +//! use rusty_bubbles::table::{self, Column}; +//! +//! let model = table::new(vec![ +//! table::with_width(16), +//! table::with_columns(&[Column { title: "Name".into(), width: 8 }]), +//! table::with_rows(&[vec!["Bubbles".into()]]), +//! ]); +//! assert_eq!(model.selected_row().unwrap()[0], "Bubbles"); +//! assert!(model.view().contains("Bubbles")); +//! ``` +//! + use crate::help; use crate::key::{self, Binding}; use crate::viewport; From 1d2014891f8a5755a4fb348c39f4439d9dc64ecd Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 06:05:57 +0100 Subject: [PATCH 05/15] fix(bui-012): honor shared cargo target --- scripts/verify_docs_projection.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/verify_docs_projection.sh b/scripts/verify_docs_projection.sh index 310fa8f..6e32caf 100644 --- a/scripts/verify_docs_projection.sh +++ b/scripts/verify_docs_projection.sh @@ -18,6 +18,7 @@ grep -Fxq '//! ' docs/src/lib.rs grep -Fxq '//! ' docs/src/lib.rs grep -Fq 'rusty_bubbles::table' docs/src/lib.rs +target_dir="${CARGO_TARGET_DIR:-target}" BUI012_DOC_RLIB="$( cargo build --lib --message-format=json-render-diagnostics | node -e ' @@ -40,6 +41,6 @@ test -n "$BUI012_DOC_RLIB" rustdoc --test docs/src/lib.rs \ --edition=2021 \ --extern "rusty_bubbles=$BUI012_DOC_RLIB" \ - -L dependency=target/debug/deps + -L "dependency=$target_dir/debug/deps" echo "OK: docs projection is mapped and compiled" From 284590fae2a56951e8350e495d375498173851ca Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 06:09:31 +0100 Subject: [PATCH 06/15] fix(bui-012): link docs from cargo build dir --- scripts/verify_docs_projection.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/verify_docs_projection.sh b/scripts/verify_docs_projection.sh index 6e32caf..9aba880 100644 --- a/scripts/verify_docs_projection.sh +++ b/scripts/verify_docs_projection.sh @@ -18,7 +18,7 @@ grep -Fxq '//! ' docs/src/lib.rs grep -Fxq '//! ' docs/src/lib.rs grep -Fq 'rusty_bubbles::table' docs/src/lib.rs -target_dir="${CARGO_TARGET_DIR:-target}" +build_dir="${CARGO_BUILD_BUILD_DIR:-${CARGO_TARGET_DIR:-target}}" BUI012_DOC_RLIB="$( cargo build --lib --message-format=json-render-diagnostics | node -e ' @@ -41,6 +41,6 @@ test -n "$BUI012_DOC_RLIB" rustdoc --test docs/src/lib.rs \ --edition=2021 \ --extern "rusty_bubbles=$BUI012_DOC_RLIB" \ - -L "dependency=$target_dir/debug/deps" + -L "dependency=$build_dir/debug/deps" echo "OK: docs projection is mapped and compiled" From 8f21bb14a0dfe4473d627048c68bfb087877e6fa Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 06:13:46 +0100 Subject: [PATCH 07/15] docs(bui-012): refresh review evidence --- .../acceptance/BUI-012/independent-review.md | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md index bf102f3..032c2d5 100644 --- a/evidence/acceptance/BUI-012/independent-review.md +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -1,20 +1,40 @@ # BUI-012 Implementation Evidence -Status: implementation self-review complete; independent review, protected CI, and merge authorization remain lifecycle gates. +Status: current exact-head implementation evidence for material repair +`284590fae2a56951e8350e495d375498173851ca`; the R-01 replay and merge +authorization remain lifecycle gates. This packet is not merge authorization. -## Result +## Historical boundary + +The previous packet covered `b1a42d2a8f6cf48597dc8eeb997aba627dcbde5a` over +base `2a88f46...`; it is historical only. The synchronized candidate includes +current `dev` at `bea9af850c7891d2006ce7c581f80846df99ece7`, Rust `1.98.0`, +immutable action references, release-boundary guards, and the API-based trusted +`dev` coverage-badge publication. The previous statement that the workflow +publishes with `git push HEAD:dev [skip ci]` is historical; the current workflow +uses the guarded GitHub API publication path and does not push from pull-request +coverage. + +## Current result The rusty-bubbles table now materializes every declared column deterministically. A row with fewer values produces empty cells, surplus values cannot index past the column definition, and zero or maximum navigation inputs do not panic. Outer-height subtraction clamps at zero while preserving the existing option-application semantics. -## Scope +## Current change set + +The current pull request change set is: -- `.github/workflows/ci.yml`: separates PR coverage validation from trusted `dev` badge publication and prevents the PR checkout from attempting to push `dev`. -- `Cargo.toml`: declares the standalone Cargo resolver and the compiler floor used by the candidate. +- `.github/workflows/ci.yml`: runs the exact-head documentation projection check. +- `UPSTREAM_MAPPING.md`: records the Rust-side table boundary adaptation. +- `docs/projection.yaml`: maps the user documentation source to the table module. +- `docs/src/lib.rs`: documents the table shape contract and contains a compiling user-facing example. +- `evidence/acceptance/BUI-012/independent-review.md`: this exact-head evidence packet. +- `scripts/verify_docs_projection.sh`: compiles the documentation projection against the built library. - `src/lib.rs`: documents the crate facade as a user-facing component collection. -- `src/table.rs`: hardens outer-height arithmetic, cursor/viewport movement, and ragged-row rendering. +- `src/table.rs`: hardens outer-height arithmetic, cursor/viewport movement, ragged-row rendering, and the public user-documentation contract. - `tests/table_test.rs`: adds deterministic ragged-row, zero-height, and maximum-input coverage while extending the upstream overflow case. -- `docs/src/lib.rs`: documents the table shape contract and contains a compiling user-facing example. -- `UPSTREAM_MAPPING.md`: records the Rust-side table boundary adaptation. + +The synchronized `dev` changes to `Cargo.toml` and the CI security/release +configuration are imported base changes, not additional BUI-012-owned edits. ## Contract checks @@ -25,6 +45,7 @@ The rusty-bubbles table now materializes every declared column deterministically | Cursor movement handles maximum inputs | Pass | `tests/table_test.rs::test_navigation_saturates_at_cursor_bounds` | | Zero-height navigation is safe | Pass | `tests/table_test.rs::test_zero_height_navigation_is_safe` | | Sibling dependency direction remains unchanged | Pass | `Cargo.toml`; path dependencies remain rusty-bubbletea, rusty-lipgloss, and rusty-x-ansi | +| User-facing table documentation is projected and compilable | Pass | `docs/projection.yaml`; `scripts/verify_docs_projection.sh`; `src/table.rs`; `docs/src/lib.rs` | ## Focused validation @@ -34,8 +55,23 @@ The rusty-bubbles table now materializes every declared column deterministically - `cargo clippy --all-targets -- -D warnings`: passed. - `cargo doc --no-deps --all-features`: passed without warnings. - `cargo test --doc table:: --no-fail-fast`: 6 table doctests passed, including the changed public operations. -- `cargo build --lib`, followed by `BUI012_DOC_RLIB=$(find target/debug/deps -maxdepth 1 -type f -name 'librusty_bubbles-*.rlib' -print -quit); rustdoc --test docs/src/lib.rs --edition=2021 --extern rusty_bubbles="$BUI012_DOC_RLIB" -L dependency=target/debug/deps`: 1 passed. -- `scripts/verify_mapping.sh`: passed; the optional local `upstream-go/` checkout was absent as documented by the script. -- `yq '.' .github/workflows/ci.yml`: passed; the coverage badge publication is guarded to push events on `dev` and uses `HEAD:dev [skip ci]`. +- `bash ./scripts/verify_docs_projection.sh`: passed; the projection manifest and the `docs/src/lib.rs` example compile against the current library. The same check passed under the CI split `CARGO_TARGET_DIR`/`CARGO_BUILD_BUILD_DIR` layout. +- `scripts/verify_mapping.sh`: passed in protected CI; the optional local `upstream-go/` checkout is absent as documented by the script. +- `./scripts/test-release-guards.sh`: passed. +- `yq '.' .github/workflows/ci.yml`: passed. +- Protected CI run [`33235506720`](https://github.com/coderbants/rusty-bubbles/actions/runs/33235506720) passed on exact head `284590fae2a56951e8350e495d375498173851ca`: version gate, lint/build/docs/tests, documentation projection, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. + +The repository-wide `cargo test --doc --no-fail-fast` run remains a known +out-of-scope baseline failure in the unchanged `src/key.rs` example/API +signature; all BUI-012 table doctests and the dedicated documentation +projection test pass. + +## Review boundary + +R-01 requested two repairs: refresh this packet after base synchronization and +add a complete `` contract plus exact-head projection proof. The +material repairs are present at `284590fae2a56951e8350e495d375498173851ca`; +the repaired head must be sent back to the same reviewer for confirmation. -This packet is implementation evidence, not an approval or merge authorization. The final independent review, exact-head protected CI, and post-merge absorb transition remain owned by the Mutate lifecycle. +The final independent review, exact-head protected CI, and post-merge absorb +transition remain owned by the Mutate lifecycle. From 2e85a43cd03b95444a86db90810da10b7d7248d8 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 06:33:51 +0100 Subject: [PATCH 08/15] test(bui-012): protect module table doctest --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d79b43..b427b1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,8 @@ jobs: run: cargo doc --no-deps - name: verify_docs_projection run: bash ./scripts/verify_docs_projection.sh + - name: Table doctests + run: cargo test --doc table --no-fail-fast - name: Test (all targets, all integration tests) run: cargo test --all-targets - name: verify_mapping From 41cc48b161060842b99a6bd2048de59cbcb011ed Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 06:37:26 +0100 Subject: [PATCH 09/15] docs(bui-012): record table doctest coverage --- .../acceptance/BUI-012/independent-review.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md index 032c2d5..adc5871 100644 --- a/evidence/acceptance/BUI-012/independent-review.md +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -1,8 +1,9 @@ # BUI-012 Implementation Evidence -Status: current exact-head implementation evidence for material repair -`284590fae2a56951e8350e495d375498173851ca`; the R-01 replay and merge -authorization remain lifecycle gates. This packet is not merge authorization. +Status: current exact-head implementation evidence for candidate +`2e85a43cd03b95444a86db90810da10b7d7248d8`; R-01 replay, R-02 replay, and +merge authorization remain lifecycle gates. This packet is not merge +authorization. ## Historical boundary @@ -23,7 +24,7 @@ The rusty-bubbles table now materializes every declared column deterministically The current pull request change set is: -- `.github/workflows/ci.yml`: runs the exact-head documentation projection check. +- `.github/workflows/ci.yml`: runs the exact-head documentation projection check and the complete seven-test table doctest selection. - `UPSTREAM_MAPPING.md`: records the Rust-side table boundary adaptation. - `docs/projection.yaml`: maps the user documentation source to the table module. - `docs/src/lib.rs`: documents the table shape contract and contains a compiling user-facing example. @@ -55,11 +56,12 @@ configuration are imported base changes, not additional BUI-012-owned edits. - `cargo clippy --all-targets -- -D warnings`: passed. - `cargo doc --no-deps --all-features`: passed without warnings. - `cargo test --doc table:: --no-fail-fast`: 6 table doctests passed, including the changed public operations. +- `cargo test --doc table --no-fail-fast`: 7 table doctests passed, including the module-level user-facing example. - `bash ./scripts/verify_docs_projection.sh`: passed; the projection manifest and the `docs/src/lib.rs` example compile against the current library. The same check passed under the CI split `CARGO_TARGET_DIR`/`CARGO_BUILD_BUILD_DIR` layout. - `scripts/verify_mapping.sh`: passed in protected CI; the optional local `upstream-go/` checkout is absent as documented by the script. - `./scripts/test-release-guards.sh`: passed. - `yq '.' .github/workflows/ci.yml`: passed. -- Protected CI run [`33235506720`](https://github.com/coderbants/rusty-bubbles/actions/runs/33235506720) passed on exact head `284590fae2a56951e8350e495d375498173851ca`: version gate, lint/build/docs/tests, documentation projection, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. +- Protected CI run [`33236464259`](https://github.com/coderbants/rusty-bubbles/actions/runs/33236464259) passed on exact head `2e85a43cd03b95444a86db90810da10b7d7248d8`: version gate, lint/build/docs/tests, documentation projection, seven table doctests, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. The repository-wide `cargo test --doc --no-fail-fast` run remains a known out-of-scope baseline failure in the unchanged `src/key.rs` example/API @@ -69,9 +71,12 @@ projection test pass. ## Review boundary R-01 requested two repairs: refresh this packet after base synchronization and -add a complete `` contract plus exact-head projection proof. The -material repairs are present at `284590fae2a56951e8350e495d375498173851ca`; -the repaired head must be sent back to the same reviewer for confirmation. +add a complete `` contract plus exact-head projection proof. R-01 +replay passed with no findings on `8f21bb1`. R-02 then identified +`TST-R02-001`: the module-level table doctest was not protected by CI. The +protected `cargo test --doc table --no-fail-fast` step now covers all seven +table doctests on `2e85a43`; the repaired head must be sent back to the Testing +Specialist for confirmation. The final independent review, exact-head protected CI, and post-merge absorb transition remain owned by the Mutate lifecycle. From ca848b803ff2e548fe272cc7a2b04a4206b26ed5 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 06:40:59 +0100 Subject: [PATCH 10/15] docs(bui-012): bind doctest evidence to current head --- evidence/acceptance/BUI-012/independent-review.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md index adc5871..0f591e1 100644 --- a/evidence/acceptance/BUI-012/independent-review.md +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -1,9 +1,10 @@ # BUI-012 Implementation Evidence Status: current exact-head implementation evidence for candidate -`2e85a43cd03b95444a86db90810da10b7d7248d8`; R-01 replay, R-02 replay, and +`41cc48b161060842b99a6bd2048de59cbcb011ed`; R-01 replay, R-02 replay, and merge authorization remain lifecycle gates. This packet is not merge -authorization. +authorization. The current head is an evidence-only descendant of the +material CI repair `2e85a43cd03b95444a86db90810da10b7d7248d8`. ## Historical boundary @@ -61,7 +62,7 @@ configuration are imported base changes, not additional BUI-012-owned edits. - `scripts/verify_mapping.sh`: passed in protected CI; the optional local `upstream-go/` checkout is absent as documented by the script. - `./scripts/test-release-guards.sh`: passed. - `yq '.' .github/workflows/ci.yml`: passed. -- Protected CI run [`33236464259`](https://github.com/coderbants/rusty-bubbles/actions/runs/33236464259) passed on exact head `2e85a43cd03b95444a86db90810da10b7d7248d8`: version gate, lint/build/docs/tests, documentation projection, seven table doctests, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. +- Protected CI run [`33236603654`](https://github.com/coderbants/rusty-bubbles/actions/runs/33236603654) passed on exact head `41cc48b161060842b99a6bd2048de59cbcb011ed`: version gate, lint/build/docs/tests, documentation projection, seven table doctests, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. The repository-wide `cargo test --doc --no-fail-fast` run remains a known out-of-scope baseline failure in the unchanged `src/key.rs` example/API @@ -75,8 +76,9 @@ add a complete `` contract plus exact-head projection proof. R-01 replay passed with no findings on `8f21bb1`. R-02 then identified `TST-R02-001`: the module-level table doctest was not protected by CI. The protected `cargo test --doc table --no-fail-fast` step now covers all seven -table doctests on `2e85a43`; the repaired head must be sent back to the Testing -Specialist for confirmation. +table doctests on `2e85a43`, and the exact current head `41cc48b` has a passing +protected rerun; it must now be sent back to the Testing Specialist for +confirmation. The final independent review, exact-head protected CI, and post-merge absorb transition remain owned by the Mutate lifecycle. From 07704c6dbc6ad368219579f7cc75d72e75c17a4e Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 07:36:59 +0100 Subject: [PATCH 11/15] fix(security): isolate release publication from verification --- .github/workflows/publish.yml | 167 +++++++++++++----- .../acceptance/BUI-012/independent-review.md | 33 ++-- scripts/test-release-guards.sh | 130 ++++++++++++-- scripts/verify_release_admission.sh | 87 +++++++++ 4 files changed, 349 insertions(+), 68 deletions(-) create mode 100755 scripts/verify_release_admission.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6e3bd97..992cece 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,15 +6,19 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: - publish: - name: Publish Crate + verify: + name: Verify and package release runs-on: ubuntu-latest + permissions: + contents: read + actions: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: + fetch-depth: 0 persist-credentials: false - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: @@ -22,11 +26,13 @@ jobs: components: clippy, rustfmt - name: Verify version and tag match upstream run: scripts/verify_upstream_version.sh "$GITHUB_REF_NAME" + - name: Verify tag, ancestry, and CI admission + id: admission + env: + GH_TOKEN: ${{ github.token }} + run: bash scripts/verify_release_admission.sh - name: Configure shared Cargo cache run: | - # The runner image pre-sets CARGO_HOME=$HOME/.cargo; drop the - # ambient value so the shared-cache defaults apply (the resolver - # rejects it as an uncontained override otherwise). unset CARGO_HOME . scripts/cargo-env.sh configure_shared_cargo_cache_environment @@ -83,7 +89,7 @@ jobs: persist-credentials: false - name: Place sibling crates run: | - mkdir -p ../siblings && true + mkdir -p ../siblings mv siblings/rusty-bubbletea ../rusty-bubbletea mv siblings/rusty-colorprofile ../rusty-colorprofile mv siblings/rusty-lipgloss ../rusty-lipgloss @@ -98,52 +104,133 @@ jobs: run: cargo build --all-targets - name: Docgen run: cargo doc --no-deps + - name: Table doctests + run: cargo test --doc table --no-fail-fast - name: Test (all targets, all integration tests) run: cargo test --all-targets - name: Upstream mapping verification run: ./scripts/verify_mapping.sh - # Releases are tag-gated: only pushes of a v* tag publish. crates.io - # rejects re-publishing an existing version, so the version-bump gate - # in ci.yml keeps every release on a fresh, unreleased version. + - name: Release-boundary guard tests + run: ./scripts/test-release-guards.sh + - name: Package release artifacts from a clean source archive + id: package + env: + CI_RUN_ID: ${{ steps.admission.outputs.ci_run_id }} + run: | + set -euo pipefail + workspace="$RUNNER_TEMP/release-workspace" + rm -rf "$workspace" + mkdir -p "$workspace/rusty-bubbles" + git archive --format=tar "$GITHUB_SHA" | tar -xf - -C "$workspace/rusty-bubbles" + for sibling in rusty-bubbletea rusty-colorprofile rusty-lipgloss rusty-testkit rusty-ultraviolet rusty-x-ansi; do + mkdir -p "$workspace/$sibling" + git -C "$GITHUB_WORKSPACE/../$sibling" archive --format=tar HEAD | tar -xf - -C "$workspace/$sibling" + done + rm -f "$workspace/rusty-bubbles/.command-whitelist" "$workspace/rusty-bubbles/whitelist-exec.sh" "$workspace/rusty-bubbles/.command-whitelist-log" + package_target="$workspace/target" + (cd "$workspace/rusty-bubbles" && env -u CARGO_BUILD_BUILD_DIR CARGO_TARGET_DIR="$package_target" cargo package --locked --no-verify --allow-dirty) + crate_file="rusty-bubbles-${GITHUB_REF_NAME#v}.crate" + crate_path="$package_target/package/$crate_file" + test -f "$crate_path" + artifact_dir="$RUNNER_TEMP/release-artifacts" + rm -rf "$artifact_dir" + mkdir -p "$artifact_dir" + cp "$crate_path" "$artifact_dir/$crate_file" + source_archive="${GITHUB_REPOSITORY##*/}-$GITHUB_REF_NAME.tar.gz" + git archive --format=tar.gz --prefix="${GITHUB_REPOSITORY##*/}-$GITHUB_REF_NAME/" -o "$artifact_dir/$source_archive" "$GITHUB_SHA" + crate_sha256="$(sha256sum "$artifact_dir/$crate_file" | awk '{print $1}')" + source_sha256="$(sha256sum "$artifact_dir/$source_archive" | awk '{print $1}')" + jq -n --arg tag "$GITHUB_REF_NAME" --arg commit "$GITHUB_SHA" --arg ci_run_id "$CI_RUN_ID" --arg crate_file "$crate_file" --arg crate_sha256 "$crate_sha256" --arg source_archive "$source_archive" --arg source_sha256 "$source_sha256" '{tag: $tag, commit: $commit, ci_run_id: $ci_run_id, crate_file: $crate_file, crate_sha256: $crate_sha256, source_archive: $source_archive, source_sha256: $source_sha256}' > "$artifact_dir/release-manifest.json" + - name: Upload verified release artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-artifacts-${{ github.sha }} + path: ${{ runner.temp }}/release-artifacts + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish verified release + needs: verify + if: needs.verify.result == 'success' + runs-on: ubuntu-latest + # Configure this environment with required reviewers in repository settings. + environment: + name: release + permissions: + contents: write + actions: read + steps: + - name: Download verified release artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-artifacts-${{ github.sha }} + path: artifacts + - name: Verify artifact binding and digest + run: | + set -euo pipefail + manifest=artifacts/release-manifest.json + test -f "$manifest" + tag="$(jq -er .tag "$manifest")" + commit="$(jq -er .commit "$manifest")" + ci_run_id="$(jq -er .ci_run_id "$manifest")" + crate_file="$(jq -er .crate_file "$manifest")" + crate_sha256="$(jq -er .crate_sha256 "$manifest")" + source_archive="$(jq -er .source_archive "$manifest")" + source_sha256="$(jq -er .source_sha256 "$manifest")" + test "$tag" = "$GITHUB_REF_NAME" + test "$commit" = "$GITHUB_SHA" + test "$ci_run_id" -gt 0 + test "$crate_file" = "rusty-bubbles-${GITHUB_REF_NAME#v}.crate" + case "$crate_file" in /*|*..*) exit 1 ;; esac + case "$source_archive" in /*|*..*) exit 1 ;; esac + test -f "artifacts/$crate_file" + test -f "artifacts/$source_archive" + test "$crate_sha256" = "$(sha256sum "artifacts/$crate_file" | awk '{print $1}')" + test "$source_sha256" = "$(sha256sum "artifacts/$source_archive" | awk '{print $1}')" + tar -tzf "artifacts/$source_archive" >/dev/null + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.98.0 - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/v') env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | - if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - echo "Release $GITHUB_REF_NAME already exists; skipping." + set -euo pipefail + tag="$(jq -er .tag artifacts/release-manifest.json)" + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $tag already exists; preserving it." else - gh release create "$GITHUB_REF_NAME" --generate-notes + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes fi - name: Upload source to GitHub Release - if: startsWith(github.ref, 'refs/tags/v') env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | - src="${GITHUB_REPOSITORY##*/}-$GITHUB_REF_NAME.tar.gz" - # Keep the archive out of the repo working tree so `cargo publish` - # sees a clean checkout. - git archive --format=tar.gz -o "$RUNNER_TEMP/$src" HEAD - gh release upload "$GITHUB_REF_NAME" "$RUNNER_TEMP/$src" --clobber - - name: Publish to crates.io - if: startsWith(github.ref, 'refs/tags/v') + set -euo pipefail + tag="$(jq -er .tag artifacts/release-manifest.json)" + source_archive="$(jq -er .source_archive artifacts/release-manifest.json)" + # Existing assets cause a hard failure; replacements are forbidden. + gh release upload "$tag" "artifacts/$source_archive" --repo "$GITHUB_REPOSITORY" + - name: Publish crate from verified artifact env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | - if [ -n "${CARGO_REGISTRY_TOKEN}" ]; then - # Dev-dependencies are not part of the published crate, but - # `cargo publish` resolves them anyway; drop them from the - # packaging manifest so sibling dev-deps (which may be - # unpublished or cyclically depend on this crate) can't block - # the upload. The published crate is unaffected. - awk '/^\[dev-dependencies\]/ { in_dev=1 } /^\[/ && !/^\[dev-dependencies\]/ { in_dev=0 } !(in_dev && /^rusty-/) { print }' Cargo.toml > Cargo.toml.publish - mv Cargo.toml.publish Cargo.toml - # --no-verify: the verify build resolves dependencies from the - # registry, but ultraviolet's dev-dependency on lipgloss and - # lipgloss's dependency on ultraviolet form a cycle; the full - # gates (build, clippy, tests) already ran above. - cargo publish --no-verify --allow-dirty - git checkout -- Cargo.toml - else - echo "No CARGO_REGISTRY_TOKEN secret; skipping crates.io publish." + set -euo pipefail + : "${CARGO_REGISTRY_TOKEN:?CARGO_REGISTRY_TOKEN secret is required for a release}" + crate_file="$(jq -er .crate_file artifacts/release-manifest.json)" + package_root="$RUNNER_TEMP/verified-crate" + rm -rf "$package_root" + mkdir -p "$package_root" + tar -xzf "artifacts/$crate_file" -C "$package_root" + package_dir="$(find "$package_root" -mindepth 1 -maxdepth 1 -type d -name 'rusty-bubbles-*' -print -quit)" + test -n "$package_dir" + cd "$package_dir" + rm -f Cargo.toml.orig + if test -f build.rs || grep -qE '^[[:space:]]*build[[:space:]]*=' Cargo.toml; then + echo "ERROR: release package contains a build script; review the package before publishing" >&2 + exit 1 fi + # --no-verify prevents build scripts/tests from running on the + # fresh credential-bearing publication runner. + cargo publish --no-verify --locked diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md index 0f591e1..439eb9e 100644 --- a/evidence/acceptance/BUI-012/independent-review.md +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -1,10 +1,11 @@ # BUI-012 Implementation Evidence -Status: current exact-head implementation evidence for candidate -`41cc48b161060842b99a6bd2048de59cbcb011ed`; R-01 replay, R-02 replay, and -merge authorization remain lifecycle gates. This packet is not merge -authorization. The current head is an evidence-only descendant of the -material CI repair `2e85a43cd03b95444a86db90810da10b7d7248d8`. +Status: implementation evidence for material parent +`41cc48b161060842b99a6bd2048de59cbcb011ed`; the current pull-request head is a +later security-remediation descendant. Exact-head CI and review binding for +that descendant is recorded in the GitHub Issue ledger. R-01 replay, R-02 +replay, Security review, and merge authorization remain lifecycle gates. This +packet is not merge authorization. ## Historical boundary @@ -23,20 +24,26 @@ The rusty-bubbles table now materializes every declared column deterministically ## Current change set -The current pull request change set is: +The current pull request change set contains the original table/documentation +implementation and the release-boundary remediation required by the +independent Security review: - `.github/workflows/ci.yml`: runs the exact-head documentation projection check and the complete seven-test table doctest selection. - `UPSTREAM_MAPPING.md`: records the Rust-side table boundary adaptation. - `docs/projection.yaml`: maps the user documentation source to the table module. - `docs/src/lib.rs`: documents the table shape contract and contains a compiling user-facing example. - `evidence/acceptance/BUI-012/independent-review.md`: this exact-head evidence packet. +- `.github/workflows/publish.yml`: separates secretless release verification and packaging from artifact publication, with protected-ref admission and digest-bound artifacts. - `scripts/verify_docs_projection.sh`: compiles the documentation projection against the built library. +- `scripts/verify_release_admission.sh`: fails closed unless a release tag is on `dev`, has an exact successful `dev` CI run, and is covered by no-bypass ref rulesets. +- `scripts/test-release-guards.sh`: enforces immutable workflow/sibling references and the publication trust boundary. - `src/lib.rs`: documents the crate facade as a user-facing component collection. - `src/table.rs`: hardens outer-height arithmetic, cursor/viewport movement, ragged-row rendering, and the public user-documentation contract. - `tests/table_test.rs`: adds deterministic ragged-row, zero-height, and maximum-input coverage while extending the upstream overflow case. -The synchronized `dev` changes to `Cargo.toml` and the CI security/release -configuration are imported base changes, not additional BUI-012-owned edits. +The synchronized `dev` changes to `Cargo.toml` remain imported base changes. +The publication workflow and release guards are ticket-owned remediation for +the Security review because they govern whether this crate can be released. ## Contract checks @@ -61,8 +68,9 @@ configuration are imported base changes, not additional BUI-012-owned edits. - `bash ./scripts/verify_docs_projection.sh`: passed; the projection manifest and the `docs/src/lib.rs` example compile against the current library. The same check passed under the CI split `CARGO_TARGET_DIR`/`CARGO_BUILD_BUILD_DIR` layout. - `scripts/verify_mapping.sh`: passed in protected CI; the optional local `upstream-go/` checkout is absent as documented by the script. - `./scripts/test-release-guards.sh`: passed. +- `bash -n scripts/verify_release_admission.sh scripts/test-release-guards.sh`: passed. - `yq '.' .github/workflows/ci.yml`: passed. -- Protected CI run [`33236603654`](https://github.com/coderbants/rusty-bubbles/actions/runs/33236603654) passed on exact head `41cc48b161060842b99a6bd2048de59cbcb011ed`: version gate, lint/build/docs/tests, documentation projection, seven table doctests, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. +- Historical protected CI run [`33236603654`](https://github.com/coderbants/rusty-bubbles/actions/runs/33236603654) passed on exact head `41cc48b161060842b99a6bd2048de59cbcb011ed`: version gate, lint/build/docs/tests, documentation projection, seven table doctests, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. It is not proof for the later security-remediation descendant; the current exact-head run is recorded in the GitHub Issue ledger. The repository-wide `cargo test --doc --no-fail-fast` run remains a known out-of-scope baseline failure in the unchanged `src/key.rs` example/API @@ -80,5 +88,8 @@ table doctests on `2e85a43`, and the exact current head `41cc48b` has a passing protected rerun; it must now be sent back to the Testing Specialist for confirmation. -The final independent review, exact-head protected CI, and post-merge absorb -transition remain owned by the Mutate lifecycle. +The Security review identified two blocking release-boundary findings and two +non-blocking guard/evidence findings. The remediation is included in this +pull request and must be re-reviewed at its exact final head. The final +independent review, exact-head protected CI, and post-merge absorb transition +remain owned by the Mutate lifecycle. diff --git a/scripts/test-release-guards.sh b/scripts/test-release-guards.sh index fa50117..3872906 100755 --- a/scripts/test-release-guards.sh +++ b/scripts/test-release-guards.sh @@ -1,50 +1,142 @@ #!/usr/bin/env bash -# Regression checks for the release and CI trust boundaries. These checks are +# Regression checks for release and CI trust boundaries. These checks are # intentionally static and fast so every gate can prove that workflow changes -# did not reintroduce mutable dependencies or accidental write access. Keep -# the implementation on GitHub-hosted runner core tools; ripgrep is not -# guaranteed to be installed there. +# did not reintroduce mutable dependencies or accidental write access. set -euo pipefail cd "$(dirname "$0")/.." fail=0 +workflow_files=(.github/workflows/*.yml) report() { - printf 'ERROR: %s\n' "$1" >&2 + echo "ERROR: $1" >&2 fail=1 } -check_pinned_action() { - local action="$1" +if [ ! -e "${workflow_files[0]}" ]; then + report "no GitHub workflow files were found" +fi + +check_all_pinned_actions() { local line local ref - - while IFS= read -r line; do + local action_lines=() + mapfile -t action_lines < <(grep -nHE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*[^@[:space:]]+@[^[:space:]]+' "${workflow_files[@]}" || true) + if [ "${#action_lines[@]}" -eq 0 ]; then + report "no external workflow actions were found to validate" + return + fi + for line in "${action_lines[@]}"; do ref="${line##*@}" + ref="${ref%%[[:space:]]*}" if [[ ! "${ref}" =~ ^[0-9a-f]{40}$ ]]; then - report "${action} must use a full immutable commit SHA: ${line}" + report "every external workflow action must use a full immutable commit SHA: ${line}" fi - done < <(grep -nE "uses: ${action}@" .github/workflows/*.yml) + done } -check_pinned_action "actions/checkout" -check_pinned_action "actions/setup-go" -check_pinned_action "taiki-e/install-action" +check_sibling_refs() { + local sibling=0 + local line + local ref + while IFS= read -r line; do + if [[ "${line}" == *"repository: coderbants/rusty-"* ]]; then + sibling=1 + continue + fi + if (( sibling )) && [[ "${line}" =~ ^[[:space:]]*ref:[[:space:]]*(.+)$ ]]; then + ref="${BASH_REMATCH[1]}" + ref="${ref%%[[:space:]]*}" + if [[ ! "${ref}" =~ ^[0-9a-f]{40}$ ]]; then + report "every sibling checkout must use a full immutable commit SHA: ${line}" + fi + sibling=0 + elif (( sibling )) && [[ "${line}" =~ ^[[:space:]]*-[[:space:]]name: ]]; then + report "sibling checkout is missing an immutable ref" + sibling=0 + fi + done < <(cat "${workflow_files[@]}") + if (( sibling )); then + report "sibling checkout is missing an immutable ref" + fi +} + +check_all_pinned_actions +check_sibling_refs if grep -n 'workflow_dispatch' .github/workflows/publish.yml >/dev/null; then report "publish workflow must not expose a manual dispatch path" fi +if ! sed -n '1,18p' .github/workflows/publish.yml | grep -nE '^permissions:|^ contents: read$' >/dev/null; then + report "publish workflow must default to read-only repository permissions" +fi + +if ! grep -n 'verify_release_admission.sh' .github/workflows/publish.yml >/dev/null; then + report "publish workflow must run the fail-closed release-admission gate" +fi + +if ! grep -n 'git merge-base --is-ancestor' scripts/verify_release_admission.sh >/dev/null; then + report "release admission must require the tag commit to be on dev" +fi + +if ! grep -n -- '--workflow .github/workflows/ci.yml' scripts/verify_release_admission.sh >/dev/null; then + report "release admission must query the exact CI workflow" +fi + +if ! grep -n 'refs/tags/v\*' scripts/verify_release_admission.sh >/dev/null; then + report "release admission must require immutable v* tag protection" +fi + +if ! grep -n 'environment:' .github/workflows/publish.yml >/dev/null || ! grep -n 'name: release' .github/workflows/publish.yml >/dev/null; then + report "publication must be gated by the protected release environment" +fi + +publish_job="$(awk ' + /^ publish:/ { in_job=1 } + in_job && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ publish:/ { exit } + in_job { print } +' .github/workflows/publish.yml)" +verify_job="$(awk ' + /^ verify:/ { in_job=1 } + in_job && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ verify:/ { exit } + in_job { print } +' .github/workflows/publish.yml)" + +if [[ "${verify_job}" == *"contents: write"* || "${verify_job}" == *"CARGO_REGISTRY_TOKEN"* ]]; then + report "secretless verification job must not receive write permission or registry credentials" +fi + +if [[ "${publish_job}" != *"contents: write"* || "${publish_job}" != *"needs: verify"* ]]; then + report "only the artifact publication job may receive contents: write and it must require verification" +fi + +if [[ "${publish_job}" == *"actions/checkout@"* || "${publish_job}" == *"cargo test"* || "${publish_job}" == *"cargo build"* || "${publish_job}" == *"cargo clippy"* ]]; then + report "credential-bearing publication job must not checkout or execute repository verification code" +fi + +if [[ "${publish_job}" != *"actions/download-artifact@"* || "${verify_job}" != *"actions/upload-artifact@"* ]]; then + report "release must exchange a verified artifact between isolated jobs" +fi + +if [[ "${publish_job}" != *"cargo publish --no-verify"* ]]; then + report "publication must use the verified package without running build scripts/tests on the credential-bearing runner" +fi + +if grep -n -- '--clobber' .github/workflows/publish.yml >/dev/null; then + report "release assets must never be silently overwritten" +fi + if awk ' /repository: coderbants\/rusty-/ { sibling=1; next } sibling && /ref: dev/ { bad=1 } sibling && /^ - name:/ { sibling=0 } END { exit bad ? 0 : 1 } ' .github/workflows/ci.yml .github/workflows/publish.yml; then - report "sibling dependency checkouts must use immutable commit refs" + report "sibling dependency checkouts must not use a branch ref" fi if ! grep -n 'git clone --quiet --no-tags' .github/workflows/ci.yml >/dev/null; then @@ -75,7 +167,7 @@ if ! grep -nE 'uses: actions/(upload|download)-artifact@[0-9a-f]{40}' .github/wo report "coverage must exchange its report through immutable artifact actions" fi -if grep -nE 'x-access-token:|git (remote set-url|push).*(GH_TOKEN|\$\{GH_TOKEN\})|cargo publish.*--token' .github/workflows/ci.yml .github/workflows/publish.yml >/dev/null; then +if grep -nE 'x-access-token:|git (remote set-url|push)|cargo publish.*--token' .github/workflows/ci.yml .github/workflows/publish.yml >/dev/null; then report "workflow credentials must not be embedded in URLs or command-line arguments" fi @@ -83,6 +175,10 @@ if ! grep -n 'gh api --method PUT' .github/workflows/ci.yml >/dev/null; then report "coverage badge updates must use the GitHub API credential channel" fi +if ! bash -n scripts/verify_release_admission.sh; then + report "release admission script must pass bash syntax validation" +fi + if ! scripts/verify_upstream_version.sh >/dev/null; then report "the tracked upstream version must pass the release-version guard" fi @@ -91,7 +187,7 @@ if scripts/verify_upstream_version.sh not-a-release-tag >/dev/null 2>&1; then report "the release-version guard must reject non-v tags" fi -if [ "${fail}" -ne 0 ]; then +if [ "$fail" -ne 0 ]; then exit 1 fi diff --git a/scripts/verify_release_admission.sh b/scripts/verify_release_admission.sh new file mode 100755 index 0000000..796ce47 --- /dev/null +++ b/scripts/verify_release_admission.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +# Fail-closed admission checks for the tag-triggered publication workflow. +# This script runs in the secretless verification job with a read-only +# GitHub token. Publication is not admitted from an arbitrary tag, an +# unverified commit, or an unprotected ref. + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_SHA:?GITHUB_SHA is required}" +: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9] ]]; then + echo "ERROR: release admission requires a semantic v* tag, got ${GITHUB_REF_NAME}" >&2 + exit 1 +fi + +git fetch --quiet origin dev --no-tags +if ! git merge-base --is-ancestor "${GITHUB_SHA}" FETCH_HEAD; then + echo "ERROR: tag commit ${GITHUB_SHA} is not an ancestor of origin/dev" >&2 + exit 1 +fi + +ci_runs="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow .github/workflows/ci.yml --commit "${GITHUB_SHA}" --limit 100 --json databaseId,headSha,status,conclusion,event,headBranch)" + +ci_run_id="$(jq -r --arg sha "${GITHUB_SHA}" ' + map(select( + .headSha == $sha + and .status == "completed" + and .conclusion == "success" + and .event == "push" + and .headBranch == "dev" + )) + | sort_by(.databaseId) + | last + | .databaseId // empty +' <<<"${ci_runs}")" + +if [[ -z "${ci_run_id}" ]]; then + echo "ERROR: no successful exact-SHA CI push run on dev admits ${GITHUB_SHA}" >&2 + exit 1 +fi + +if ! rulesets="$(gh api "repos/${GITHUB_REPOSITORY}/rulesets?includes_parents=true&per_page=100")"; then + echo "ERROR: could not read repository ref-protection rulesets; refusing publication" >&2 + exit 1 +fi + +if ! jq -e ' + any(.[]; + .enforcement == "active" + and ((.bypass_actors // []) | length == 0) + and ((.conditions.ref_name.include // []) + | any(. == "refs/heads/dev" or . == "~DEFAULT_BRANCH")) + and ((.rules // []) + | any(.type == "pull_request")) + and ((.rules // []) + | any(.type == "required_status_checks")) + ) +' <<<"${rulesets}" >/dev/null; then + echo "ERROR: no active no-bypass dev protection ruleset with pull-request and status-check rules" >&2 + exit 1 +fi + +if ! jq -e ' + any(.[]; + .enforcement == "active" + and ((.bypass_actors // []) | length == 0) + and ((.conditions.ref_name.include // []) + | any(. == "refs/tags/v*" or . == "refs/tags/*")) + and ((.rules // []) + | any(.type == "deletion")) + and ((.rules // []) + | any(.type == "non_fast_forward")) + ) +' <<<"${rulesets}" >/dev/null; then + echo "ERROR: no active no-bypass immutable v* tag protection ruleset" >&2 + exit 1 +fi + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "ci_run_id=${ci_run_id}" >>"${GITHUB_OUTPUT}" +fi + +echo "OK: release tag ${GITHUB_REF_NAME} at ${GITHUB_SHA} admitted by dev CI run ${ci_run_id} and protected refs" From 736a9ae43c4877348e99a75b24722d01a2f53ff3 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 07:57:00 +0100 Subject: [PATCH 12/15] fix(security): validate complete release rulesets --- scripts/test-release-admission.sh | 71 ++++++++++++ scripts/test-release-guards.sh | 1 + scripts/verify_release_admission.sh | 174 ++++++++++++++++------------ 3 files changed, 175 insertions(+), 71 deletions(-) create mode 100755 scripts/test-release-admission.sh diff --git a/scripts/test-release-admission.sh b/scripts/test-release-admission.sh new file mode 100755 index 0000000..09efc16 --- /dev/null +++ b/scripts/test-release-admission.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# Offline regression tests for the fail-closed ruleset predicates. The live +# admission script remains responsible for fetching the authoritative ruleset +# detail records and checking the tag, ancestry, and CI evidence. + +set -euo pipefail + +cd "$(dirname "$0")/.." +source scripts/verify_release_admission.sh + +missing_bypass_dev="$(jq -n '{ + enforcement: "active", + target: "branch", + conditions: { ref_name: { include: ["refs/heads/dev"], exclude: [] } }, + rules: [{ type: "pull_request" }, { type: "required_status_checks" }] +}')" +if rulesets_admit_dev <<<"[${missing_bypass_dev}]"; then + echo "ERROR: omitted bypass_actors must not admit a dev ruleset" >&2 + exit 1 +fi + +valid_dev="$(jq -n '{ + enforcement: "active", + target: "branch", + bypass_actors: [], + conditions: { ref_name: { include: ["refs/heads/dev"], exclude: [] } }, + rules: [{ type: "pull_request" }, { type: "required_status_checks" }] +}')" +if ! rulesets_admit_dev <<<"[${valid_dev}]"; then + echo "ERROR: a complete protected dev ruleset must admit" >&2 + exit 1 +fi + +tag_without_update="$(jq -n '{ + enforcement: "active", + target: "tag", + bypass_actors: [], + conditions: { ref_name: { include: ["refs/tags/v*"], exclude: [] } }, + rules: [{ type: "deletion" }, { type: "non_fast_forward" }] +}')" +if rulesets_admit_tag <<<"[${tag_without_update}]"; then + echo "ERROR: tag rulesets without update protection must not admit" >&2 + exit 1 +fi + +tag_with_exclude="$(jq -n '{ + enforcement: "active", + target: "tag", + bypass_actors: [], + conditions: { ref_name: { include: ["refs/tags/v*"], exclude: ["refs/tags/v1*"] } }, + rules: [{ type: "update" }, { type: "deletion" }, { type: "non_fast_forward" }] +}')" +if rulesets_admit_tag <<<"[${tag_with_exclude}]"; then + echo "ERROR: tag rulesets with an effective exclusion must not admit" >&2 + exit 1 +fi + +valid_tag="$(jq -n '{ + enforcement: "active", + target: "tag", + bypass_actors: [], + conditions: { ref_name: { include: ["refs/tags/v*"], exclude: [] } }, + rules: [{ type: "update" }, { type: "deletion" }, { type: "non_fast_forward" }] +}')" +if ! rulesets_admit_tag <<<"[${valid_tag}]"; then + echo "ERROR: a complete immutable v* tag ruleset must admit" >&2 + exit 1 +fi + +echo "OK: release-admission predicate regressions pass" diff --git a/scripts/test-release-guards.sh b/scripts/test-release-guards.sh index 3872906..7f35b47 100755 --- a/scripts/test-release-guards.sh +++ b/scripts/test-release-guards.sh @@ -66,6 +66,7 @@ check_sibling_refs() { check_all_pinned_actions check_sibling_refs +scripts/test-release-admission.sh if grep -n 'workflow_dispatch' .github/workflows/publish.yml >/dev/null; then report "publish workflow must not expose a manual dispatch path" diff --git a/scripts/verify_release_admission.sh b/scripts/verify_release_admission.sh index 796ce47..a2575fd 100755 --- a/scripts/verify_release_admission.sh +++ b/scripts/verify_release_admission.sh @@ -7,81 +7,113 @@ set -euo pipefail -: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" -: "${GITHUB_SHA:?GITHUB_SHA is required}" -: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" -: "${GH_TOKEN:?GH_TOKEN is required}" - -if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9] ]]; then - echo "ERROR: release admission requires a semantic v* tag, got ${GITHUB_REF_NAME}" >&2 - exit 1 -fi +rulesets_admit_dev() { + jq -e ' + any(.[]; + .enforcement == "active" + and .target == "branch" + and (has("bypass_actors") and (.bypass_actors | type == "array" and length == 0)) + and (has("conditions") and (.conditions | type == "object" and has("ref_name"))) + and (.conditions.ref_name | type == "object" and has("include") and has("exclude")) + and (.conditions.ref_name.include | type == "array" and any(.[]; . == "refs/heads/dev" or . == "~DEFAULT_BRANCH")) + and (.conditions.ref_name.exclude | type == "array" and length == 0) + and (.rules | type == "array" and any(.[]; .type == "pull_request")) + and (.rules | type == "array" and any(.[]; .type == "required_status_checks")) + ) + ' >/dev/null +} -git fetch --quiet origin dev --no-tags -if ! git merge-base --is-ancestor "${GITHUB_SHA}" FETCH_HEAD; then - echo "ERROR: tag commit ${GITHUB_SHA} is not an ancestor of origin/dev" >&2 - exit 1 -fi +rulesets_admit_tag() { + jq -e ' + any(.[]; + .enforcement == "active" + and .target == "tag" + and (has("bypass_actors") and (.bypass_actors | type == "array" and length == 0)) + and (has("conditions") and (.conditions | type == "object" and has("ref_name"))) + and (.conditions.ref_name | type == "object" and has("include") and has("exclude")) + and (.conditions.ref_name.include | type == "array" and any(.[]; . == "refs/tags/v*")) + and (.conditions.ref_name.exclude | type == "array" and length == 0) + and (.rules | type == "array" and any(.[]; .type == "update")) + and (.rules | type == "array" and any(.[]; .type == "deletion")) + and (.rules | type == "array" and any(.[]; .type == "non_fast_forward")) + ) + ' >/dev/null +} -ci_runs="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow .github/workflows/ci.yml --commit "${GITHUB_SHA}" --limit 100 --json databaseId,headSha,status,conclusion,event,headBranch)" - -ci_run_id="$(jq -r --arg sha "${GITHUB_SHA}" ' - map(select( - .headSha == $sha - and .status == "completed" - and .conclusion == "success" - and .event == "push" - and .headBranch == "dev" - )) - | sort_by(.databaseId) - | last - | .databaseId // empty -' <<<"${ci_runs}")" - -if [[ -z "${ci_run_id}" ]]; then - echo "ERROR: no successful exact-SHA CI push run on dev admits ${GITHUB_SHA}" >&2 - exit 1 -fi +main() { + : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GH_TOKEN:?GH_TOKEN is required}" -if ! rulesets="$(gh api "repos/${GITHUB_REPOSITORY}/rulesets?includes_parents=true&per_page=100")"; then - echo "ERROR: could not read repository ref-protection rulesets; refusing publication" >&2 - exit 1 -fi + if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9] ]]; then + echo "ERROR: release admission requires a semantic v* tag, got ${GITHUB_REF_NAME}" >&2 + exit 1 + fi -if ! jq -e ' - any(.[]; - .enforcement == "active" - and ((.bypass_actors // []) | length == 0) - and ((.conditions.ref_name.include // []) - | any(. == "refs/heads/dev" or . == "~DEFAULT_BRANCH")) - and ((.rules // []) - | any(.type == "pull_request")) - and ((.rules // []) - | any(.type == "required_status_checks")) - ) -' <<<"${rulesets}" >/dev/null; then - echo "ERROR: no active no-bypass dev protection ruleset with pull-request and status-check rules" >&2 - exit 1 -fi + git fetch --quiet origin dev --no-tags + if ! git merge-base --is-ancestor "${GITHUB_SHA}" FETCH_HEAD; then + echo "ERROR: tag commit ${GITHUB_SHA} is not an ancestor of origin/dev" >&2 + exit 1 + fi -if ! jq -e ' - any(.[]; - .enforcement == "active" - and ((.bypass_actors // []) | length == 0) - and ((.conditions.ref_name.include // []) - | any(. == "refs/tags/v*" or . == "refs/tags/*")) - and ((.rules // []) - | any(.type == "deletion")) - and ((.rules // []) - | any(.type == "non_fast_forward")) - ) -' <<<"${rulesets}" >/dev/null; then - echo "ERROR: no active no-bypass immutable v* tag protection ruleset" >&2 - exit 1 -fi + ci_runs="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow .github/workflows/ci.yml --commit "${GITHUB_SHA}" --limit 100 --json databaseId,headSha,status,conclusion,event,headBranch)" -if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - echo "ci_run_id=${ci_run_id}" >>"${GITHUB_OUTPUT}" -fi + ci_run_id="$(jq -r --arg sha "${GITHUB_SHA}" ' + map(select( + .headSha == $sha + and .status == "completed" + and .conclusion == "success" + and .event == "push" + and .headBranch == "dev" + )) + | sort_by(.databaseId) + | last + | .databaseId // empty + ' <<<"${ci_runs}")" + + if [[ -z "${ci_run_id}" ]]; then + echo "ERROR: no successful exact-SHA CI push run on dev admits ${GITHUB_SHA}" >&2 + exit 1 + fi -echo "OK: release tag ${GITHUB_REF_NAME} at ${GITHUB_SHA} admitted by dev CI run ${ci_run_id} and protected refs" + if ! ruleset_summaries="$(gh api "repos/${GITHUB_REPOSITORY}/rulesets?includes_parents=true&per_page=100")"; then + echo "ERROR: could not read repository ref-protection rulesets; refusing publication" >&2 + exit 1 + fi + + # The collection endpoint returns summary records. Fetch each full ruleset + # so conditions, rules, and bypass_actors are checked from the authoritative + # detail response. Missing bypass_actors is deliberately rejected by the + # predicates because GitHub omits it for callers without write access. + if ! rulesets="$( + jq -r '.[].id // empty' <<<"${ruleset_summaries}" | + while IFS= read -r ruleset_id; do + gh api "repos/${GITHUB_REPOSITORY}/rulesets/${ruleset_id}?includes_parents=true" + done | + jq -s '.' + )"; then + echo "ERROR: could not read complete repository ref-protection rulesets; refusing publication" >&2 + exit 1 + fi + + if ! rulesets_admit_dev <<<"${rulesets}"; then + echo "ERROR: no active no-bypass dev protection ruleset with pull-request, status-check, target, and exclude rules" >&2 + exit 1 + fi + + if ! rulesets_admit_tag <<<"${rulesets}"; then + echo "ERROR: no active no-bypass immutable v* tag ruleset with target, update, deletion, force-push, and exclude protections" >&2 + exit 1 + fi + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "ci_run_id=${ci_run_id}" >>"${GITHUB_OUTPUT}" + fi + + echo "OK: release tag ${GITHUB_REF_NAME} at ${GITHUB_SHA} admitted by dev CI run ${ci_run_id} and protected refs" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi From d8938132b5dc179c12a8ab83d764e0c316d374a2 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 08:17:35 +0100 Subject: [PATCH 13/15] test(security): cover every release admission predicate --- scripts/test-release-admission.sh | 78 +++++++++++++++++-------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/scripts/test-release-admission.sh b/scripts/test-release-admission.sh index 09efc16..0fa148b 100755 --- a/scripts/test-release-admission.sh +++ b/scripts/test-release-admission.sh @@ -9,16 +9,33 @@ set -euo pipefail cd "$(dirname "$0")/.." source scripts/verify_release_admission.sh +assert_rejected() { + local label="$1" + local predicate="$2" + local fixture="$3" + if "$predicate" <<<"[${fixture}]"; then + echo "ERROR: ${label} must be rejected" >&2 + exit 1 + fi +} + +assert_admitted() { + local label="$1" + local predicate="$2" + local fixture="$3" + if ! "$predicate" <<<"[${fixture}]"; then + echo "ERROR: ${label} must be admitted" >&2 + exit 1 + fi +} + missing_bypass_dev="$(jq -n '{ enforcement: "active", target: "branch", conditions: { ref_name: { include: ["refs/heads/dev"], exclude: [] } }, rules: [{ type: "pull_request" }, { type: "required_status_checks" }] }')" -if rulesets_admit_dev <<<"[${missing_bypass_dev}]"; then - echo "ERROR: omitted bypass_actors must not admit a dev ruleset" >&2 - exit 1 -fi +assert_rejected "dev ruleset with omitted bypass_actors" rulesets_admit_dev "${missing_bypass_dev}" valid_dev="$(jq -n '{ enforcement: "active", @@ -27,34 +44,9 @@ valid_dev="$(jq -n '{ conditions: { ref_name: { include: ["refs/heads/dev"], exclude: [] } }, rules: [{ type: "pull_request" }, { type: "required_status_checks" }] }')" -if ! rulesets_admit_dev <<<"[${valid_dev}]"; then - echo "ERROR: a complete protected dev ruleset must admit" >&2 - exit 1 -fi - -tag_without_update="$(jq -n '{ - enforcement: "active", - target: "tag", - bypass_actors: [], - conditions: { ref_name: { include: ["refs/tags/v*"], exclude: [] } }, - rules: [{ type: "deletion" }, { type: "non_fast_forward" }] -}')" -if rulesets_admit_tag <<<"[${tag_without_update}]"; then - echo "ERROR: tag rulesets without update protection must not admit" >&2 - exit 1 -fi - -tag_with_exclude="$(jq -n '{ - enforcement: "active", - target: "tag", - bypass_actors: [], - conditions: { ref_name: { include: ["refs/tags/v*"], exclude: ["refs/tags/v1*"] } }, - rules: [{ type: "update" }, { type: "deletion" }, { type: "non_fast_forward" }] -}')" -if rulesets_admit_tag <<<"[${tag_with_exclude}]"; then - echo "ERROR: tag rulesets with an effective exclusion must not admit" >&2 - exit 1 -fi +assert_admitted "complete protected dev ruleset" rulesets_admit_dev "${valid_dev}" +dev_with_bypass="$(jq '.bypass_actors = [{ actor_id: 123, actor_type: "User", bypass_mode: "always" }]' <<<"${valid_dev}")" +assert_rejected "dev ruleset with a bypass actor" rulesets_admit_dev "${dev_with_bypass}" valid_tag="$(jq -n '{ enforcement: "active", @@ -63,9 +55,23 @@ valid_tag="$(jq -n '{ conditions: { ref_name: { include: ["refs/tags/v*"], exclude: [] } }, rules: [{ type: "update" }, { type: "deletion" }, { type: "non_fast_forward" }] }')" -if ! rulesets_admit_tag <<<"[${valid_tag}]"; then - echo "ERROR: a complete immutable v* tag ruleset must admit" >&2 - exit 1 -fi +assert_admitted "complete immutable v* tag ruleset" rulesets_admit_tag "${valid_tag}" + +tag_without_bypass="$(jq 'del(.bypass_actors)' <<<"${valid_tag}")" +assert_rejected "tag ruleset with omitted bypass_actors" rulesets_admit_tag "${tag_without_bypass}" +tag_with_bypass="$(jq '.bypass_actors = [{ actor_id: 123, actor_type: "User", bypass_mode: "always" }]' <<<"${valid_tag}")" +assert_rejected "tag ruleset with a bypass actor" rulesets_admit_tag "${tag_with_bypass}" +tag_with_wrong_target="$(jq '.target = "branch"' <<<"${valid_tag}")" +assert_rejected "tag ruleset with the wrong target" rulesets_admit_tag "${tag_with_wrong_target}" +tag_with_wrong_include="$(jq '.conditions.ref_name.include = ["refs/tags/release*"]' <<<"${valid_tag}")" +assert_rejected "tag ruleset without exact v* coverage" rulesets_admit_tag "${tag_with_wrong_include}" +tag_without_update="$(jq '.rules |= map(select(.type != "update"))' <<<"${valid_tag}")" +assert_rejected "tag ruleset without update protection" rulesets_admit_tag "${tag_without_update}" +tag_without_deletion="$(jq '.rules |= map(select(.type != "deletion"))' <<<"${valid_tag}")" +assert_rejected "tag ruleset without deletion protection" rulesets_admit_tag "${tag_without_deletion}" +tag_without_force_push_protection="$(jq '.rules |= map(select(.type != "non_fast_forward"))' <<<"${valid_tag}")" +assert_rejected "tag ruleset without force-push protection" rulesets_admit_tag "${tag_without_force_push_protection}" +tag_with_exclude="$(jq '.conditions.ref_name.exclude = ["refs/tags/v1*"]' <<<"${valid_tag}")" +assert_rejected "tag ruleset with an effective exclusion" rulesets_admit_tag "${tag_with_exclude}" echo "OK: release-admission predicate regressions pass" From 276be3a99a90c9187c1371fbef682386012ddd0d Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 10:54:22 +0100 Subject: [PATCH 14/15] fix(security): attest ruleset admission outside verifier --- .github/workflows/publish.yml | 60 ++++++++++++++++++++++++++++ scripts/test-release-admission.sh | 38 +++++++++++++++++- scripts/test-release-guards.sh | 43 ++++++++++++++++++++ scripts/verify_release_admission.sh | 61 +++++++++++++++++++---------- 4 files changed, 180 insertions(+), 22 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 992cece..407bfb2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,13 +9,71 @@ permissions: contents: read jobs: + admit: + name: Trusted ruleset admission + runs-on: ubuntu-latest + environment: + name: release-admission + permissions: + contents: read + actions: write + outputs: + attestation_sha256: ${{ steps.attestation.outputs.attestation_sha256 }} + steps: + - name: Fetch authoritative ruleset details + id: attestation + env: + GH_TOKEN: ${{ secrets.RULESET_ADMISSION_TOKEN }} + run: | + set -euo pipefail + : "${GH_TOKEN:?RULESET_ADMISSION_TOKEN must be configured in the protected release-admission environment}" + summaries="$(gh api "repos/${GITHUB_REPOSITORY}/rulesets?includes_parents=true&per_page=100")" + if [[ "$(jq -r 'type' <<<"${summaries}")" != "array" ]]; then + echo "ERROR: ruleset summary response is not an array" >&2 + exit 1 + fi + rulesets="$( + jq -r '.[].id // empty' <<<"${summaries}" | + while IFS= read -r ruleset_id; do + if [[ ! "${ruleset_id}" =~ ^[0-9]+$ ]]; then + echo "ERROR: ruleset id is not numeric" >&2 + exit 1 + fi + gh api "repos/${GITHUB_REPOSITORY}/rulesets/${ruleset_id}?includes_parents=true" + done | + jq -s '.' + )" + attestation_path="$RUNNER_TEMP/ruleset-attestation.json" + jq -n --arg repository "${GITHUB_REPOSITORY}" --arg commit "${GITHUB_SHA}" --arg workflow_run_id "${GITHUB_RUN_ID}" --argjson rulesets "${rulesets}" '{ + schema_version: 1, + source: "github-ruleset-detail-attestation", + repository: $repository, + commit: $commit, + workflow_run_id: $workflow_run_id, + rulesets: $rulesets + }' > "${attestation_path}" + test -s "${attestation_path}" + echo "attestation_sha256=$(sha256sum "${attestation_path}" | awk '{print $1}')" >> "$GITHUB_OUTPUT" + - name: Upload trusted ruleset attestation + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ruleset-attestation-${{ github.sha }} + path: ${{ runner.temp }}/ruleset-attestation.json + if-no-files-found: error + retention-days: 1 verify: name: Verify and package release runs-on: ubuntu-latest + needs: admit permissions: contents: read actions: read steps: + - name: Download trusted ruleset attestation + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: ruleset-attestation-${{ github.sha }} + path: ${{ runner.temp }}/ruleset-attestation - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: fetch-depth: 0 @@ -30,6 +88,8 @@ jobs: id: admission env: GH_TOKEN: ${{ github.token }} + RULESET_ATTESTATION_FILE: ${{ runner.temp }}/ruleset-attestation/ruleset-attestation.json + RULESET_ATTESTATION_SHA256: ${{ needs.admit.outputs.attestation_sha256 }} run: bash scripts/verify_release_admission.sh - name: Configure shared Cargo cache run: | diff --git a/scripts/test-release-admission.sh b/scripts/test-release-admission.sh index 0fa148b..25bd0b5 100755 --- a/scripts/test-release-admission.sh +++ b/scripts/test-release-admission.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Offline regression tests for the fail-closed ruleset predicates. The live -# admission script remains responsible for fetching the authoritative ruleset -# detail records and checking the tag, ancestry, and CI evidence. +# admission job fetches authoritative ruleset detail records; the verification +# script only validates the hash-bound attestation and checks tag, ancestry, +# and CI evidence. set -euo pipefail @@ -29,6 +30,39 @@ assert_admitted() { fi } +attestation_dir="$(mktemp -d)" +trap 'rm -rf "${attestation_dir}"' EXIT +attestation_file="${attestation_dir}/ruleset-attestation.json" +export GITHUB_REPOSITORY="coderbants/rusty-bubbles" +export GITHUB_SHA="0123456789abcdef0123456789abcdef01234567" +export GITHUB_RUN_ID="12345" +jq -n \ + --arg repository "${GITHUB_REPOSITORY}" \ + --arg commit "${GITHUB_SHA}" \ + --arg workflow_run_id "${GITHUB_RUN_ID}" \ + '{schema_version: 1, source: "github-ruleset-detail-attestation", repository: $repository, commit: $commit, workflow_run_id: $workflow_run_id, rulesets: []}' \ + >"${attestation_file}" +export RULESET_ATTESTATION_FILE="${attestation_file}" +export RULESET_ATTESTATION_SHA256="$(sha256sum "${attestation_file}" | awk '{print $1}')" +if ! rulesets_from_attestation >/dev/null; then + echo "ERROR: a correctly bound ruleset attestation must be admitted" >&2 + exit 1 +fi + +export RULESET_ATTESTATION_SHA256="not-the-file-digest" +if rulesets_from_attestation >/dev/null; then + echo "ERROR: an attestation with a digest mismatch must be rejected" >&2 + exit 1 +fi + +jq '.workflow_run_id = "different-run"' "${attestation_file}" >"${attestation_file}.wrong-run" +export RULESET_ATTESTATION_FILE="${attestation_file}.wrong-run" +export RULESET_ATTESTATION_SHA256="$(sha256sum "${RULESET_ATTESTATION_FILE}" | awk '{print $1}')" +if rulesets_from_attestation >/dev/null; then + echo "ERROR: an attestation for a different workflow run must be rejected" >&2 + exit 1 +fi + missing_bypass_dev="$(jq -n '{ enforcement: "active", target: "branch", diff --git a/scripts/test-release-guards.sh b/scripts/test-release-guards.sh index 7f35b47..97abb0d 100755 --- a/scripts/test-release-guards.sh +++ b/scripts/test-release-guards.sh @@ -106,11 +106,54 @@ verify_job="$(awk ' in_job && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ verify:/ { exit } in_job { print } ' .github/workflows/publish.yml)" +admit_job="$(awk ' + /^ admit:/ { in_job=1 } + in_job && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ admit:/ { exit } + in_job { print } +' .github/workflows/publish.yml)" if [[ "${verify_job}" == *"contents: write"* || "${verify_job}" == *"CARGO_REGISTRY_TOKEN"* ]]; then report "secretless verification job must not receive write permission or registry credentials" fi +if [[ "${admit_job}" != *"release-admission"* || + "${admit_job}" != *"RULESET_ADMISSION_TOKEN"* || + "${admit_job}" != *"gh api"* || + "${admit_job}" != *"rulesets"* || + "${admit_job}" != *"actions/upload-artifact@"* ]]; then + report "ruleset admission must use the protected environment, dedicated token, authoritative API, and immutable artifact upload" +fi + +if [[ "${admit_job}" == *"actions/checkout@"* || + "${admit_job}" == *"cargo test"* || + "${admit_job}" == *"cargo build"* || + "${admit_job}" == *"cargo clippy"* || + "${admit_job}" == *"CARGO_REGISTRY_TOKEN"* || + "${admit_job}" == *"contents: write"* ]]; then + report "ruleset admission must not execute repository code or receive build/repository-write credentials" +fi + +if [[ "${verify_job}" != *"needs: admit"* || + "${verify_job}" != *"RULESET_ATTESTATION_FILE"* || + "${verify_job}" != *"RULESET_ATTESTATION_SHA256"* || + "${verify_job}" != *"actions/download-artifact@"* ]]; then + report "read-only verification must consume the exact trusted ruleset attestation artifact" +fi + +if [[ "${verify_job}" == *"RULESET_ADMISSION_TOKEN"* ]]; then + report "read-only verification must not receive the privileged ruleset admission token" +fi + +if grep -nF 'gh api "repos/${GITHUB_REPOSITORY}/rulesets' scripts/verify_release_admission.sh >/dev/null; then + report "read-only release verification must not fetch repository rulesets directly" +fi + +if ! grep -n 'rulesets_from_attestation' scripts/verify_release_admission.sh >/dev/null || + ! grep -n 'sha256sum' scripts/verify_release_admission.sh >/dev/null || + ! grep -n 'workflow_run_id' scripts/verify_release_admission.sh >/dev/null; then + report "release verification must validate a hash-bound, run-bound ruleset attestation" +fi + if [[ "${publish_job}" != *"contents: write"* || "${publish_job}" != *"needs: verify"* ]]; then report "only the artifact publication job may receive contents: write and it must require verification" fi diff --git a/scripts/verify_release_admission.sh b/scripts/verify_release_admission.sh index a2575fd..5093b0c 100755 --- a/scripts/verify_release_admission.sh +++ b/scripts/verify_release_admission.sh @@ -2,8 +2,10 @@ # Fail-closed admission checks for the tag-triggered publication workflow. # This script runs in the secretless verification job with a read-only -# GitHub token. Publication is not admitted from an arbitrary tag, an -# unverified commit, or an unprotected ref. +# GitHub token and consumes a hash-bound trusted ruleset attestation. The +# privileged admission job is the only stage that fetches ruleset details. +# Publication is not admitted from an arbitrary tag, an unverified commit, or +# an unprotected ref. set -euo pipefail @@ -40,11 +42,45 @@ rulesets_admit_tag() { ' >/dev/null } +rulesets_from_attestation() { + : "${RULESET_ATTESTATION_FILE:?RULESET_ATTESTATION_FILE is required}" + : "${RULESET_ATTESTATION_SHA256:?RULESET_ATTESTATION_SHA256 is required}" + if [[ ! -f "${RULESET_ATTESTATION_FILE}" ]]; then + echo "ERROR: trusted ruleset attestation is missing; refusing publication" >&2 + return 1 + fi + + local actual_sha256 + actual_sha256="$(sha256sum "${RULESET_ATTESTATION_FILE}" | awk '{print $1}')" + if [[ "${actual_sha256}" != "${RULESET_ATTESTATION_SHA256}" ]]; then + echo "ERROR: trusted ruleset attestation digest mismatch; refusing publication" >&2 + return 1 + fi + + if ! jq -e \ + --arg repository "${GITHUB_REPOSITORY}" \ + --arg commit "${GITHUB_SHA}" \ + --arg workflow_run_id "${GITHUB_RUN_ID}" ' + .schema_version == 1 + and .source == "github-ruleset-detail-attestation" + and .repository == $repository + and .commit == $commit + and .workflow_run_id == $workflow_run_id + and (.rulesets | type == "array") + ' "${RULESET_ATTESTATION_FILE}" >/dev/null; then + echo "ERROR: trusted ruleset attestation is not bound to this repository/run/commit; refusing publication" >&2 + return 1 + fi + + jq -c '.rulesets' "${RULESET_ATTESTATION_FILE}" +} + main() { : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" : "${GITHUB_SHA:?GITHUB_SHA is required}" : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" : "${GH_TOKEN:?GH_TOKEN is required}" + : "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9] ]]; then echo "ERROR: release admission requires a semantic v* tag, got ${GITHUB_REF_NAME}" >&2 @@ -77,23 +113,8 @@ main() { exit 1 fi - if ! ruleset_summaries="$(gh api "repos/${GITHUB_REPOSITORY}/rulesets?includes_parents=true&per_page=100")"; then - echo "ERROR: could not read repository ref-protection rulesets; refusing publication" >&2 - exit 1 - fi - - # The collection endpoint returns summary records. Fetch each full ruleset - # so conditions, rules, and bypass_actors are checked from the authoritative - # detail response. Missing bypass_actors is deliberately rejected by the - # predicates because GitHub omits it for callers without write access. - if ! rulesets="$( - jq -r '.[].id // empty' <<<"${ruleset_summaries}" | - while IFS= read -r ruleset_id; do - gh api "repos/${GITHUB_REPOSITORY}/rulesets/${ruleset_id}?includes_parents=true" - done | - jq -s '.' - )"; then - echo "ERROR: could not read complete repository ref-protection rulesets; refusing publication" >&2 + if ! rulesets="$(rulesets_from_attestation)"; then + echo "ERROR: could not validate trusted repository ref-protection attestation; refusing publication" >&2 exit 1 fi @@ -111,7 +132,7 @@ main() { echo "ci_run_id=${ci_run_id}" >>"${GITHUB_OUTPUT}" fi - echo "OK: release tag ${GITHUB_REF_NAME} at ${GITHUB_SHA} admitted by dev CI run ${ci_run_id} and protected refs" + echo "OK: release tag ${GITHUB_REF_NAME} at ${GITHUB_SHA} admitted by dev CI run ${ci_run_id}, trusted ruleset attestation, and protected refs" } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then From a38ea5b06c00981fc16574c1982e2ed07fd75d84 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Sat, 29 Aug 2026 11:03:56 +0100 Subject: [PATCH 15/15] docs(bui-012): bind evidence to attested admission --- .../acceptance/BUI-012/independent-review.md | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md index 439eb9e..6d551aa 100644 --- a/evidence/acceptance/BUI-012/independent-review.md +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -1,11 +1,12 @@ # BUI-012 Implementation Evidence Status: implementation evidence for material parent -`41cc48b161060842b99a6bd2048de59cbcb011ed`; the current pull-request head is a -later security-remediation descendant. Exact-head CI and review binding for -that descendant is recorded in the GitHub Issue ledger. R-01 replay, R-02 -replay, Security review, and merge authorization remain lifecycle gates. This -packet is not merge authorization. +`276be3a99a90c9187c1371fbef682386012ddd0d` is the current security-remediation +material head. The attestation-only descendant preserves the prior Principal +and Testing approvals; the repaired exact head must receive targeted Security +confirmation. Exact-head CI and review binding are recorded in the GitHub +Issue ledger. Merge authorization remains a lifecycle gate. This packet is not +merge authorization. ## Historical boundary @@ -33,10 +34,11 @@ independent Security review: - `docs/projection.yaml`: maps the user documentation source to the table module. - `docs/src/lib.rs`: documents the table shape contract and contains a compiling user-facing example. - `evidence/acceptance/BUI-012/independent-review.md`: this exact-head evidence packet. -- `.github/workflows/publish.yml`: separates secretless release verification and packaging from artifact publication, with protected-ref admission and digest-bound artifacts. +- `.github/workflows/publish.yml`: adds a protected `release-admission` job with a dedicated `RULESET_ADMISSION_TOKEN`, uploads a hash-bound ruleset attestation, and keeps verification and publication credential-separated. - `scripts/verify_docs_projection.sh`: compiles the documentation projection against the built library. -- `scripts/verify_release_admission.sh`: fails closed unless a release tag is on `dev`, has an exact successful `dev` CI run, and is covered by no-bypass ref rulesets. -- `scripts/test-release-guards.sh`: enforces immutable workflow/sibling references and the publication trust boundary. +- `scripts/verify_release_admission.sh`: validates the hash-bound attestation's repository, commit, workflow-run binding, and no-bypass ruleset predicates without direct ruleset API access. +- `scripts/test-release-admission.sh`: covers valid and invalid ruleset predicates plus attestation digest and workflow-run binding. +- `scripts/test-release-guards.sh`: enforces immutable workflow/sibling references, the protected admission boundary, and read-only verifier artifact wiring. - `src/lib.rs`: documents the crate facade as a user-facing component collection. - `src/table.rs`: hardens outer-height arithmetic, cursor/viewport movement, ragged-row rendering, and the public user-documentation contract. - `tests/table_test.rs`: adds deterministic ragged-row, zero-height, and maximum-input coverage while extending the upstream overflow case. @@ -55,6 +57,8 @@ the Security review because they govern whether this crate can be released. | Zero-height navigation is safe | Pass | `tests/table_test.rs::test_zero_height_navigation_is_safe` | | Sibling dependency direction remains unchanged | Pass | `Cargo.toml`; path dependencies remain rusty-bubbletea, rusty-lipgloss, and rusty-x-ansi | | User-facing table documentation is projected and compilable | Pass | `docs/projection.yaml`; `scripts/verify_docs_projection.sh`; `src/table.rs`; `docs/src/lib.rs` | +| Release admission evidence is hash-bound and run-bound | Pass | `.github/workflows/publish.yml`; `scripts/verify_release_admission.sh`; `scripts/test-release-admission.sh` | +| Privileged admission does not execute candidate repository code | Pass | `.github/workflows/publish.yml`; `scripts/test-release-guards.sh` | ## Focused validation @@ -68,8 +72,9 @@ the Security review because they govern whether this crate can be released. - `bash ./scripts/verify_docs_projection.sh`: passed; the projection manifest and the `docs/src/lib.rs` example compile against the current library. The same check passed under the CI split `CARGO_TARGET_DIR`/`CARGO_BUILD_BUILD_DIR` layout. - `scripts/verify_mapping.sh`: passed in protected CI; the optional local `upstream-go/` checkout is absent as documented by the script. - `./scripts/test-release-guards.sh`: passed. -- `bash -n scripts/verify_release_admission.sh scripts/test-release-guards.sh`: passed. -- `yq '.' .github/workflows/ci.yml`: passed. +- `scripts/test-release-admission.sh`: passed, including valid binding, digest-mismatch rejection, and wrong-workflow-run rejection. +- `bash -n scripts/verify_release_admission.sh scripts/test-release-admission.sh scripts/test-release-guards.sh`: passed. +- `yq '.' .github/workflows/publish.yml`: passed. - Historical protected CI run [`33236603654`](https://github.com/coderbants/rusty-bubbles/actions/runs/33236603654) passed on exact head `41cc48b161060842b99a6bd2048de59cbcb011ed`: version gate, lint/build/docs/tests, documentation projection, seven table doctests, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. It is not proof for the later security-remediation descendant; the current exact-head run is recorded in the GitHub Issue ledger. The repository-wide `cargo test --doc --no-fail-fast` run remains a known @@ -85,11 +90,17 @@ replay passed with no findings on `8f21bb1`. R-02 then identified `TST-R02-001`: the module-level table doctest was not protected by CI. The protected `cargo test --doc table --no-fail-fast` step now covers all seven table doctests on `2e85a43`, and the exact current head `41cc48b` has a passing -protected rerun; it must now be sent back to the Testing Specialist for -confirmation. +protected rerun. The R-02 replay at `d8938132b5dc179c12a8ab83d764e0c316d374a2` +passed and remains valid for this non-build attestation descendant. The Security review identified two blocking release-boundary findings and two -non-blocking guard/evidence findings. The remediation is included in this -pull request and must be re-reviewed at its exact final head. The final -independent review, exact-head protected CI, and post-merge absorb transition -remain owned by the Mutate lifecycle. +non-blocking guard/evidence findings. SEC-R03-001 was the remaining blocking +finding: the read-only verifier could not authoritatively inspect +`bypass_actors`. The remediation moves that read to the protected +`release-admission` job, binds the complete ruleset response to the repository, +commit, and workflow run, and supplies only the digest-bound artifact to the +read-only verifier. This does not alter the candidate build or admission +predicates, so prior Principal and Testing approvals are retained; only +targeted Security confirmation of the repaired exact head remains. The final +protected CI and post-merge absorb transition remain owned by the Mutate +lifecycle.