diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ba56ab8..b427b1f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -122,6 +122,10 @@ 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: 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
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 6e3bd97..407bfb2 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -6,15 +6,77 @@ on:
- 'v*'
permissions:
- contents: write
+ contents: read
jobs:
- publish:
- name: Publish Crate
+ 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
persist-credentials: false
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
with:
@@ -22,11 +84,15 @@ 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 }}
+ 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: |
- # 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 +149,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 +164,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/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/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/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..6d551aa
--- /dev/null
+++ b/evidence/acceptance/BUI-012/independent-review.md
@@ -0,0 +1,106 @@
+# BUI-012 Implementation Evidence
+
+Status: implementation evidence for material parent
+`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
+
+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.
+
+## Current change set
+
+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`: 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`: 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.
+
+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
+
+| 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 |
+| 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
+
+- `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.
+- `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.
+- `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
+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. 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`, and the exact current head `41cc48b` has a passing
+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. 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.
diff --git a/scripts/test-release-admission.sh b/scripts/test-release-admission.sh
new file mode 100755
index 0000000..25bd0b5
--- /dev/null
+++ b/scripts/test-release-admission.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+
+# Offline regression tests for the fail-closed ruleset predicates. The live
+# 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
+
+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
+}
+
+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",
+ conditions: { ref_name: { include: ["refs/heads/dev"], exclude: [] } },
+ rules: [{ type: "pull_request" }, { type: "required_status_checks" }]
+}')"
+assert_rejected "dev ruleset with omitted bypass_actors" rulesets_admit_dev "${missing_bypass_dev}"
+
+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" }]
+}')"
+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",
+ target: "tag",
+ bypass_actors: [],
+ conditions: { ref_name: { include: ["refs/tags/v*"], exclude: [] } },
+ rules: [{ type: "update" }, { type: "deletion" }, { type: "non_fast_forward" }]
+}')"
+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"
diff --git a/scripts/test-release-guards.sh b/scripts/test-release-guards.sh
index fa50117..97abb0d 100755
--- a/scripts/test-release-guards.sh
+++ b/scripts/test-release-guards.sh
@@ -1,50 +1,186 @@
#!/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
+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"
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)"
+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
+
+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 +211,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 +219,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 +231,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_docs_projection.sh b/scripts/verify_docs_projection.sh
new file mode 100644
index 0000000..9aba880
--- /dev/null
+++ b/scripts/verify_docs_projection.sh
@@ -0,0 +1,46 @@
+#!/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
+
+build_dir="${CARGO_BUILD_BUILD_DIR:-${CARGO_TARGET_DIR:-target}}"
+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=$build_dir/debug/deps"
+
+echo "OK: docs projection is mapped and compiled"
diff --git a/scripts/verify_release_admission.sh b/scripts/verify_release_admission.sh
new file mode 100755
index 0000000..5093b0c
--- /dev/null
+++ b/scripts/verify_release_admission.sh
@@ -0,0 +1,140 @@
+#!/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 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
+
+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
+}
+
+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
+}
+
+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
+ 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="$(rulesets_from_attestation)"; then
+ echo "ERROR: could not validate trusted repository ref-protection attestation; 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}, trusted ruleset attestation, and protected refs"
+}
+
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ main "$@"
+fi
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..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;
@@ -216,11 +243,24 @@ 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.
+///
+/// # 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());
- m.viewport.set_height(h - hh);
+ m.viewport.set_height(h.saturating_sub(hh));
})
}
@@ -321,6 +361,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());
@@ -334,7 +383,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(),
);
@@ -392,10 +441,23 @@ 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.
+ ///
+ /// # 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 - hh);
+ self.viewport.set_height(h.saturating_sub(hh));
self.update_viewport();
}
@@ -422,6 +484,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.
@@ -431,13 +502,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();
@@ -445,19 +526,37 @@ 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 + 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 +608,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..ec8cc21 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,127 @@ 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(),
+ "d".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 ");
+
+ let rendered = ansi_strip(&m.view());
+ assert!(rendered.contains('b'));
+ assert!(rendered.contains('c'));
+ assert!(rendered.contains('d'));
+ assert!(!rendered.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);
+
+ 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]
+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);
+}