Merge changes
Write a check
What lives in .bay/, what bay sends a check, what a check sends back, and how bay judges it.
A check is any program that reads one request and prints one result. bay runs it on the exact candidate, in a sandbox, and judges only the result it prints. This page names the files a repository keeps for bay, then the check contract, field by field.
What lives in .bay/
bay reads the configuration, the policy, .gitattributes and the check programs from main, so a change never judges itself.
| File | What it holds | Where it is explained |
|---|---|---|
.bay/config.toml | How the repository works with bay: main, remote, trailers, runner clocks, the agent product and plugins. | Configuration |
.bay/policy.toml | What a change must pass: each [[check]], approvals, limits and holds. | Configuration |
Your check programs, such as .bay/check.py | The code a [[check]] runs. List every file it reads to decide in adapter. | This page |
Your plugins, such as .bay/notify | Programs for notices, wake-ups, runtime records or a large-file store. | Configuration |
.gitattributes | How each path collides: merge=union, lockable and bay-offload. | Configuration |
.bay-assets | One line per large file kept outside Git: its SHA-256, size and path. Each change carries its own lines. | Configuration |
The agent settings file, such as .claude/settings.json | bay's hook entries. bay install writes them; you merge them once. | Agents and hooks |
A first check
This check runs one command on the candidate and reports it as one unit. It uses only Python's standard library, so any machine with Python 3 runs it. Commit it with the policy entry below, then test it before you merge it.
#!/usr/bin/env python3
"""A bay check: run one command on the candidate, and report it as one unit."""
import json, subprocess, sys, time
request = json.load(sys.stdin) # bay.check.request/1
command = sys.argv[1:] # the policy's command, after this script
started = time.monotonic()
try:
run = subprocess.run(command, capture_output=True, text=True)
verdict = "pass" if run.returncode == 0 else "product_failure"
output = run.stdout + run.stderr
except OSError as error: # the tool is missing: not the change's fault
verdict, output = "infra_failure", str(error)
print(output, file=sys.stderr) # people read stderr; bay reads stdout
unit = {"id": " ".join(command), "verdict": verdict, "seconds": round(time.monotonic() - started, 2)}
if verdict != "pass":
unit["reason"] = output.encode()[-1800:].decode(errors="ignore") # bay shows at most 2,048 bytes
json.dump({
"schema": "bay.check.result/1",
"exercised": request["candidate"], # the exact commit and tree it ran on
"plan": {"selected": 1, "completed": 1},
"checks": [unit],
}, sys.stdout)Name it in the policy
# .bay/policy.toml on main
[[check]]
id = "unit"
environment = "linux-x86_64" # a runner that serves it lists it in bay.checkEnvironment
command = ".bay/check.py npm test"
adapter = [".bay/check.py"] # every file that decides a pass, run from the base
expected_minimum = 1 # the least units the check must report
timeout_s = 600
path = ["/usr/local/bin", "/usr/bin", "/bin"]bay plugin test check --expected-minimum 1 -- .bay/check.py npm testWhat bay sends
bay writes one bay.check.request/1 document to the check's stdin. A check ignores a field it does not know.
| Field | What it holds |
|---|---|
candidate | The commit and tree to judge. The working directory holds exactly this tree. |
changed_paths | The paths the candidate changes against the main commit it was merged onto. |
plan | The check's id, and the policy's revision as version. |
requires | The environment the check runs in, and its pinned fixtures, if any. |
timeout_s | How long the check may run. |
inputs | What the check may read: the tree alone, or also commit or history. |
base | The commit and tree of the main commit the candidate was merged onto. |
change, messages | The change's id, intent and actor, and its own commit messages. Absent for a batch. |
materialized | changed when the check asked for only the changed files. |
git | Only with inputs = ["history"]: where to read the repository's history, read-only. |
What a check sends back
The check prints one bay.check.result/1 document on stdout. People read stderr; bay never does.
- A
verdictispass,product_failure,infra_failureorunknown. product_failureblames the change.infra_failureblames the machine or a tool.- A
reasontells the author why a unit did not pass. bay shows at most 2,048 bytes. - A check that cannot run here prints
{"schema": "bay.check.result/1", "unavailable": "<reason>"}instead.
| Field | What it holds |
|---|---|
schema | bay.check.result/1. |
exercised | The commit and tree it ran on. They must match the request's candidate. |
plan | selected and completed: how many units it chose and finished. selection may say declared_empty. |
checks | One entry per unit: its id, verdict, and optionally seconds and reason. |
How bay judges the result
- An exit code alone is never evidence. With no valid result, the check is
unknown. - A result for another commit or tree is rejected whole, as
unknown. - Any unit with
product_failurefails the check, and the change does not merge. - Fewer units than
expected_minimum, or fewercompletedthanselected, isunknown, never a pass. - A unit with
infra_failuremakes the checkinfra_failure; areasonover 2,048 bytes does too. unavailablekeeps no record. The change waits withCHECK_UNAVAILABLEfor a machine that can run it.
Checks that hold up
These habits come from checks that run every day on large repositories.
- One program, several modes: name the mode in
command, such as.bay/check.py lintand.bay/check.py test web. - List every judge in `adapter`: helpers, baselines and schemas too. bay runs them from the base.
- Cheap checks first: a failed check ends the run, so a fast lint spares the slow tests behind it.
- Wake only what a change touches:
when_pathsmakes an app's tests required only when its paths change. - Copy only what a check reads:
pathslimits the tree bay builds for it, so a large repository checks quickly.
- Name the machine's settings: each variable in
env, each tool folder inpath. - Say `unavailable` when no worker can run it: a busy queue is never a pass, nor the change's fault.
- Keep secrets out of `reason`: bay writes it into Git, so redact tokens and keep the tail of the output.
- Set `expected_minimum` from a real run: the measured count minus a margin, never zero.