Contributing to offshoot
offshoot is at 0.x, single-maintainer, and moving fast. That means: ask before investing a lot of time in something structural (see "what needs an issue first" below), but small, well-scoped PRs are welcome and get reviewed quickly.
Dev setup
You need:
- Go 1.26+ with cgo enabled, which means a C compiler on
PATH(offshoot usesgithub.com/mattn/go-sqlite3, which is cgo-backed — there's no pure-Go build). macOS:xcode-select --install; Debian/Ubuntu:apt-get install build-essential. - The
sqlite3andsqldiffCLIs onPATH. The test suite shells out tosqlite3as a stock foreign writer and for.dumpequivalence checks, andoffshoot diff's default mode (and its tests) callsqldiff.- macOS:
brew install sqlite sqldiff, thenexport PATH="$(brew --prefix sqlite)/bin:$PATH"— thesqliteformula is keg-only, so brew does not link itssqlite3intoPATHfor you (and the stock macOS image may not ship one at all). - Debian/Ubuntu:
apt-get install sqlite3 sqlite3-tools(sqldiffis insqlite3-tools).
- macOS:
- Linux or macOS — there are no Windows code paths, and none are planned for v1
Tests gated on sqlite3/sqldiff skip when the binary is missing
locally, but fail when the CI environment variable is set (GitHub
Actions sets it automatically; see internal/testutil/require.go). So a
green go test ./... on a machine without sqldiff proves less than it
looks like; run CI=1 go test ./... to turn those skips into failures and
confirm your setup is complete.
Optional, only needed for the SDK test tier:
- Python 3.10+ (Python SDK tests and tooling; macOS:
brew install python@3.12, Debian/Ubuntu:apt-get install python3). The Makefile runspython3by default and refuses an older one with a message; when the platform'spython3is older (macOS ships 3.9), pass the interpreter explicitly:make PYTHON=python3.12 test-sdks. - Node 20+ (TypeScript SDK tests; macOS:
brew install node, elsewhere via nodejs.org or your package manager)
Optional, only needed for the pytest fixture plugin's OWN test tier
(sdk/python/offshoot/pytest_plugin.py — the offshoot-db[pytest] extra;
the base SDK's plain-unittest suites above need none of this):
pip install -e "sdk/python[pytest]" pytest-xdist
Optional, only needed for the LangGraph companion's end-to-end test tier
(sdk/python-langgraph; kept separate so the base SDK test still proves a
stdlib-only install):
python3 -m venv .venv-langgraph
.venv-langgraph/bin/pip install -e sdk/python -e "sdk/python-langgraph[test]"
go build -o offshoot ./cmd/offshoot
make lint # gofmt + go vet + staticcheck (staticcheck is advisory locally)
make lint-python # ruff check + ruff format --check over the SDKs, scripts and examples
Refreshing the hash-pinned CI requirements
CI never names a third-party Python package on a pip install line: every
one it installs directly comes from a hash-pinned lock under requirements/
(pip install --require-hashes -r requirements/<name>.txt), and the repo's
own packages install afterwards with pip install --no-deps -e. That
does not cover build isolation: pip install -e and python -m build
still fetch the build backend (setuptools) into an isolated environment
without hashes. Each
.txt is generated from the .in beside it, whose header names the exact
command. To change a dependency (or pick up new releases), edit the .in
and regenerate with uv:
uv pip compile --python-version 3.12 --generate-hashes --universal \
-o requirements/ci-langgraph.txt requirements/ci-langgraph.in
(same for ci-pytest-plugin and ci-publish-tooling). --universal keeps
the markers valid for both CI interpreters (setup-python 3.12 and ubuntu's
system python3). When an SDK's pyproject.toml changes its dependencies or
test extras, update the matching .in in the same PR — CI installs the
editable package with --no-deps, so a dependency missing from the lock
fails the job instead of being fetched unpinned. The Dockerfile's base
images are digest-pinned (image:tag@sha256:...) and Dependabot's docker
ecosystem bumps them weekly.
Test tiers
There are eight, and they cost very different amounts of time. Run the tier that matches what you touched — don't run torture on a docs typo, and don't skip it on a capture-engine change.
| Tier | Command | Cost | When required |
|---|---|---|---|
| Unit/integration | go test ./... -race |
seconds | Always, every PR |
| Torture (writer kill-9 + capturer restart) | make test-torture |
~5 minutes | Touching internal/capture or internal/session flush paths |
| Fuzz | make fuzz |
~3 min | nightly, on demand |
| S3 conformance | make test-s3 |
needs a real S3-compatible provider running (make ci-local-s3 spins up RustFS in Docker) |
Touching internal/store's S3 backend or the CAS probe |
| SDKs | make test-sdks |
needs python3 + Node 20+ |
Touching sdk/python, sdk/typescript, or the daemon API surface they depend on |
| pytest fixture plugin | make test-pytest-plugin |
needs pip install -e "sdk/python[pytest]" pytest-xdist |
Touching sdk/python/offshoot/pytest_plugin.py or its test suite — kept OUT of test-sdks on purpose, since that tier proves the base SDK works with no pytest installed at all |
| LangGraph companion | make test-python-langgraph |
needs the isolated [test]-extra venv above |
Touching sdk/python-langgraph, LangGraph-facing checkpoint behavior, or the daemon API surface it depends on |
| SDK publish dry-run | make dry-run-sdks |
needs python3 (+ pip install build twine) and npm |
Touching either SDK's manifest (pyproject.toml, package.json, sdk/VERSION) or .github/workflows/publish.yml — see "Release process" below |
go test ./... -race is hermetic and needs only the sqlite3 CLI — that's
the bar for "does this even work," and CI runs it on every PR. make test
runs the same suite without -race if you want a faster local loop, but
-race is what actually gets checked before merge, so run it before you
push.
make test-torture is not optional cosmetics: it's the suite that proves the
capture engine detects every divergence instead of silently absorbing it
(random kill -9 of the writer plus graceful capturer shutdown/restart,
verified with sqlite3 .dump equivalence after every round). The harness does
not SIGKILL the capturer; docs/testing.md
is the canonical statement of that boundary. If your change touches
internal/capture or a flush path in internal/session, a PR without a
torture run attached will get sent back for one, not tests we'll take on
faith.
make test-s3 needs a real provider or a local RustFS — the in-process fake
used by the unit tests proves nothing about a real bucket's conditional-write
behavior, which is what the whole compare-and-swap safety story rests on.
Local CI
.github/workflows/ci.yml runs five jobs on every PR: test (ubuntu,
gofmt + go vet + go test ./... -race), cow-paths (macOS), metrics-lint
(promtool over the exposition format), sdks (both SDK suites plus the
publish dry-run), and s3-conformance (RustFS in Docker). Prose-only
changes (docs/**, site/**, **.md) skip ci.yml entirely by design;
.github/workflows/pages.yml builds the docs site on those PRs instead, so
a broken intra-doc link still fails the PR.
cow-paths is the one macOS job: since v0.2.12 checkouts clone from a
by-chain cache entry and at-rest checkpoints diff against a reflinked
shadow, and those fast paths only run on a filesystem that can clone, which
ubuntu's ext4 cannot — so that job runs just the packages holding those
paths on APFS, and proves a clone works before trusting the result. The
expensive tiers live in .github/workflows/nightly.yml: torture daily,
fuzz daily, linux-reflink daily (the copy-on-write tests on a loopback
XFS filesystem with reflinks, so Linux FICLONE is covered and not only
APFS), macos-test (the full go test ./... on macOS) weekly, and
real-provider-conformance against AWS S3 daily (on main, when the
NIGHTLY_S3 repository variable is on). docs/testing.md explains what each of those
proves.
make ci-local mirrors the test, s3-conformance and sdks jobs
locally through four targets, in minutes instead of waiting on a runner
(cow-paths needs a real APFS runner and metrics-lint a pinned promtool;
see "What it does NOT replicate" below):
| Target | Mirrors | What it needs |
|---|---|---|
make ci-local-host |
the test job (ubuntu-only matrix), run on this host |
sqlite3/sqldiff on PATH (dev-setup prerequisite above) |
make ci-local-linux |
the test job's ubuntu leg, in Docker |
Docker; module/build caches live in named volumes so repeat runs are fast |
make ci-local-s3 |
the s3-conformance job |
Docker (spins up RustFS at the digest ci.yml pins, runs make test-s3, always tears down) |
make ci-local-sdks |
the sdks job |
python3, Node 20+, and optionally pip install build twine for the dry-run-sdks step (skipped loudly, not silently, if absent) |
make ci-local runs those four in sequence and prints a pass/fail-per-job
summary table with timings; a single job is runnable alone too. See
scripts/ci-local.sh's header comment for the job-by-job detail.
What it does NOT replicate — CI remains the merge gate, this is pre-merge signal:
- GHA runner image drift.
ci.yml's macOS leg broke without a version bump on our side becausemacos-latestrolled to a new image ("macos-26") that had quietly dropped thesqlite3CLI from PATH — seeci.yml's "Install sqlite3 CLI (macos)" step comment.ci-local-hostruns on whatever your machine's own Homebrew/PATH state already is; it cannot catch a runner image regression like that, only a realmacos-latestrun can. - Runner pip/HOME layout. The
sdksjob's pytest-plugin step installs into a venv instead ofpip install --break-system-packagesonto system Python specifically because, on the actual (non-root, no-sudo) GHA runner, that flag falls back to a user-site install under$HOME— and pytest'spytesterfixture repointsHOMEfor its subprocess runs, making pytest invisible to them (ValueError: Pytest terminal summary report not found).ci-local-sdksuses a venv from the start, so it can't reproduce the runner-layout failure mode it was written in response to — seeci.yml'ssdksjob comment for the full incident. - Actions-specific behavior —
actions/checkout,actions/setup-go, caching,GITHUB_PATH, etc. — none of it runs locally;ci-localcalls the same underlying commands (go vet,go test,make test-s3, ...) directly.
A green ci-local is strong local evidence, not a substitute for the real
GitHub Actions run — push and let CI have the final word.
Code style
- Match the patterns already in the file you're editing over anything you'd
reach for on a green field. This codebase leans on long doc comments that
explain why (races found, invariants relied on, what breaks if you change
the order of two calls) — see
internal/capture/engine.gofor the norm. Terse code with a paragraph above it explaining the non-obvious part beats clever code with no comment. - Errors are wrapped with context, not just passed through:
fmt.Errorf("rebase: checkpoint TRUNCATE: %w", err)— prefix with the operation, wrap with%wso callers can stillerrors.Is/errors.As. - No new dependencies without a good reason — the dependency list is
deliberately short (AWS SDK,
mattn/go-sqlite3,superfly/ltx). Pulling in something new for a small feature is a smell; ask in the issue first. go vet ./...andgofmtclean before you push.
PR expectations
- Tests required for behavioral changes. A PR that changes behavior with no test coverage will get asked for one before review continues.
- Torture run required (and mentioned in the PR description — the template has a checkbox) for anything touching capture or flush paths, per the table above.
- Docs updated in the same PR when behavior changes — README, ROADMAP, or code comments, whichever describes what you touched.
- Conventional commits:
feat:,fix:,test:,docs:,chore:, etc. Look atgit logfor the norm in this repo. - DCO sign-off required. Every commit needs a
Signed-off-bytrailer — usegit commit -s. This project uses the Developer Certificate of Origin instead of a CLA: signing off is your statement that you wrote the patch or otherwise have the right to submit it under the project's license (Apache-2.0), not a copyright assignment to anyone. Unsigned commits will get bounced back for a rebase with-s, not silently merged.
Release process
There are two independent release tracks — the offshoot binary and the
two SDKs — with two independent versioning schemes, on purpose: the SDKs
are thin wrappers over a stable daemon wire protocol and can move on their
own cadence, while the binary's v0.2.x tags govern the on-disk storage
format. The two schemes already read differently (binary at v0.2.x, SDKs
at 0.1.0) — that divergence is the design, not drift.
Binary releases
Tag v0.2.<n> (vX.Y.Z generally) and push it.
.github/workflows/release.yml builds Linux/macOS binaries for both
architectures, creates a GitHub release, and publishes a ghcr.io Docker
image. workflow_dispatch builds the same artifacts under a
dev-<short-sha> name without tagging, for a smoke check before tagging.
Before tagging, bump every surface that states the number, in one PR:
CHANGELOG.md— a## [X.Y.Z] - YYYY-MM-DDheader above the entriesFormula/offshoot.rb'surl+sha256server.json's"version"(the MCP registry manifest)docs/ci-recipes.md'sOFFSHOOT_VERSION: vX.Y.Zpins- the version chip in
site/index.html's header
scripts/check-version-surfaces.sh vX.Y.Z fails loudly, naming each
surface that disagrees with the tag, so run it before pushing the tag;
release.yml runs it too before signing anything. The Formula is the one
surface bumped after the tag (its sha256 is of the tarball GitHub serves
for the tag), so it is checked separately once that commit lands:
scripts/check-version-surfaces.sh --with-formula vX.Y.Z.
SDK releases
Both SDKs publish together, from one tag, at one version number —
sdk-v<version> (e.g. sdk-v0.1.0), never sdk-py-v.../sdk-ts-v...
separately. This is the simplest scheme for a two-SDK repo where both
clients track the same daemon wire protocol and ship from the same
PR/review cadence; splitting them into independently-versioned tags would
mean two release processes to keep straight for no benefit either SDK
currently needs. Revisit only if one SDK ever needs to ship a fix the other
genuinely doesn't.
sdk/VERSION is the single source of truth for the SDK version number.
Both manifests spell the version out literally (PyPI and npm both require
a real version string in their own manifest — neither supports pulling it
from an external file at publish time without extra tooling this repo
doesn't carry), so a release bump is three edits kept in lockstep by hand:
sdk/VERSIONsdk/python/pyproject.toml's[project].versionsdk/typescript/package.json'sversion
python3 scripts/check_sdk_versions.py (also make check-sdk-versions)
fails loudly, listing every mismatch, if any of the three disagree — this
runs as the first step of make dry-run-python-sdk and again in
.github/workflows/publish.yml's check job before either publish job
does anything. Tagging sdk-v<version> is a fourth place the same number
has to agree; scripts/check_sdk_tag_version.py <tag> (run automatically
on a tag push) catches a tag cut against a stale sdk/VERSION.
Publishing gate
.github/workflows/publish.yml triggers on sdk-v* tags and
workflow_dispatch, and always builds + validates both SDKs' real publish
artifacts (sdist/wheel + twine check + install-test for PyPI; npm pack
- install-test for npm). It only uploads them when the
PUBLISH_ENABLEDrepository variable (Settings > Secrets and variables > Actions > Variables) is exactly"true"— unset or anything else runs the same job in dry-run mode (build + check + install-test, no upload). The same build-and-check tier also runs on every PR viamake dry-run-sdksinci.yml'ssdksjob, so a manifest mistake is caught long before a release tag exists, not discovered the first timePUBLISH_ENABLEDflips on.
Turning it on requires, at minimum:
- The
offshoot-dbname claimed on PyPI, with Trusted Publishing (OIDC) configured for this repo +publish.yml+ thepypijob — no PyPI secret is stored in this repo;pypa/gh-action-pypi-publishauthenticates via the job'sid-token: writepermission alone. - The
@offshoot-dbscope claimed on npm, and registry auth configured: today that means an automation token in theNPM_TOKENrepository secret (used asNODE_AUTH_TOKENfornpm publish --provenance). npm has since shipped OIDC-based Trusted Publishing for GitHub Actions, which would remove the need for a stored token the same way PyPI's does — adopting it means confirming the npm CLI version on the runner supports it and configuring@offshoot-db/clientas a Trusted Publisher on npmjs.com for this exact repo + workflow, then deleting theNODE_AUTH_TOKENenv var from thenpmjob. Not done yet;NPM_TOKENis the working path today. PUBLISH_ENABLED=trueset as a repository variable.- No org transfer needed — the manifests already match the real repo.
sdk/python/pyproject.toml's[project.urls],sdk/typescript/package.json'srepository/homepage/bugs, andserver.json'srepository.urlall point atgithub.com/sricola/offshoot— the repo's actual remote today (git remote -v).npm publish --provenanceindependently verifies thatpackage.json'srepository.urlmatches the repo the publish is actually running from (it builds the provenance attestation from the real GitHub OIDC claims), so publishing fromsricola/offshootwith the manifest already pointing atsricola/offshootpasses that check with nothing further to do here. Only the PyPI/npm name-claims andPUBLISH_ENABLEDabove are still outstanding; the package registry names themselves stayoffshoot-db(PyPI) and@offshoot-db(npm) — those are unrelated to the repo's GitHub identity and are not being renamed.
Claiming both names is a manual, one-time, user-side action tracked as a deliberately-out-of-band item in ROADMAP.md and docs/status.md — not something a PR against this repo can do on its own.
PUBLISH_ENABLED=true alone is not enough to publish anything. A real
upload additionally requires the workflow run itself to be an actual
sdk-v* tag push — workflow_dispatch (a manual "Run workflow" click)
is always dry-run, unconditionally, even with the gate on. Note that a
ref check alone (startsWith(github.ref, 'refs/tags/sdk-v')) is NOT
sufficient to establish that: GitHub's "Use workflow from" picker lets a
maintainer dispatch a workflow_dispatch run against a tag ref, which makes
github.ref match refs/tags/sdk-v* too, even though nothing was pushed.
Both the pypi and npm jobs' actual upload steps therefore check
github.event_name == 'push' as well (startsWith(github.ref, 'refs/tags/sdk-v') && github.event_name == 'push'), not just the ref
pattern and not just the check job's resolved output, so the guard
survives both a dispatch-against-a-tag-ref run and a bug in that job's own
gating logic.
Optional extra gate: GitHub Environments. publish.yml's pypi and
npm jobs each target a GitHub Environment (pypi-publish,
npm-publish respectively). Both exist with no protection rules the first
time the workflow runs, until a maintainer adds one — configuring required
reviewers on either (repo Settings > Environments > pypi-publish /
npm-publish > required reviewers) is a one-click way to require a human
approval on top of the tag+PUBLISH_ENABLED gate before that job's steps
run at all. Like the two name claims and the PUBLISH_ENABLED flip, this is
a manual, user-side action this repo's PRs can't do on their own — worth
doing before the first real release, not required for the pipeline to be
correct without it.
What's most welcome right now
- Docs. The gap between what's implemented and what the design spec promises is real at 0.x, and closing that gap in writing — an honest implemented/deferred matrix, a CLI reference, fixing stale examples — is some of the highest-leverage work available and doesn't need prior coordination.
- Provider conformance runs.
make test-s3against AWS S3 and a report of pass/fail (see the provider table in README.md) is directly useful and low-risk to contribute. - CLI ergonomics. Better error messages,
--helptext, flag consistency, output formatting — open a PR directly, no issue needed first, unless it changes a flag's meaning or removes one.
What needs an issue first
Open an issue (or comment on an existing one) before writing code for:
- Storage format — anything touching the LTX segment layout, snapshot format, ref schema, or layout versioning. This is the hardest thing to change once something has written data with it.
- Capture engine —
internal/capture's lock dance, rebase/takeover logic, or the Sink contract. The invariants here (no duplicateApply, every divergence detected not absorbed) are load-bearing and easy to violate in a way that only shows up under torture. - Fencing — epoch bumps, lease semantics, ref CAS. This is what keeps two writers from corrupting a branch; a change here needs a design discussion, not just a green test run.
For everything else: if you're not sure, open an issue and ask — that's cheaper than a rejected PR.