Local validation for Bad Bundle repos. It runs a repo's checks on the exact commit, on the developer's machine, and posts the result to GitHub as a commit status, so a PR carries a green check without a CI run. Written in TypeScript for Bun.
In these repos every change is built and tested on the developer's Mac before it's pushed, so hosted CI would only repeat that work, and couldn't use the Xcode and simulators the Mac already has. What was missing is proof, on the PR, that the commit being merged passed. local-check provides it, and a ruleset on main makes it a requirement for merging.
- It checks the commit out into a worktree kept in a cache outside the repo, and removes any untracked files there, so uncommitted changes and stale build output can't affect the result.
- It loads
local-check.config.tsfrom that commit and runs its setups, then its checks, in order. The first check to fail stops the run. - It stores the result in the clone's
.git/local-check/and posts it to GitHub as a commit status,Validate (local)unless the config names another. While the checks run, the status ispending. If the run is interrupted, it becomeserror. - If GitHub doesn't have the commit yet, the pre-push hook posts the stored result when you push it. So you can validate before or after pushing.
Statuses are posted with the gh CLI, so they use your own login.
You'll need Bun 1.4 or later, gh logged in with access to the repo, and git 2.31 or later.
-
Add a
package.json, pinned to a release tag:{ "private": true, "scripts": { "validate": "local-check", "prepare": "local-check install-hook" }, "devDependencies": { "@badbundle/local-check": "github:badbundle/local-check#v0.1.0" } } -
Run
bun install, and addnode_modules/to.gitignore. Commitpackage.jsonandbun.lock.The
preparescript runslocal-check install-hookon every install. It writes.githooks/pre-pushif the repo doesn't have one (commit it), and pointscore.hooksPathat.githooks, unless it already points somewhere else. -
Write
local-check.config.tsat the root of the repo. See Writing a config. -
Require the check on
main, with a bypass for admins in an emergency:gh api -X POST repos/OWNER/REPO/rulesets --input - <<'EOF' { "name": "Require local validation", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "bypass_actors": [{ "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" }], "rules": [{ "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": false, "required_status_checks": [{ "context": "Validate (local)" }] } }] } EOF
Then, to validate: commit, and run bun run validate. Only the committed HEAD is validated, and every new commit needs validating again.
The config is the default export of local-check.config.ts. It's loaded from the commit being validated, so a commit carries its own checks.
Import only types from this package (import type). Bun erases them, so the config loads from a bare checkout with no node_modules. The helpers a config needs come from the kit it's given instead:
import type { ConfigFunction } from "@badbundle/local-check";
export default (({ xcode }) => {
const ios = xcode({
version: "27.0",
simulator: {
name: "iPhone 18 Pro Max",
deviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro-Max",
runtime: "com.apple.CoreSimulator.SimRuntime.iOS-27-0",
},
});
return {
setup: [ios.setup],
checks: [
{ name: "Lint", run: ["make", "-C", "Vault", "lint"] },
ios.buildForTesting({ workspace: "Vault.xcworkspace", scheme: "CI_iOS", testPlan: "iOSAllTests" }),
ios.testWithoutBuilding(),
],
};
}) satisfies ConfigFunction;A config that needs no helpers can export a Config object instead.
A check has a name and a run, which is either a command or a function:
- A command is an array, such as
["make", "-C", "Vault", "lint"]. No shell is involved, so nothing needs quoting. It runs in the worktree, its output goes to the log, and the check fails if it exits non-zero. - A function gets the context and can do anything: run several commands, wait for something, read files, decide what to run. It fails by throwing, which
ctx.execdoes when a command fails.
A check can also have skip, a function that returns a reason to skip it, or nothing to run it. A skipped check is named on the status, so a green check can't hide one. If every check is skipped, the run fails.
{
name: "Fastlane config",
async skip(ctx) {
const wanted = (await Bun.file(`${ctx.worktree}/.ruby-version`).text()).trim();
const { stdout } = await ctx.capture(["ruby", "-e", "print RUBY_VERSION"]);
return stdout === wanted ? undefined : `Ruby ${wanted} isn't installed`;
},
async run(ctx) {
await ctx.exec(["bundle", "install", "--quiet"]);
await ctx.exec(["bundle", "exec", "fastlane", "lanes"]);
},
}setup is a list of functions that run before the first check. One can add to ctx.env for every command that follows, and can return a teardown, which runs after the last check, even if a check fails or the run is interrupted. Teardowns run in reverse order.
sha, worktree |
The commit, and the checkout of it where commands run. |
cacheDir |
Kept between runs, for build caches such as DerivedData. --clean empties it. |
stateDir |
Per-clone state in .git/local-check/. |
env |
Extra environment for every command. Setups can add to it. |
exec(command, { cwd, env }) |
Runs a command with its output in the log, as it's written. Throws if it exits non-zero. |
capture(command, { cwd, env }) |
Runs a command and returns { exitCode, stdout, stderr }. It doesn't log or throw, and an interruption doesn't stop it, so teardowns can use it. |
$ |
Bun Shell in the worktree, with env applied. Its output isn't logged. |
log(message) |
Writes a line to the log. |
signal |
Aborted when the run is interrupted. Pass it to anything that waits. |
waitFor(condition, { timeout, interval, signal, description })callsconditionuntil it returns something truthy, and returns that.xcode({ version, developerDir, simulator })returns helpers for Xcode projects:setupselects Xcode throughDEVELOPER_DIR, and creates the simulator.versionfinds/Applications/Xcode_<version>.app, the naming GitHub's runners use.- The simulator is created fresh for each run and deleted afterwards. It can have the same name as one you already have, which matters when tests check the device name, because it's always addressed by UDID.
build(...)andbuildForTesting(...)take a workspace, a scheme, an optional test plan and extra flags, and build into DerivedData undercacheDir, so later runs build incrementally.testWithoutBuilding(...)runs the newest.xctestrun. Parallel testing is off unless you turn it on, because its simulator clones are renamed "Clone 1 of …", which fails tests that check the device name. Diagnostics collection is off too, because after a failurexcodebuildcan spend up to ten minutes collecting it.
Everything untracked is removed from the worktree before each run. To keep something, such as a build cache that a lint tool writes into the repo, list it in worktree.keep:
{ worktree: { keep: ["Vault/.build"] }, checks: [/* ... */] }local-check [--clean] [<commit>] |
Validates a commit, HEAD by default, and posts the result. --clean empties the cache first, worktree included. |
local-check post <commit> |
Posts a stored result, once GitHub has the commit. |
local-check hook pre-push |
Runs as git's pre-push hook. It never blocks a push. |
local-check install-hook |
Writes .githooks/pre-push if it's missing, and points core.hooksPath at it. |
- The worktree and build caches are in
~/Library/Caches/local-check/on macOS,~/.cache/local-check/(or$XDG_CACHE_HOME) on Linux, and%LOCALAPPDATA%\local-checkon Windows. There's one directory per clone. SetLOCAL_CHECK_CACHE_DIRto use somewhere else. - Results and logs are in the clone's
.git/local-check/:results/<sha>.json,logs/<sha>.log, andlogs/post.logfor posts made by the hook.
The check is self-attested. It records that the commit passed on the machine that posted it, rather than on independent CI, and anyone with write access to the repo could post one without running anything. It suits repos where the people who can push are the people who validate.
bun install
bun test
bun run typecheck
bun run validate # local-check validates itselfbun run validate runs the local-check in your checkout against the commit being validated, using this repo's own local-check.config.ts.
To release, bump version in package.json, merge, and tag the merge commit v<version>. Repos pin a tag, and move to a new one when they're ready.
BSD 3-Clause. See LICENSE.