From b99064260d737fbaf080e52fe2cad9146733cc49 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Sun, 1 Nov 2026 14:31:30 -0800 Subject: [PATCH] ci(checks): pin Rust and harden local validation --- .githooks/pre-push | 42 ++++++ .github/workflows/ci.yml | 10 +- .github/workflows/npm-publish.yml | 8 +- .github/workflows/release-from-package.yml | 2 +- .github/workflows/release-please.yml | 2 +- .github/workflows/release.yml | 9 +- docs/ci-checks.md | 41 ++++++ rust-toolchain.toml | 2 +- scripts/install-git-hooks.sh | 24 ++++ scripts/test-git-hooks.py | 146 +++++++++++++++++++++ src/store/events.rs | 8 +- 11 files changed, 277 insertions(+), 17 deletions(-) create mode 100755 .githooks/pre-push create mode 100644 docs/ci-checks.md create mode 100755 scripts/install-git-hooks.sh create mode 100644 scripts/test-git-hooks.py diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..c9e208e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +head=$(git rev-parse HEAD) +zero=${head//?/0} +seen=false +update=false +while read -r local_ref local_sha remote_ref remote_sha extra; do + seen=true + if [[ -z "$local_ref" || -z "$remote_ref" || -n "${extra:-}" || ${#local_sha} != ${#head} || ${#remote_sha} != ${#head} || "$local_sha$remote_sha" == *[!0-9a-f]* ]]; then + echo 'pre-push: malformed ref record; refusing to skip checks.' >&2 + exit 1 + fi + [[ "$local_sha" == "$zero" ]] && continue + update=true + if [[ "$(git rev-parse --verify "$local_sha^{commit}")" != "$head" ]]; then + echo 'pre-push: switch to the pushed commit in a clean checkout first; only HEAD can be checked.' >&2 + exit 1 + fi +done +[[ "$seen" == true && "$update" == false ]] && exit 0 + +status=$(git status --porcelain --untracked-files=normal) +if [[ -n "$status" ]]; then + echo 'pre-push: commit or move unrelated working-tree changes first. Nothing is stashed automatically.' >&2 + exit 1 +fi +if ! command -v rustup >/dev/null || ! command -v bun >/dev/null || [[ ! -x tui-opentui/node_modules/.bin/tsc ]]; then + echo 'pre-push: prepare Rust 1.97.1, cargo fetch --locked, and frozen Bun dependencies in tui-opentui first.' >&2 + exit 1 +fi +# rustup run never installs a missing toolchain. No downloads during the hook. +if ! rustup run 1.97.1 rustc --version >/dev/null; then + echo 'pre-push: install pinned Rust 1.97.1 with rustfmt/clippy before pushing.' >&2 + exit 1 +fi + +# Conservative full scopes cover config/dependencies, unknown bases and every ref. +rustup run 1.97.1 cargo fmt --all -- --check +rustup run 1.97.1 cargo clippy --offline --locked --all-targets --all-features -- -D warnings +(cd tui-opentui && bun run typecheck) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70ee161..58feb60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ permissions: jobs: rust-quality: name: Rust Quality - runs-on: ubuntu-latest + runs-on: boat steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -27,12 +27,12 @@ jobs: - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - run: cargo fmt --check - - run: cargo clippy --all-targets --all-features -- -D warnings - - run: cargo test --quiet + - run: cargo clippy --locked --all-targets --all-features -- -D warnings + - run: cargo test --locked --quiet js-check: name: JS and npm checks - runs-on: ubuntu-latest + runs-on: boat steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -48,7 +48,7 @@ jobs: - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - run: cargo build --quiet --bin tsq + - run: cargo build --locked --quiet --bin tsq - run: bun test working-directory: tui-opentui diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 3b6018f..83969fb 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -42,7 +42,7 @@ jobs: bun_os: linux bun_cpu: x64 tui_bin: tsq-tui - runner: ubuntu-latest + runner: boat method: native - platform: linux-arm64-gnu target: aarch64-unknown-linux-gnu @@ -50,7 +50,7 @@ jobs: bun_os: linux bun_cpu: arm64 tui_bin: tsq-tui - runner: ubuntu-latest + runner: boat method: zigbuild - platform: win32-x64-msvc target: x86_64-pc-windows-msvc @@ -75,8 +75,9 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 with: + toolchain: '1.97.1' targets: ${{ matrix.target }} - uses: oven-sh/setup-bun@v2 @@ -169,6 +170,7 @@ jobs: publish: needs: build + # Tokenless npm trusted publishing requires a GitHub-hosted runner. runs-on: ubuntu-latest name: publish permissions: diff --git a/.github/workflows/release-from-package.yml b/.github/workflows/release-from-package.yml index 41c225f..8515ac7 100644 --- a/.github/workflows/release-from-package.yml +++ b/.github/workflows/release-from-package.yml @@ -22,7 +22,7 @@ jobs: create-release: name: Create Release - runs-on: ubuntu-latest + runs-on: boat needs: rust-quality steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 54f673b..ab580f2 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,7 +15,7 @@ jobs: release-please: name: Release Please - runs-on: ubuntu-latest + runs-on: boat needs: rust-quality outputs: release_created: ${{ steps.release.outputs.release_created }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83d000a..0605f8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,13 @@ jobs: build-and-upload: needs: rust-quality name: Build & Upload (${{ matrix.os }}) - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.runner || matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-latest + runner: boat steps: - uses: actions/checkout@v6 with: @@ -32,7 +35,9 @@ jobs: - uses: oven-sh/setup-bun@v2 - name: Set up Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 + with: + toolchain: '1.97.1' - name: Build release binary run: cargo build --release --locked diff --git a/docs/ci-checks.md b/docs/ci-checks.md new file mode 100644 index 0000000..bb16bc4 --- /dev/null +++ b/docs/ci-checks.md @@ -0,0 +1,41 @@ +# Reproducible checks and opt-in hooks + +Rust is pinned to 1.97.1 in the root toolchain and both binary-release workflows. +This is the concrete stable toolchain exercised by the secret-free Linux +baseline, not a claim that native release targets passed. CI Clippy, tests and +CLI builds now use `--locked`. Existing frozen Bun setup and the dependency of +TUI contract tests on the actual built CLI remain intact. + +The baseline Clippy `question_mark` diagnostic in event-record parsing was fixed +with equivalent `?` propagation. No lint was disabled or error path removed. + +Prepare Rust 1.97.1 with rustfmt/clippy, run `cargo fetch --locked`, and install +existing TUI dependencies with `cd tui-opentui && bun install --frozen-lockfile`. +Enable the tracked pre-push explicitly with `bash scripts/install-git-hooks.sh`. +It refuses unrelated active hooks/config and can be rerun. Repository-local +`.githooks` also resolves correctly in linked worktrees; repository-local Git +config is shared across those worktrees. No automatic installation occurs. + +The hook requires a clean checkout of the pushed commit. All updated refs must +resolve to HEAD; deletion-only pushes skip source checks. Unknown history, new +branches, empty input, shared configuration, source renames/deletions and +multiple refs run conservative full Rust format/locked offline Clippy and full +TUI typecheck. No source mutation, dependency installation, `tsq` state writes, +contract test execution, service launch or publishing happens in the hook. +Missing prepared dependencies/toolchains fail with setup instructions. + +`python3 scripts/test-git-hooks.py` exercises real hook/installer executables in +throwaway fixtures with stubbed tools. No outbound push occurs. These fixtures +prove ref and failure handling, not native release acceptance. + +Compatible Ubuntu-latest jobs and Linux matrix legs now use Boat. The release +matrix retains `matrix.os` identities/job names while selecting Boat through a +separate Linux runner field. Existing native Windows/macOS legs, platform artifact +names and trust/publishing guards remain unchanged. Tokenless npm publishing stays +on `ubuntu-latest`, required by npm's GitHub-hosted trusted-publisher contract; +OIDC and provenance remain intact without a new token. Source edits dispatch no CI. +Owner-only public policy is configured separately; outsiders/bots remain blocked. + +Native qualification of all six npm targets and cargo-zigbuild version/cache +benchmarking remain separate release gates. No cache optimization or speedup is +claimed from runner-label changes. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 73cb934..8565ca6 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "stable" +channel = "1.97.1" components = ["rustfmt", "clippy"] diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..237a212 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel) +cd "$root" +current=$(git config --get core.hooksPath || true) +if [[ -n "$current" && "$current" != .githooks ]]; then + echo "Refusing to replace existing core.hooksPath=$current. Integrate hooks manually." >&2 + exit 1 +fi +if [[ -z "$current" ]]; then + active=$(git rev-parse --git-path hooks) + for hook in "$active"/*; do + if [[ -x "$hook" && "$hook" != *.sample ]]; then + echo "Refusing to hide active hook $hook. Integrate hooks manually." >&2 + exit 1 + fi + done +fi +if [[ ! -x .githooks/pre-push ]]; then + echo 'Tracked .githooks/pre-push must be executable before installation.' >&2 + exit 1 +fi +git config --local core.hooksPath .githooks +echo 'Opt-in hooks enabled for this repository. Re-running this installer is safe.' diff --git a/scripts/test-git-hooks.py b/scripts/test-git-hooks.py new file mode 100644 index 0000000..42f6e5c --- /dev/null +++ b/scripts/test-git-hooks.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Run executable hook/installer fixtures. No outbound push or real project tools.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class HookTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="push hooks ") + self.addCleanup(self.temp.cleanup) + self.parent = Path(self.temp.name) + self.repo = self.parent / "checkout with spaces" + self.repo.mkdir() + self.git("init", "-q") + self.git("config", "user.name", "Fixture") + self.git("config", "user.email", "fixture@example.invalid") + for path in [".githooks/pre-push", "scripts/install-git-hooks.sh"]: + dest = self.repo / path + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / path, dest) + if (ROOT / '.gitattributes').exists(): + shutil.copy2(ROOT / '.gitattributes', self.repo / '.gitattributes') + (self.repo / ".gitignore").write_text("node_modules/\ntui-opentui/node_modules/\n") + self.commit("file with spaces.ts", "first") + self.base = self.git("rev-parse", "HEAD") + self.commit("file with spaces.ts", "second") + self.head = self.git("rev-parse", "HEAD") + self.zero = "0" * len(self.head) + self.calls = self.parent / "calls" + self.bin = self.parent / "bin" + self.bin.mkdir() + self.stub(self.bin / "rustup", '#!/bin/sh\nif [ "$3" = rustc ]; then exit 0; fi\necho "rust $4" >> "$CALLS"\nif [ "$4" = "${FAIL_RUST:-}" ]; then exit 17; fi\n') + self.stub(self.bin / "bun", '#!/bin/sh\necho "bun $*" >> "$CALLS"\nexit "${FAIL_BUN:-0}"\n') + self.stub(self.repo / "tui-opentui/node_modules/.bin/tsc", '#!/bin/sh\nexit 0\n') + + def stub(self, path, content): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + path.chmod(0o755) + + def git(self, *args, cwd=None): + return subprocess.check_output(["git", *args], cwd=cwd or self.repo, text=True).strip() + + def commit(self, path, text): + (self.repo / path).write_text(text) + self.git("add", "-A") + self.git("commit", "-qm", "fixture") + + def record(self, local=None, remote=None): + return f"refs/heads/topic {local or self.head} refs/heads/topic {remote or self.base}\n" + + def run_hook(self, records=None, **extra): + self.calls.write_text("") + result = subprocess.run([str(self.repo / ".githooks/pre-push")], cwd=self.repo, + input=self.record() if records is None else records, text=True, + env={**os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", + "CALLS": str(self.calls), **extra}, capture_output=True) + return result, self.calls.read_text().splitlines() + + def test_full_scope_for_new_unknown_empty_and_multiple_refs(self): + for records in [self.record(), self.record(remote=self.zero), self.record(remote="f" * 40), + "", self.record() + self.record(remote=self.zero), self.record(remote=self.head)]: + with self.subTest(records=records): + result, calls = self.run_hook(records) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls, ["rust fmt", "rust clippy", "bun run typecheck"]) + + def test_renamed_and_deleted_source_still_checks_full_project(self): + self.git("mv", "file with spaces.ts", "renamed source.ts") + self.git("commit", "-qm", "rename") + self.head = self.git("rev-parse", "HEAD") + self.assertEqual(len(self.run_hook()[1]), 3) + self.git("rm", "renamed source.ts") + self.git("commit", "-qm", "delete") + self.head = self.git("rev-parse", "HEAD") + self.assertEqual(len(self.run_hook()[1]), 3) + + def test_deletion_only_skips_but_mixed_update_checks(self): + deleted = self.record(local=self.zero) + self.assertEqual(self.run_hook(deleted)[1], []) + self.assertEqual(len(self.run_hook(deleted + self.record())[1]), 3) + + def test_non_head_and_malformed_refs_fail(self): + for records in [self.record(local=self.base), "bad record\n", self.record() + "bad record\n"]: + result, calls = self.run_hook(records) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + + def test_dirty_checkout_and_missing_tool_fail(self): + (self.repo / "unrelated.txt").write_text("user work") + self.assertNotEqual(self.run_hook()[0].returncode, 0) + (self.repo / "unrelated.txt").unlink() + (self.repo / "tui-opentui/node_modules/.bin/tsc").unlink() + self.assertNotEqual(self.run_hook()[0].returncode, 0) + + def test_failed_checks_propagate(self): + self.assertEqual(self.run_hook(FAIL_RUST="fmt")[0].returncode, 17) + self.assertEqual(self.run_hook(FAIL_RUST="clippy")[0].returncode, 17) + self.assertEqual(self.run_hook(FAIL_BUN="23")[0].returncode, 23) + + def installer(self, cwd=None): + cwd = cwd or self.repo + return subprocess.run([str(cwd / "scripts/install-git-hooks.sh")], cwd=cwd, + capture_output=True, text=True) + + def test_installer_idempotent_and_refuses_existing_config_or_hook(self): + hook = self.repo / ".git/hooks/pre-commit" + self.stub(hook, "#!/bin/sh\nexit 0\n") + self.assertNotEqual(self.installer().returncode, 0) + self.assertEqual(hook.read_text(), "#!/bin/sh\nexit 0\n") + hook.unlink() + self.git("config", "core.hooksPath", "unrelated hooks") + self.assertNotEqual(self.installer().returncode, 0) + self.assertEqual(self.git("config", "core.hooksPath"), "unrelated hooks") + self.git("config", "--unset", "core.hooksPath") + self.assertEqual(self.installer().returncode, 0) + self.assertEqual(self.installer().returncode, 0) + self.assertEqual(self.git("config", "core.hooksPath"), ".githooks") + + def test_installer_in_disposable_linked_worktree(self): + linked = self.parent / "linked checkout" + self.git("worktree", "add", "-qb", "linked-fixture", str(linked)) + self.assertEqual(self.installer(linked).returncode, 0) + self.assertEqual(self.git("config", "core.hooksPath", cwd=linked), ".githooks") + + def test_git_status_error_stops_before_project_checks(self): + (self.repo / '.git/index').write_bytes(b'bad') + result, calls = self.run_hook() + self.assertNotEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls, []) + + def test_autocrlf_checkout_preserves_shell_entrypoints(self): + self.git('config', 'core.autocrlf', 'true') + self.git('checkout-index', '--force', '--all') + for path in ['.githooks/pre-push', 'scripts/install-git-hooks.sh']: + self.assertNotIn(b'\r\n', (self.repo / path).read_bytes(), path) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/store/events.rs b/src/store/events.rs index 98e36f9..1f51a90 100644 --- a/src/store/events.rs +++ b/src/store/events.rs @@ -692,10 +692,10 @@ fn parse_events_raw( } match serde_json::from_str::(line) { - Ok(parsed) => match parse_event_record(&parsed, line_offset + index + 1) { - Ok(record) => events.push(record), - Err(error) => return Err(error), - }, + Ok(parsed) => { + let record = parse_event_record(&parsed, line_offset + index + 1)?; + events.push(record); + } Err(_error) => { if index == lines.len() - 1 { warning = Some(format!(