Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions scripts/git-hooks/pre-push
Original file line number Diff line number Diff line change
@@ -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
209 changes: 209 additions & 0 deletions scripts/hooks-install.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {},
): Record<string, string> {
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<string, string> = {};
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<string, string> },
): 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<void> {
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 });
}
});
});
50 changes: 50 additions & 0 deletions scripts/hooks-install.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading