From f0173e31c0b1a572d8485e45555420316f9a545f Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 14:46:12 +0100 Subject: [PATCH 01/11] chore: pin Rust 1.98.0 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 2 +- CONTRIBUTING.md | 2 +- Cargo.toml | 2 +- rust-toolchain.toml | 4 ++++ 5 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 rust-toolchain.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c846095..08b0114 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: needs: version-bump steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 with: components: clippy, rustfmt - name: Configure shared Cargo cache @@ -131,7 +131,7 @@ jobs: needs: gate steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 - name: Configure shared Cargo cache run: | # The runner image pre-sets CARGO_HOME=$HOME/.cargo; drop the diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3f110c1..3f26bed 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.98.0 - name: Verify version and tag match upstream run: scripts/verify_upstream_version.sh "$GITHUB_REF_NAME" - name: Configure shared Cargo cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de4fd99..2e478fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ workflow. ## Development setup -- A recent stable Rust toolchain (`rustup default stable`). +- Rust 1.98.0, selected automatically by the checked-in `rust-toolchain.toml`. - Go (for the upstream parity scripts and the pinned `upstream-go/` checkout). - No other system dependencies; there are no C build steps. diff --git a/Cargo.toml b/Cargo.toml index 495993f..9310ba8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "rusty-bubbletea" exclude = ["POLICIES.md"] version = "2.0.8" edition = "2021" -rust-version = "1.91" +rust-version = "1.98.0" description = "Cleanroom Rust port of Charmbracelet's Bubble Tea (v2.0.8) TUI Elm architecture framework" license = "MIT" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d1ad785 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.98.0" +profile = "minimal" +components = ["clippy", "rustfmt"] From 35cfad00acd9f8616c40efd31c68ce47549149f4 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 15:12:35 +0100 Subject: [PATCH 02/11] fix: pin rust-toolchain action immutably --- .github/workflows/ci.yml | 7 +++++-- .github/workflows/publish.yml | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08b0114..64f5e67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,9 @@ jobs: needs: version-bump steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.98.0 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: + toolchain: 1.98.0 components: clippy, rustfmt - name: Configure shared Cargo cache run: | @@ -131,7 +132,9 @@ jobs: needs: gate steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.98.0 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.98.0 - name: Configure shared Cargo cache run: | # The runner image pre-sets CARGO_HOME=$HOME/.cargo; drop the diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3f26bed..98f2c48 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.98.0 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.98.0 - name: Verify version and tag match upstream run: scripts/verify_upstream_version.sh "$GITHUB_REF_NAME" - name: Configure shared Cargo cache From e052cc4c9bf97d2463ace00645027325152d4373 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 20:05:10 +0100 Subject: [PATCH 03/11] fix: complete Bubbletea 1.98 release setup --- .github/workflows/publish.yml | 1 + UPSTREAM_MAPPING.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 98f2c48..490dd49 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,6 +18,7 @@ jobs: - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: toolchain: 1.98.0 + components: clippy, rustfmt - name: Verify version and tag match upstream run: scripts/verify_upstream_version.sh "$GITHUB_REF_NAME" - name: Configure shared Cargo cache diff --git a/UPSTREAM_MAPPING.md b/UPSTREAM_MAPPING.md index 7847e52..9bf892b 100644 --- a/UPSTREAM_MAPPING.md +++ b/UPSTREAM_MAPPING.md @@ -172,7 +172,7 @@ in the Support Files section. | `README.md` | `README.md` | Documented Rust port header with graphics & links | | Repository lifecycle guide | `docs/src/lib.rs` | User-facing documentation anchor for `ProgramHandle`, headless options, cancellation, and graceful versus error shutdown | | `UPGRADE_GUIDE_V2.md` | `README.md` (notes) | v1 -> v2 migration guidance summarized in README | -| `go.mod` / `go.sum` | `Cargo.toml` | Dependency manifest (Go modules -> Cargo crates); candidate declares the supported Rust 1.91 toolchain floor | +| `go.mod` / `go.sum` | `Cargo.toml` | Dependency manifest (Go modules -> Cargo crates); candidate declares the supported Rust 1.98.0 toolchain floor | | `examples/go.mod` / `examples/go.sum` / `tutorials/go.mod` / `tutorials/go.sum` | `Cargo.toml` | Example-module manifests (deps like bubbles, glamour, harmonica are example-only) | | `examples/*/README.md` and `examples/*/*.gif` | `examples/` docs | Per-example docs/assets; retained as upstream documentation references | | `examples/isbn-form/isbn-form.tape` | (asset) | VHS recording asset; not applicable to the Rust crate | From 5a35a709e0d35571ac3636708a7228b633354ddc Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 20:17:55 +0100 Subject: [PATCH 04/11] test: guard Rust toolchain consistency --- .github/workflows/ci.yml | 2 ++ .github/workflows/publish.yml | 2 ++ CONTRIBUTING.md | 2 +- scripts/verify_toolchain.sh | 52 +++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100755 scripts/verify_toolchain.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64f5e67..0aa20c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,8 @@ jobs: - name: verify_mapping run: ./scripts/verify_mapping.sh + - name: verify_toolchain + run: ./scripts/verify_toolchain.sh coverage: name: Coverage runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 490dd49..01ae3d9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,6 +19,8 @@ jobs: with: toolchain: 1.98.0 components: clippy, rustfmt + - name: Verify toolchain consistency + run: ./scripts/verify_toolchain.sh - name: Verify version and tag match upstream run: scripts/verify_upstream_version.sh "$GITHUB_REF_NAME" - name: Configure shared Cargo cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e478fe..9f4a158 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ cargo test --all-targets - `examples/` — executable Rust ports of upstream Go examples. - `tests/` — Rust integration tests ported from upstream `*_test.go` suites. - `upstream-go/` — the pinned upstream Go checkout (git-ignored, never commit it). -- `scripts/` — parity and mapping verification helpers. +- `scripts/` — parity, mapping, and toolchain consistency verification helpers. - `UPSTREAM_MAPPING.md` — the authoritative 1:1 account of every upstream file. ## The cleanroom porting workflow diff --git a/scripts/verify_toolchain.sh b/scripts/verify_toolchain.sh new file mode 100755 index 0000000..8204639 --- /dev/null +++ b/scripts/verify_toolchain.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Verifies that the checked-in Rust toolchain is consistent across local and CI entrypoints. +set -u + +cd "$(dirname "$0")/.." +fail=0 + +toolchain="$(grep -m1 '^channel = ' rust-toolchain.toml | sed 's/.*"\(.*\)".*/\1/')" +cargo_version="$(grep -m1 '^rust-version = ' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')" + +if [ -z "${toolchain}" ]; then + echo "ERROR: rust-toolchain.toml does not declare a channel" >&2 + fail=1 +fi + +if [ "${cargo_version}" != "${toolchain}" ]; then + echo "ERROR: Cargo.toml rust-version '${cargo_version}' does not match rust-toolchain.toml channel '${toolchain}'" >&2 + fail=1 +fi + +for workflow in .github/workflows/ci.yml .github/workflows/publish.yml; do + if ! grep -qF "toolchain: ${toolchain}" "${workflow}"; then + echo "ERROR: ${workflow} does not select Rust ${toolchain}" >&2 + fail=1 + fi +done + +if ! grep -qF 'components: clippy, rustfmt' .github/workflows/ci.yml; then + echo "ERROR: CI does not install the clippy and rustfmt components" >&2 + fail=1 +fi + +if ! grep -qF 'components: clippy, rustfmt' .github/workflows/publish.yml; then + echo "ERROR: publish does not install the clippy and rustfmt components" >&2 + fail=1 +fi + +if ! grep -qF "Rust ${toolchain}" CONTRIBUTING.md; then + echo "ERROR: CONTRIBUTING.md does not document Rust ${toolchain}" >&2 + fail=1 +fi + +if ! grep -qF "Rust ${toolchain}" UPSTREAM_MAPPING.md; then + echo "ERROR: UPSTREAM_MAPPING.md does not document Rust ${toolchain}" >&2 + fail=1 +fi + +if [ "${fail}" -ne 0 ]; then + exit 1 +fi + +echo "OK: Rust ${toolchain} is consistent across the manifest, toolchain file, workflows, contributor docs, and upstream mapping" From 47d077857d30b503bef8a5e7626c42ae28ce706b Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 20:23:58 +0100 Subject: [PATCH 05/11] test: validate every workflow toolchain selector --- scripts/verify_toolchain.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/verify_toolchain.sh b/scripts/verify_toolchain.sh index 8204639..73234b2 100755 --- a/scripts/verify_toolchain.sh +++ b/scripts/verify_toolchain.sh @@ -19,8 +19,14 @@ if [ "${cargo_version}" != "${toolchain}" ]; then fi for workflow in .github/workflows/ci.yml .github/workflows/publish.yml; do - if ! grep -qF "toolchain: ${toolchain}" "${workflow}"; then - echo "ERROR: ${workflow} does not select Rust ${toolchain}" >&2 + action_count="$(grep -cF 'uses: dtolnay/rust-toolchain@' "${workflow}")" + selector_count="$(grep -cF 'toolchain:' "${workflow}")" + matching_count="$(grep -cF "toolchain: ${toolchain}" "${workflow}")" + if [ "${action_count}" -eq 0 ]; then + echo "ERROR: ${workflow} does not install a Rust toolchain" >&2 + fail=1 + elif [ "${action_count}" -ne "${selector_count}" ] || [ "${action_count}" -ne "${matching_count}" ]; then + echo "ERROR: every Rust toolchain action in ${workflow} must select Rust ${toolchain}" >&2 fail=1 fi done From 282ef3b3d8e3aeba2a3ab377a1cff615d8c866bc Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 20:42:09 +0100 Subject: [PATCH 06/11] fix: harden release policy checks --- .github/workflows/publish.yml | 2 +- CONTRIBUTING.md | 18 ++++-------------- scripts/verify_upstream_version.sh | 3 ++- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 01ae3d9..f12dfef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,6 @@ on: push: tags: - 'v*' - workflow_dispatch: permissions: contents: write @@ -135,6 +134,7 @@ jobs: # rejects re-publishing an existing version, so the version-bump gate # in ci.yml keeps every release on a fresh, unreleased version. - name: Publish to crates.io + if: startsWith(github.ref, 'refs/tags/v') env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f4a158..83dcefc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,20 +76,10 @@ cargo test --all-targets ## Versioning -Every release that matches an upstream version uses the upstream `MAJOR.MINOR.PATCH` plus a -fourth dot-separated iteration number that internally tracks which deployed release of this -port it is for that upstream version: - -- `v2.0.8.0` — first port release of upstream `v2.0.8` -- `v2.0.8.1` — a hotfix iteration for `v2.0.8` (bug fix released without an upstream - version bump) - -The iteration increments whenever we publish a new release of our port without an upstream -version bump (e.g. a bug fix that upstream has not yet released). The git tag and GitHub -release carry the full four-part version (`v2.0.8.1`). `Cargo.toml` keeps the upstream -`X.Y.Z` (`2.0.8`), since crates.io only accepts `MAJOR.MINOR.PATCH`; iteration hotfixes -publish under the same `X.Y.Z` on crates.io, replacing the previous deployment (iterations -are only used for bug fixes, so the contents differ only in fixes). +Every release uses the tracked upstream `MAJOR.MINOR.PATCH` version exactly. The crate version, +release tag, and GitHub release must all match the upstream version; there is no fourth-part +iteration or republishing of an existing crates.io version. If upstream has not advanced beyond +the currently published version, do not create a release—wait for the next upstream release. ## Contribution guidelines diff --git a/scripts/verify_upstream_version.sh b/scripts/verify_upstream_version.sh index f26dc14..e229f5e 100755 --- a/scripts/verify_upstream_version.sh +++ b/scripts/verify_upstream_version.sh @@ -55,7 +55,8 @@ if [ "$#" -ge 1 ]; then fi ;; *) - echo "WARN: '${tag}' is not a v* tag; skipping the tag check (crate version was still verified)." >&2 + echo "ERROR: release tag '${tag}' is not a v* tag." >&2 + fail=1 ;; esac fi From 012600f7d1733b2fc347d54ae6a96ed96024a0a0 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 21:04:07 +0100 Subject: [PATCH 07/11] security: pin release inputs and credentials --- .github/workflows/publish.yml | 36 ++++++++++++++++++------------ scripts/verify_toolchain.sh | 6 +++-- scripts/verify_upstream_version.sh | 6 ----- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f12dfef..b3c2bf9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,9 @@ jobs: name: Publish Crate runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: toolchain: 1.98.0 @@ -39,7 +41,7 @@ jobs: } >> "$GITHUB_ENV" - name: Test shared Cargo cache env run: scripts/test-cargo-env.sh - - uses: actions/setup-go@v5 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 with: go-version: 'stable' - name: Fetch upstream Go source (pinned) @@ -47,41 +49,47 @@ jobs: git clone --quiet https://github.com/charmbracelet/bubbletea.git upstream-go cd upstream-go && git checkout --quiet v2.0.8 - name: Fetch sibling rusty-bubbles (pinned) - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: repository: coderbants/rusty-bubbles path: siblings/rusty-bubbles - ref: dev + ref: 2a88f46cb8179388adb1951adb0f4fdbdcd633c0 + persist-credentials: false - name: Fetch sibling rusty-colorprofile (pinned) - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: repository: coderbants/rusty-colorprofile path: siblings/rusty-colorprofile - ref: dev + ref: 2d505e7821f72f133c17d6cc7afd314e03eddb66 + persist-credentials: false - name: Fetch sibling rusty-lipgloss (pinned) - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: repository: coderbants/rusty-lipgloss path: siblings/rusty-lipgloss - ref: dev + ref: 685b25da2af226b6ab2b9ce70f149eb9e4fb54ee + persist-credentials: false - name: Fetch sibling rusty-testkit (pinned) - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: repository: coderbants/rusty-testkit path: siblings/rusty-testkit - ref: dev + ref: 163cf16399662da7bf2b17fb6637b466c6402fb2 + persist-credentials: false - name: Fetch sibling rusty-ultraviolet (pinned) - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: repository: coderbants/rusty-ultraviolet path: siblings/rusty-ultraviolet - ref: dev + ref: ad29f896f95e1bcd850d69580152edfac3289eec + persist-credentials: false - name: Fetch sibling rusty-x-ansi (pinned) - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: repository: coderbants/rusty-x-ansi path: siblings/rusty-x-ansi - ref: dev + ref: 971128e8566ad37991cd9cc50ff4f4c8a4b09c29 + persist-credentials: false - name: Place sibling crates run: | mkdir -p ../siblings && true diff --git a/scripts/verify_toolchain.sh b/scripts/verify_toolchain.sh index 73234b2..1a33d21 100755 --- a/scripts/verify_toolchain.sh +++ b/scripts/verify_toolchain.sh @@ -4,6 +4,7 @@ set -u cd "$(dirname "$0")/.." fail=0 +toolchain_action_sha="6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772" toolchain="$(grep -m1 '^channel = ' rust-toolchain.toml | sed 's/.*"\(.*\)".*/\1/')" cargo_version="$(grep -m1 '^rust-version = ' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')" @@ -20,13 +21,14 @@ fi for workflow in .github/workflows/ci.yml .github/workflows/publish.yml; do action_count="$(grep -cF 'uses: dtolnay/rust-toolchain@' "${workflow}")" + pinned_action_count="$(grep -cF "uses: dtolnay/rust-toolchain@${toolchain_action_sha}" "${workflow}")" selector_count="$(grep -cF 'toolchain:' "${workflow}")" matching_count="$(grep -cF "toolchain: ${toolchain}" "${workflow}")" if [ "${action_count}" -eq 0 ]; then echo "ERROR: ${workflow} does not install a Rust toolchain" >&2 fail=1 - elif [ "${action_count}" -ne "${selector_count}" ] || [ "${action_count}" -ne "${matching_count}" ]; then - echo "ERROR: every Rust toolchain action in ${workflow} must select Rust ${toolchain}" >&2 + elif [ "${action_count}" -ne "${pinned_action_count}" ] || [ "${action_count}" -ne "${selector_count}" ] || [ "${action_count}" -ne "${matching_count}" ]; then + echo "ERROR: every Rust toolchain action in ${workflow} must use the approved immutable pin and select Rust ${toolchain}" >&2 fail=1 fi done diff --git a/scripts/verify_upstream_version.sh b/scripts/verify_upstream_version.sh index e229f5e..2b2ffae 100755 --- a/scripts/verify_upstream_version.sh +++ b/scripts/verify_upstream_version.sh @@ -23,12 +23,6 @@ crate_version="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')" upstream="$(grep -m1 'Upstream Target Tag / Version:' src/lib.rs | sed -n 's/.*`\([^`]*\)`.*/\1/p' | tr -d ' ')" if [ -z "${upstream}" ]; then - if [ ! -d upstream-go ]; then - # No tracked upstream at all (e.g. the original test harness): the - # upstream-mirror policy does not apply. - echo "OK: no upstream tracked (original crate); version policy not applicable" - exit 0 - fi echo "ERROR: could not read the tracked upstream version from src/lib.rs" >&2 exit 1 fi From f5178e57a1fc37009aa913353e10667134149413 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 21:14:20 +0100 Subject: [PATCH 08/11] test: cover release security guards --- .github/workflows/ci.yml | 2 ++ .github/workflows/publish.yml | 2 ++ scripts/test_release_guards.sh | 29 +++++++++++++++++++++++++++++ scripts/verify_toolchain.sh | 30 ++++++++++++++++++++++++++++++ scripts/verify_upstream_version.sh | 3 ++- 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100755 scripts/test_release_guards.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aa20c6..16d0847 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,8 @@ jobs: run: ./scripts/verify_mapping.sh - name: verify_toolchain run: ./scripts/verify_toolchain.sh + - name: test_release_guards + run: ./scripts/test_release_guards.sh coverage: name: Coverage runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b3c2bf9..045403b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,6 +22,8 @@ jobs: components: clippy, rustfmt - name: Verify toolchain consistency run: ./scripts/verify_toolchain.sh + - name: Test release guards + run: ./scripts/test_release_guards.sh - name: Verify version and tag match upstream run: scripts/verify_upstream_version.sh "$GITHUB_REF_NAME" - name: Configure shared Cargo cache diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh new file mode 100755 index 0000000..c559b73 --- /dev/null +++ b/scripts/test_release_guards.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Exercises the release-policy guards with both valid and invalid inputs. +set -u + +cd "$(dirname "$0")/.." +fail=0 + +if ! ./scripts/verify_toolchain.sh >/dev/null; then + echo "ERROR: toolchain/release structure verification failed" >&2 + fail=1 +fi + +if ! ./scripts/verify_upstream_version.sh v2.0.8 >/dev/null; then + echo "ERROR: valid upstream release tag was rejected" >&2 + fail=1 +fi + +if UPSTREAM_VERSION_SOURCE=rust-toolchain.toml ./scripts/verify_upstream_version.sh v2.0.8 >/dev/null 2>&1; then + echo "ERROR: missing upstream header was accepted" >&2 + fail=1 +else + echo "OK: missing upstream header is rejected" +fi + +if [ "${fail}" -ne 0 ]; then + exit 1 +fi + +echo "OK: release guards reject unsafe inputs and accept the valid release path" diff --git a/scripts/verify_toolchain.sh b/scripts/verify_toolchain.sh index 1a33d21..81e6e1a 100755 --- a/scripts/verify_toolchain.sh +++ b/scripts/verify_toolchain.sh @@ -43,6 +43,36 @@ if ! grep -qF 'components: clippy, rustfmt' .github/workflows/publish.yml; then fail=1 fi +checkout_sha="11bd71901bbe5b1630ceea73d27597364c9af683" +setup_go_sha="d35c59abb061a4a6fb18e82ac0862c26744d6ab5" +publish_checkout_count="$(grep -cF 'uses: actions/checkout@' .github/workflows/publish.yml)" +publish_pinned_checkout_count="$(grep -cF "uses: actions/checkout@${checkout_sha}" .github/workflows/publish.yml)" +publish_credential_count="$(grep -cF 'persist-credentials: false' .github/workflows/publish.yml)" +publish_setup_go_count="$(grep -cF 'uses: actions/setup-go@' .github/workflows/publish.yml)" +publish_pinned_setup_go_count="$(grep -cF "uses: actions/setup-go@${setup_go_sha}" .github/workflows/publish.yml)" +sibling_count="$(grep -cF 'repository: coderbants/' .github/workflows/publish.yml)" +sibling_ref_count="$(grep -Ec '^[[:space:]]+ref: [0-9a-f]{40}$' .github/workflows/publish.yml)" + +if [ "${publish_checkout_count}" -eq 0 ] || [ "${publish_checkout_count}" -ne "${publish_pinned_checkout_count}" ] || [ "${publish_checkout_count}" -ne "${publish_credential_count}" ]; then + echo "ERROR: every publish checkout must use the approved immutable pin without persisted credentials" >&2 + fail=1 +fi + +if [ "${publish_setup_go_count}" -eq 0 ] || [ "${publish_setup_go_count}" -ne "${publish_pinned_setup_go_count}" ]; then + echo "ERROR: every publish setup-go action must use the approved immutable pin" >&2 + fail=1 +fi + +if [ "${sibling_count}" -eq 0 ] || [ "${sibling_count}" -ne "${sibling_ref_count}" ] || grep -qF 'ref: dev' .github/workflows/publish.yml; then + echo "ERROR: every publish sibling checkout must use an immutable commit ref" >&2 + fail=1 +fi + +if grep -qF 'workflow_dispatch:' .github/workflows/publish.yml; then + echo "ERROR: publish must not be manually dispatchable outside a release tag" >&2 + fail=1 +fi + if ! grep -qF "Rust ${toolchain}" CONTRIBUTING.md; then echo "ERROR: CONTRIBUTING.md does not document Rust ${toolchain}" >&2 fail=1 diff --git a/scripts/verify_upstream_version.sh b/scripts/verify_upstream_version.sh index 2b2ffae..7d548c7 100755 --- a/scripts/verify_upstream_version.sh +++ b/scripts/verify_upstream_version.sh @@ -20,7 +20,8 @@ set -u cd "$(dirname "$0")/.." crate_version="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')" -upstream="$(grep -m1 'Upstream Target Tag / Version:' src/lib.rs | sed -n 's/.*`\([^`]*\)`.*/\1/p' | tr -d ' ')" +upstream_source="${UPSTREAM_VERSION_SOURCE:-src/lib.rs}" +upstream="$(grep -m1 'Upstream Target Tag / Version:' "${upstream_source}" | sed -n 's/.*`\([^`]*\)`.*/\1/p' | tr -d ' ')" if [ -z "${upstream}" ]; then echo "ERROR: could not read the tracked upstream version from src/lib.rs" >&2 From 3548a110cb0e03d1e629edc2e669bb216e375115 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 21:21:46 +0100 Subject: [PATCH 09/11] test: cover release boundary hardening --- .github/workflows/publish.yml | 5 ++-- scripts/test_release_guards.sh | 51 ++++++++++++++++++++++++++++++++++ scripts/verify_toolchain.sh | 24 ++++++++++++++-- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 045403b..c155e3a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,8 +48,9 @@ jobs: go-version: 'stable' - name: Fetch upstream Go source (pinned) run: | - git clone --quiet https://github.com/charmbracelet/bubbletea.git upstream-go - cd upstream-go && git checkout --quiet v2.0.8 + git clone --quiet --no-tags https://github.com/charmbracelet/bubbletea.git upstream-go + cd upstream-go + git checkout --quiet fc707bb7ea0161405bb6c653ec93f6a9c6a72fe1 - name: Fetch sibling rusty-bubbles (pinned) uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh index c559b73..837b865 100755 --- a/scripts/test_release_guards.sh +++ b/scripts/test_release_guards.sh @@ -22,6 +22,57 @@ else echo "OK: missing upstream header is rejected" fi +fixture_root="$(mktemp -d)" +trap 'rm -rf "${fixture_root}"' EXIT +mkdir -p "${fixture_root}/.github/workflows" "${fixture_root}/scripts" +cp Cargo.toml rust-toolchain.toml CONTRIBUTING.md UPSTREAM_MAPPING.md "${fixture_root}/" +cp .github/workflows/ci.yml .github/workflows/publish.yml "${fixture_root}/.github/workflows/" +cp scripts/verify_toolchain.sh "${fixture_root}/scripts/" + +perl -0pi -e 's/actions\/checkout\@11bd71901bbe5b1630ceea73d27597364c9af683/actions\/checkout\@v4/' "${fixture_root}/.github/workflows/publish.yml" +if RELEASE_ROOT="${fixture_root}" "${fixture_root}/scripts/verify_toolchain.sh" >/dev/null 2>&1; then + echo "ERROR: mutable checkout action pin was accepted" >&2 + fail=1 +else + echo "OK: mutable checkout action pin is rejected" +fi + +cp .github/workflows/publish.yml "${fixture_root}/.github/workflows/publish.yml" +perl -0pi -e 's/ref: [0-9a-f]{40}/ref: dev/' "${fixture_root}/.github/workflows/publish.yml" +if RELEASE_ROOT="${fixture_root}" "${fixture_root}/scripts/verify_toolchain.sh" >/dev/null 2>&1; then + echo "ERROR: mutable sibling ref was accepted" >&2 + fail=1 +else + echo "OK: mutable sibling ref is rejected" +fi + +cp .github/workflows/publish.yml "${fixture_root}/.github/workflows/publish.yml" +perl -0pi -e 's/persist-credentials: false/persist-credentials: true/' "${fixture_root}/.github/workflows/publish.yml" +if RELEASE_ROOT="${fixture_root}" "${fixture_root}/scripts/verify_toolchain.sh" >/dev/null 2>&1; then + echo "ERROR: persisted checkout credentials were accepted" >&2 + fail=1 +else + echo "OK: persisted checkout credentials are rejected" +fi + +cp .github/workflows/publish.yml "${fixture_root}/.github/workflows/publish.yml" +perl -0pi -e 's/fc707bb7ea0161405bb6c653ec93f6a9c6a72fe1/v2.0.8/' "${fixture_root}/.github/workflows/publish.yml" +if RELEASE_ROOT="${fixture_root}" "${fixture_root}/scripts/verify_toolchain.sh" >/dev/null 2>&1; then + echo "ERROR: mutable upstream ref was accepted" >&2 + fail=1 +else + echo "OK: mutable upstream ref is rejected" +fi + +cp .github/workflows/publish.yml "${fixture_root}/.github/workflows/publish.yml" +perl -0pi -e 's/ CARGO_REGISTRY_TOKEN:/ CARGO_REGISTRY_TOKEN:/' "${fixture_root}/.github/workflows/publish.yml" +if RELEASE_ROOT="${fixture_root}" "${fixture_root}/scripts/verify_toolchain.sh" >/dev/null 2>&1; then + echo "ERROR: job-scoped registry credentials were accepted" >&2 + fail=1 +else + echo "OK: job-scoped registry credentials are rejected" +fi + if [ "${fail}" -ne 0 ]; then exit 1 fi diff --git a/scripts/verify_toolchain.sh b/scripts/verify_toolchain.sh index 81e6e1a..a68bc37 100755 --- a/scripts/verify_toolchain.sh +++ b/scripts/verify_toolchain.sh @@ -2,9 +2,16 @@ # Verifies that the checked-in Rust toolchain is consistent across local and CI entrypoints. set -u -cd "$(dirname "$0")/.." +if [ -n "${RELEASE_ROOT:-}" ]; then + cd "${RELEASE_ROOT}" +else + cd "$(dirname "$0")/.." +fi fail=0 toolchain_action_sha="6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772" +checkout_sha="11bd71901bbe5b1630ceea73d27597364c9af683" +setup_go_sha="d35c59abb061a4a6fb18e82ac0862c26744d6ab5" +upstream_commit="fc707bb7ea0161405bb6c653ec93f6a9c6a72fe1" toolchain="$(grep -m1 '^channel = ' rust-toolchain.toml | sed 's/.*"\(.*\)".*/\1/')" cargo_version="$(grep -m1 '^rust-version = ' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')" @@ -43,8 +50,6 @@ if ! grep -qF 'components: clippy, rustfmt' .github/workflows/publish.yml; then fail=1 fi -checkout_sha="11bd71901bbe5b1630ceea73d27597364c9af683" -setup_go_sha="d35c59abb061a4a6fb18e82ac0862c26744d6ab5" publish_checkout_count="$(grep -cF 'uses: actions/checkout@' .github/workflows/publish.yml)" publish_pinned_checkout_count="$(grep -cF "uses: actions/checkout@${checkout_sha}" .github/workflows/publish.yml)" publish_credential_count="$(grep -cF 'persist-credentials: false' .github/workflows/publish.yml)" @@ -52,6 +57,9 @@ publish_setup_go_count="$(grep -cF 'uses: actions/setup-go@' .github/workflows/p publish_pinned_setup_go_count="$(grep -cF "uses: actions/setup-go@${setup_go_sha}" .github/workflows/publish.yml)" sibling_count="$(grep -cF 'repository: coderbants/' .github/workflows/publish.yml)" sibling_ref_count="$(grep -Ec '^[[:space:]]+ref: [0-9a-f]{40}$' .github/workflows/publish.yml)" +upstream_checkout_count="$(grep -cF "git checkout --quiet ${upstream_commit}" .github/workflows/publish.yml)" +registry_token_count="$(grep -cF ' CARGO_REGISTRY_TOKEN:' .github/workflows/publish.yml)" +github_token_count="$(grep -cF ' GH_TOKEN:' .github/workflows/publish.yml)" if [ "${publish_checkout_count}" -eq 0 ] || [ "${publish_checkout_count}" -ne "${publish_pinned_checkout_count}" ] || [ "${publish_checkout_count}" -ne "${publish_credential_count}" ]; then echo "ERROR: every publish checkout must use the approved immutable pin without persisted credentials" >&2 @@ -68,6 +76,16 @@ if [ "${sibling_count}" -eq 0 ] || [ "${sibling_count}" -ne "${sibling_ref_count fail=1 fi +if [ "${upstream_checkout_count}" -ne 1 ]; then + echo "ERROR: publish must execute the verified upstream commit ${upstream_commit}" >&2 + fail=1 +fi + +if [ "${registry_token_count}" -ne 1 ] || [ "${github_token_count}" -ne 2 ]; then + echo "ERROR: release credentials must remain scoped to their individual publication steps" >&2 + fail=1 +fi + if grep -qF 'workflow_dispatch:' .github/workflows/publish.yml; then echo "ERROR: publish must not be manually dispatchable outside a release tag" >&2 fail=1 From c8991f021d9b6a69c022d1186143d070004c24f0 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 21:29:07 +0100 Subject: [PATCH 10/11] test: bind release checks to workflow steps --- scripts/test_release_guards.sh | 10 +++++++++ scripts/verify_toolchain.sh | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh index 837b865..60cb202 100755 --- a/scripts/test_release_guards.sh +++ b/scripts/test_release_guards.sh @@ -55,6 +55,16 @@ else echo "OK: persisted checkout credentials are rejected" fi +cp .github/workflows/publish.yml "${fixture_root}/.github/workflows/publish.yml" +perl -0pi -e 's/persist-credentials: false/persist-credentials: true/' "${fixture_root}/.github/workflows/publish.yml" +perl -0pi -e 's/(toolchain: 1\.98\.0\n)/$1 persist-credentials: false\n/' "${fixture_root}/.github/workflows/publish.yml" +if RELEASE_ROOT="${fixture_root}" "${fixture_root}/scripts/verify_toolchain.sh" >/dev/null 2>&1; then + echo "ERROR: credentials compensated in another step were accepted" >&2 + fail=1 +else + echo "OK: cross-step credential compensation is rejected" +fi + cp .github/workflows/publish.yml "${fixture_root}/.github/workflows/publish.yml" perl -0pi -e 's/fc707bb7ea0161405bb6c653ec93f6a9c6a72fe1/v2.0.8/' "${fixture_root}/.github/workflows/publish.yml" if RELEASE_ROOT="${fixture_root}" "${fixture_root}/scripts/verify_toolchain.sh" >/dev/null 2>&1; then diff --git a/scripts/verify_toolchain.sh b/scripts/verify_toolchain.sh index a68bc37..ea9ff22 100755 --- a/scripts/verify_toolchain.sh +++ b/scripts/verify_toolchain.sh @@ -66,6 +66,45 @@ if [ "${publish_checkout_count}" -eq 0 ] || [ "${publish_checkout_count}" -ne "$ fail=1 fi +if ! awk -v expected_sha="${checkout_sha}" ' + function finish_step() { + if (!checkout_step) { + return + } + if (!pinned_checkout || !safe_credentials || (sibling_checkout && !immutable_ref)) { + invalid=1 + } + checkout_step=0 + } + /^[[:space:]]+-[[:space:]]/ { + finish_step() + } + /uses: actions\/checkout@/ { + checkout_step=1 + pinned_checkout=index($0, "actions/checkout@" expected_sha) > 0 + safe_credentials=0 + sibling_checkout=0 + immutable_ref=0 + next + } + checkout_step && /persist-credentials: false/ { + safe_credentials=1 + } + checkout_step && /repository: coderbants\// { + sibling_checkout=1 + } + checkout_step && /^[[:space:]]+ref: [0-9a-f]{40}$/ { + immutable_ref=1 + } + END { + finish_step() + exit invalid + } +' .github/workflows/publish.yml; then + echo "ERROR: publish checkout pins, refs, and credentials are not safe within each checkout step" >&2 + fail=1 +fi + if [ "${publish_setup_go_count}" -eq 0 ] || [ "${publish_setup_go_count}" -ne "${publish_pinned_setup_go_count}" ]; then echo "ERROR: every publish setup-go action must use the approved immutable pin" >&2 fail=1 From 0b6b44ad442f636c68d0fbe9a2c6e29b69f33104 Mon Sep 17 00:00:00 2001 From: Jay Rodgers Date: Fri, 28 Aug 2026 21:43:56 +0100 Subject: [PATCH 11/11] test: isolate checkout credential regression --- scripts/test_release_guards.sh | 2 +- scripts/verify_toolchain.sh | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/test_release_guards.sh b/scripts/test_release_guards.sh index 60cb202..fdd8f41 100755 --- a/scripts/test_release_guards.sh +++ b/scripts/test_release_guards.sh @@ -56,7 +56,7 @@ else fi cp .github/workflows/publish.yml "${fixture_root}/.github/workflows/publish.yml" -perl -0pi -e 's/persist-credentials: false/persist-credentials: true/' "${fixture_root}/.github/workflows/publish.yml" +perl -0pi -e 's/(uses: actions\/checkout\@11bd71901bbe5b1630ceea73d27597364c9af683\n with:\n )persist-credentials: false/${1}persist-credentials: true/' "${fixture_root}/.github/workflows/publish.yml" perl -0pi -e 's/(toolchain: 1\.98\.0\n)/$1 persist-credentials: false\n/' "${fixture_root}/.github/workflows/publish.yml" if RELEASE_ROOT="${fixture_root}" "${fixture_root}/scripts/verify_toolchain.sh" >/dev/null 2>&1; then echo "ERROR: credentials compensated in another step were accepted" >&2 diff --git a/scripts/verify_toolchain.sh b/scripts/verify_toolchain.sh index ea9ff22..5b20a42 100755 --- a/scripts/verify_toolchain.sh +++ b/scripts/verify_toolchain.sh @@ -52,7 +52,6 @@ fi publish_checkout_count="$(grep -cF 'uses: actions/checkout@' .github/workflows/publish.yml)" publish_pinned_checkout_count="$(grep -cF "uses: actions/checkout@${checkout_sha}" .github/workflows/publish.yml)" -publish_credential_count="$(grep -cF 'persist-credentials: false' .github/workflows/publish.yml)" publish_setup_go_count="$(grep -cF 'uses: actions/setup-go@' .github/workflows/publish.yml)" publish_pinned_setup_go_count="$(grep -cF "uses: actions/setup-go@${setup_go_sha}" .github/workflows/publish.yml)" sibling_count="$(grep -cF 'repository: coderbants/' .github/workflows/publish.yml)" @@ -61,8 +60,8 @@ upstream_checkout_count="$(grep -cF "git checkout --quiet ${upstream_commit}" .g registry_token_count="$(grep -cF ' CARGO_REGISTRY_TOKEN:' .github/workflows/publish.yml)" github_token_count="$(grep -cF ' GH_TOKEN:' .github/workflows/publish.yml)" -if [ "${publish_checkout_count}" -eq 0 ] || [ "${publish_checkout_count}" -ne "${publish_pinned_checkout_count}" ] || [ "${publish_checkout_count}" -ne "${publish_credential_count}" ]; then - echo "ERROR: every publish checkout must use the approved immutable pin without persisted credentials" >&2 +if [ "${publish_checkout_count}" -eq 0 ] || [ "${publish_checkout_count}" -ne "${publish_pinned_checkout_count}" ]; then + echo "ERROR: every publish checkout must use the approved immutable pin" >&2 fail=1 fi