diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a5b32659..47a5f39bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,17 +18,26 @@ Thanks for your interest in Corbits Workbench. ## The short version -1. `bun install && bun run check` — everything must be green before and after - your change. -2. Tests first: add failing tests, then the implementation, then docs. -3. One logical change per commit; write messages for a public audience. -4. Never commit secrets. `.env.example` is the only tracked env file. -5. Never vendor code without a ledger row and kill date in +1. `bun install` (or `bun run hooks:install`) sets a repo-local pre-push + hook. That hook is the local stand-in for the cheap CI jobs: lint, + typecheck, and unit tests. GitHub still runs walking-skeleton, e2e, + isolation, and database-backed suites. Skip the hook with + `git push --no-verify` or `SKIP_WORKBENCH_HOOKS=1`. Typecheck and + tests narrow to the packages your change touches; a change under + `package.json`, `bun.lock`, a shared tsconfig, `scripts/`, or + `.github/` falls back to checking every package instead, which takes + much longer — that's expected for those paths, not a hang. +2. `bun run check` — everything must be green before and after your + change. +3. Tests first: add failing tests, then the implementation, then docs. +4. One logical change per commit; write messages for a public audience. +5. Never commit secrets. `.env.example` is the only tracked env file. +6. Never vendor code without a ledger row and kill date in [VENDORED.md](VENDORED.md). -6. A package that owns its own product tables follows +7. A package that owns its own product tables follows [docs/package-migrations.md](docs/package-migrations.md): literal SQL, a package-owned ledger table, applied transactionally. -7. Security issues go through [SECURITY.md](SECURITY.md), never a public +8. Security issues go through [SECURITY.md](SECURITY.md), never a public issue. This document will grow as the project does. diff --git a/package.json b/package.json index 488b5629d..2bc054299 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "typecheck": "bun run scripts/run-all.ts typecheck && bunx tsc -p tsconfig.json --noEmit && bunx tsc -p test/isolation/tsconfig.json --noEmit", "lint": "prettier --check --cache . && eslint --cache .", "format": "prettier --write .", + "hooks:install": "bun run scripts/hooks-install.ts", + "prepare": "bun run hooks:install", "build": "bun run scripts/run-all.ts build", "test": "bun test ./scripts/*.test.ts && bun run scripts/run-all.ts test", "test:e2e": "bun test scripts/e2e", diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 000000000..ef62e1982 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,15 @@ +#!/bin/sh +# Local stand-in for the cheap CI jobs. Heavy suites stay on GitHub. +set -eu + +if [ -n "${SKIP_WORKBENCH_HOOKS:-}" ] || [ -n "${CI:-}" ]; then + exit 0 +fi + +if git rev-parse --verify --quiet origin/main >/dev/null; then + export WORKBENCH_CHECK_SINCE=origin/main +fi + +bun run lint +bun run typecheck +bun run test diff --git a/scripts/hooks-install.test.ts b/scripts/hooks-install.test.ts new file mode 100644 index 000000000..85c7f9343 --- /dev/null +++ b/scripts/hooks-install.test.ts @@ -0,0 +1,209 @@ +// Cheap-gate hook: skip env must not spawn lint, and the hook must not +// invoke the heavy suites that stay on GitHub CI. +import { describe, expect, test } from "bun:test"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const HOOK = join(import.meta.dir, "git-hooks", "pre-push"); +const INSTALLER = join(import.meta.dir, "hooks-install.ts"); + +const HEAVY_SUITE_MARKERS = [ + "test:e2e", + "walking-skeleton", + "check:structural", +] as const; + +function ambientEnv( + overrides: Record = {}, +): Record { + const { + CI: _ci, + GITHUB_ACTIONS: _gha, + SKIP_WORKBENCH_HOOKS: _skip, + GIT_DIR: _gitDir, + GIT_WORK_TREE: _gitWorkTree, + GIT_CONFIG_GLOBAL: _global, + GIT_CONFIG_SYSTEM: _system, + ...rest + } = process.env; + const env: Record = {}; + for (const [key, value] of Object.entries(rest)) { + if (value !== undefined) env[key] = value; + } + return { ...env, GIT_CONFIG_NOSYSTEM: "1", ...overrides }; +} + +async function spawnCapture( + cmd: string[], + opts: { cwd?: string; env?: Record }, +): Promise<{ code: number; stdout: string; stderr: string }> { + const child = Bun.spawn(cmd, { + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + env: opts.env ?? ambientEnv(), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { code, stdout, stderr }; +} + +async function writeBunStub(dir: string, logPath: string): Promise { + const bunPath = join(dir, "bun"); + await writeFile( + bunPath, + `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\n`, + "utf-8", + ); + await chmod(bunPath, 0o755); +} + +describe("pre-push hook", () => { + test("exits 0 immediately when SKIP_WORKBENCH_HOOKS is set and does not spawn lint", async () => { + const work = await mkdtemp(join(tmpdir(), "pre-push-skip-")); + try { + await chmod(HOOK, 0o755); + const logPath = join(work, "bun.log"); + await writeBunStub(work, logPath); + const result = await spawnCapture([HOOK], { + env: ambientEnv({ + PATH: `${work}:${process.env.PATH ?? ""}`, + SKIP_WORKBENCH_HOOKS: "1", + }), + }); + expect(result.code).toBe(0); + expect(await Bun.file(logPath).exists()).toBe(false); + } finally { + await rm(work, { recursive: true, force: true }); + } + }); + + test("exits 0 immediately when CI is set and does not spawn lint", async () => { + const work = await mkdtemp(join(tmpdir(), "pre-push-ci-")); + try { + await chmod(HOOK, 0o755); + const logPath = join(work, "bun.log"); + await writeBunStub(work, logPath); + const result = await spawnCapture([HOOK], { + env: ambientEnv({ + PATH: `${work}:${process.env.PATH ?? ""}`, + CI: "true", + }), + }); + expect(result.code).toBe(0); + expect(await Bun.file(logPath).exists()).toBe(false); + } finally { + await rm(work, { recursive: true, force: true }); + } + }); + + test("runs lint, typecheck, and unit tests through bun, not e2e", async () => { + const work = await mkdtemp(join(tmpdir(), "pre-push-gates-")); + try { + await chmod(HOOK, 0o755); + const repo = join(work, "repo"); + await spawnCapture(["git", "init", "--initial-branch=main", repo], {}); + const logPath = join(work, "bun.log"); + await writeBunStub(work, logPath); + const result = await spawnCapture([HOOK], { + cwd: repo, + env: ambientEnv({ + PATH: `${work}:${process.env.PATH ?? ""}`, + }), + }); + expect(result.code).toBe(0); + const log = await readFile(logPath, "utf-8"); + expect(log).toContain("run lint"); + expect(log).toContain("run typecheck"); + expect(log).toContain("run test"); + expect(log).not.toContain("test:e2e"); + expect(log).not.toContain("walking-skeleton"); + expect(log).not.toContain("check:structural"); + } finally { + await rm(work, { recursive: true, force: true }); + } + }); + + test("does not mention test:e2e or walking-skeleton as a command it runs", async () => { + const source = await readFile(HOOK, "utf-8"); + for (const marker of HEAVY_SUITE_MARKERS) { + expect(source).not.toContain(marker); + } + expect(source).toContain("bun run lint"); + expect(source).toContain("bun run typecheck"); + expect(source).toContain("bun run test"); + expect(source).toContain("WORKBENCH_CHECK_SINCE"); + expect(source).toContain("origin/main"); + }); +}); + +describe("hooks:install", () => { + test("does not mention test:e2e or walking-skeleton as a command it runs", async () => { + const source = await readFile(INSTALLER, "utf-8"); + for (const marker of HEAVY_SUITE_MARKERS) { + expect(source).not.toContain(marker); + } + }); + + test("sets repo-local core.hooksPath when CI is unset", async () => { + const work = await mkdtemp(join(tmpdir(), "hooks-install-")); + try { + await spawnCapture(["git", "init", "--initial-branch=main", work], {}); + const result = await spawnCapture(["bun", "run", INSTALLER], { + cwd: work, + env: ambientEnv(), + }); + expect(result.code).toBe(0); + const hooksPath = await spawnCapture( + ["git", "config", "--local", "--get", "core.hooksPath"], + { cwd: work, env: ambientEnv() }, + ); + expect(hooksPath.stdout.trim()).toBe("scripts/git-hooks"); + } finally { + await rm(work, { recursive: true, force: true }); + } + }); + + test("is a no-op in CI and does not set core.hooksPath", async () => { + const work = await mkdtemp(join(tmpdir(), "hooks-install-ci-")); + try { + await spawnCapture(["git", "init", "--initial-branch=main", work], {}); + const result = await spawnCapture(["bun", "run", INSTALLER], { + cwd: work, + env: ambientEnv({ CI: "true" }), + }); + expect(result.code).toBe(0); + const hooksPath = await spawnCapture( + ["git", "config", "--local", "--get", "core.hooksPath"], + { cwd: work, env: ambientEnv() }, + ); + expect(hooksPath.code).not.toBe(0); + expect(hooksPath.stdout.trim()).toBe(""); + } finally { + await rm(work, { recursive: true, force: true }); + } + }); + + test("is a no-op when GITHUB_ACTIONS is set", async () => { + const work = await mkdtemp(join(tmpdir(), "hooks-install-gha-")); + try { + await spawnCapture(["git", "init", "--initial-branch=main", work], {}); + const result = await spawnCapture(["bun", "run", INSTALLER], { + cwd: work, + env: ambientEnv({ GITHUB_ACTIONS: "true" }), + }); + expect(result.code).toBe(0); + const hooksPath = await spawnCapture( + ["git", "config", "--local", "--get", "core.hooksPath"], + { cwd: work, env: ambientEnv() }, + ); + expect(hooksPath.stdout.trim()).toBe(""); + } finally { + await rm(work, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/hooks-install.ts b/scripts/hooks-install.ts new file mode 100644 index 000000000..a3af388ad --- /dev/null +++ b/scripts/hooks-install.ts @@ -0,0 +1,50 @@ +// Sets this checkout's local core.hooksPath to scripts/git-hooks so +// `git push` runs lint, typecheck, and unit tests. No-op in CI so +// `bun install` does not rewrite GitHub Actions git config. + +const HOOKS_PATH = "scripts/git-hooks"; + +function flagSet(value: string | undefined): boolean { + return value !== undefined && value !== ""; +} + +function main(): void { + if (flagSet(process.env.CI) || flagSet(process.env.GITHUB_ACTIONS)) { + return; + } + if (Bun.which("git") === null) { + return; + } + + const toplevel = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { + stdout: "pipe", + stderr: "pipe", + }); + if (toplevel.exitCode !== 0) { + return; + } + + const root = toplevel.stdout.toString().trim(); + const result = Bun.spawnSync( + ["git", "config", "--local", "core.hooksPath", HOOKS_PATH], + { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }, + ); + if (result.exitCode !== 0) { + const detail = + `${result.stdout.toString()}${result.stderr.toString()}`.trim(); + console.error( + detail.length > 0 + ? `hooks:install failed: ${detail}` + : "hooks:install failed: git config core.hooksPath", + ); + process.exit(result.exitCode ?? 1); + } +} + +if (import.meta.main) { + main(); +}