Repository navigation
ci(checks): pin Rust and harden local validation #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| [toolchain] | ||
| channel = "stable" | ||
| channel = "1.97.1" | ||
| components = ["rustfmt", "clippy"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Record Rust command options in the stub. The stub records only
|
||
| 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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isolate Git configuration in the fixtures. If a developer sets a global
|
||
|
|
||
| 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() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: BumpyClock/tasque
Length of output: 4274
Keep untrusted pull request jobs off
boat.These jobs run on
pull_requestand now requestboat. The repository does not defineboat. Its documentation treatsboatas a runner that is separate from GitHub-hosted runners. The workflow has no fork guard. A fork pull request can therefore run checkout, Cargo, Bun, and npm commands on aboatmachine. If that machine is self-hosted or persistent, fork code can run on infrastructure that is not isolated. Runner-group policy is not visible in this repository, so it does not remove this risk from the workflow.Choose one of these options:
boatjobs to trusted events. For example, skip fork pull requests in these jobs.🔒 Suggested guard for fork pull requests
Apply the same condition to
js-check. This option skips fork pull request runs for these jobs.Committable suggestion
actionlint (1.7.12)
[error] 19-19: label "boat" is unknown. available labels are "windows-latest", "windows-latest-8-cores", "windows-2025", "windows-2025-vs2026", "windows-2022", "windows-11-arm", "ubuntu-slim", "ubuntu-latest", "ubuntu-latest-4-cores", "ubuntu-latest-8-cores", "ubuntu-latest-16-cores", "ubuntu-24.04", "ubuntu-24.04-arm", "ubuntu-22.04", "ubuntu-22.04-arm", "macos-latest", "macos-latest-xlarge", "macos-latest-large", "macos-26-intel", "macos-26-xlarge", "macos-26-large", "macos-26", "macos-15-intel", "macos-15-xlarge", "macos-15-large", "macos-15", "macos-14-xlarge", "macos-14-large", "macos-14", "self-hosted", "x64", "arm", "arm64", "linux", "macos", "windows". if it is a custom label for self-hosted runner, set list of labels in actionlint.yaml config file
(runner-label)