Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

416 lines · 23.6 KBRaw History
1# Diffinery
2
3A git forge for teams of people and AI agents, running entirely on Cloudflare.
4Git hosting, pull requests and reviews, issues, GitHub-Actions-compatible CI,
5an MCP server for agents, and sandboxes, with no servers to run.
6
7- **Drop-in for GitHub workflows.** `.github/workflows/*.yml` runs as-is: same
8 triggers, expressions, matrices, `needs`, outputs, caches, artifacts, secrets,
9 OIDC, `actions/checkout`, `setup-node/python/go`, and remote JavaScript and
10 composite actions.
11- **Built for agents.** Agents are first-class accounts owned by a person or
12 team, with their own tokens and limits. They can't push or merge to protected
13 branches, and their pull requests always need a human approval. Every commit
14 they make is stamped with who they act for. They work through MCP
15 (`/mcp`) or the REST API, and can start sandboxes to build and test.
16- **Secure by default.** People sign in with passkeys, with no passwords to
17 leak or phish. Invites, roles and branch protection decide what they can do. Pushes are scanned for
18 leaked credentials and blocked if they contain any; nothing from a blocked
19 push is kept. A repository admin (a person, never an agent) can push anyway
20 with `git push -o allow-secrets=<reason>`, which is audited. A file can opt
21 out with a `diffinery:allow-secret` comment, for test fixtures. Secrets use
22 envelope encryption, and every sensitive action goes to an append-only audit log.
23
24## Documentation
25
26The full documentation is public at [diffinery.com/docs](https://diffinery.com/docs):
27guides for every feature, plus REST API, CLI and MCP references generated from
28this code. For AI agents there's [llms.txt](https://diffinery.com/llms.txt),
29[llms-full.txt](https://diffinery.com/llms-full.txt), a Markdown version of
30every page (add `.md` to its URL) and an
31[OpenAPI document](https://diffinery.com/docs/api/openapi.json). The pages live
32in [apps/diffinery/docs](apps/diffinery/docs).
33
34## How it fits on Cloudflare
35
36| Piece | Cloudflare product |
37|---|---|
38| HTTP, git smart HTTP, REST API, MCP, web UI | Workers (Hono) |
39| One repository: refs, objects, PRs, issues, reviews, checks, runs | Durable Object with SQLite (`RepoDO`), one per repository |
40| Large objects, Git LFS, container images, logs, caches, artifacts, event archive | R2 |
41| Accounts, orgs, tokens, invites, notifications, audit log | D1, read from nearby replicas through the Sessions API (each browser keeps a bookmark so it reads its own writes) |
42| Webhooks, notifications, audit fan-out | Queues (with a dead-letter queue) |
43| Workflow runs (durable, survive restarts, 6 h job timeout) | Workflows |
44| Job runners and agent sandboxes | Containers (Ubuntu 24.04 image in `containers/runner`) |
45| Scheduled workflows (`on: schedule`) | Cron Triggers |
46| Live run logs and PR updates | Durable Object WebSockets (hibernation) |
47| Sign-in | Passkeys (WebAuthn) and sessions in D1. Cloudflare Access guards only `/auth/access`, the email recovery path |
48
49```
50apps/diffinery the Worker (src/index.ts), D1 migrations, wrangler config, e2e test
51apps/cli the `diffinery` command-line client
52containers/runner runner agent + sandbox server, and the container image
53packages/git-core git objects, packfiles, deltas, diff/merge (pure TypeScript)
54packages/workflow-yaml GitHub Actions workflow parser, expressions, matrix, cron
55packages/shared types shared by the Worker and the runner
56scripts bootstrap.sh, ensure-secrets.sh, render-config.mjs
57```
58
59## Deploying
60
61Diffinery deploys itself: this repository lives at
62`diffinery.com/codician-team/diffinery`, and every push to `main` there runs
63`.github/workflows/deploy.yml` on Diffinery's hosted runners, followed by the
64live test (`.github/workflows/live-test.yml`). GitHub stays the upstream for
65now: `.github/workflows/sync-to-diffinery.yml` copies every branch and tag, and
66the secrets and variables below, to Diffinery on each push, then waits for the
67Actions runs there and fails if any of them fail. Running Deploy by hand on
68GitHub still deploys, as a fallback.
69
70The workflow needs two repository secrets:
71
72- `CLOUDFLARE_API_TOKEN`: account permissions Workers Scripts, D1, R2,
73 Queues, Workflows, Containers and Access: Apps and Policies (all Edit), and
74 zone permission Workers Routes Edit on `diffinery.com`.
75- `CLOUDFLARE_ACCOUNT_ID`
76
77On each deploy, `scripts/bootstrap.sh` creates anything that is missing: the D1
78database, R2 bucket, queues and one Access application that covers only
79`/auth/access` (email-based account recovery). It then applies D1 migrations. `wrangler deploy`
80builds and pushes the runner image. On the first deploy, `ensure-secrets.sh`
81generates `MASTER_KEY` and the OIDC signing key inside the job, so neither
82value is ever printed.
83
84If the token can't read your Zero Trust organization, set a repository
85**variable** `ACCESS_TEAM_DOMAIN` (for example `codician.cloudflareaccess.com`).
86
87To run your own Diffinery on another domain, set `DIFFINERY_DOMAIN` (for
88example `git.example.com`) when running `scripts/bootstrap.sh`. The Worker's
89route, `PUBLIC_URL` and the email sender follow it. Set `DIFFINERY_ZONE` if
90the Cloudflare zone isn't the domain's last two labels (for example
91`example.co.uk`), and `MAIL_DOMAIN` to send email from another domain. The
92token then needs Workers Routes Edit on that zone instead of `diffinery.com`.
93
94Email recovery uses Access's email one-time PIN, which works without any setup.
95You can also add GitHub as a login method under Zero Trust → Settings → Authentication.
96
97## Signing in and inviting people
98
99Everyone signs in with a passkey. There are no passwords.
100
1011. **First sign-in for admins.** The emails in `BOOTSTRAP_ADMINS` (in
102 `apps/diffinery/wrangler.jsonc`) become site admins. Open
103 `https://diffinery.com/auth/access`, confirm your email with the one-time
104 code, and add a passkey on the page that follows.
1052. **Inviting someone.** An admin or org owner creates an invite from
106 *Settings → Site administration*, an organization's *People* tab, or
107 `diffinery invite friend@example.com --org codician`. Diffinery emails them
108 the link; they open it, pick a username and create a passkey, and they're in.
1093. **Lost passkey.** A site admin clicks *Sign-in link* next to the person in
110 *Settings → Site administration*; Diffinery emails them the one-time link,
111 which works for 24 hours. People can also recover through `/auth/access` by email.
1124. **More devices.** Add passkeys under *Settings → Passkeys and sessions*, or
113 keep one in a password manager that syncs. The same page signs out other
114 browsers.
1155. Repository and org roles (read, triage, write, maintain, admin) decide what
116 each person can do.
117
118Nobody needs a Cloudflare account. Git and the CLI use a personal token from
119*Settings → Access tokens*.
120
121```sh
122git clone https://diffinery.com/codician/website.git # username: anything, password: your dfy_ token
123diffinery login && diffinery auth setup-git # or let the CLI handle credentials
124```
125
126## Email
127
128Diffinery sends email through [Cloudflare Email Sending](https://developers.cloudflare.com/email-service/)
129from `notifications@diffinery.com` (`MAIL_FROM` in `wrangler.jsonc`). Every
130email has an HTML and a plain-text version, and they work like GitHub's:
131
132| Group | Emails | Can be turned off |
133| --- | --- | --- |
134| Security | New sign-in from an unknown browser or device, passkey added or removed, access token created or about to expire, agent added, push protection bypassed or a push blocked | No |
135| Account | Invitations, sign-in links, welcome, account disabled or enabled, test email | No |
136| Participating | Issues and pull requests you opened, commented on, reviewed, were assigned, asked to review, @mentioned in or subscribed to: new comments, reviews, pushes, merges, closes and reopens | Yes |
137| Watching | Everything in repositories you watch with *All activity* | Yes |
138| Actions | Failed workflow runs you started (all results if you ask) | Yes |
139| Repositories | Repositories created or deleted in your organizations, repository access granted | Yes |
140| Organizations | Organization role changes, accepted invitations, access requests (admins) | Yes |
141
142- **Settings.** *Settings → Notifications* turns each group on or off, on
143 Diffinery and by email, lists the repositories you watch and sends a test
144 email. Each repository has a *Watch* menu (participating, all activity,
145 ignore) and each issue and pull request a *Subscribe / Unsubscribe* button.
146 Your own comments and changes don't email you unless you ask.
147- **Unsubscribing.** Emails carry `List-Unsubscribe` and one-click
148 unsubscribe headers (RFC 8058), so mail apps show an Unsubscribe button, and
149 the footer links mute the thread, stop watching the repository or turn off
150 that group. Links are signed, so they work without signing in.
151- **Threading.** Emails about one issue or pull request share
152 `References`/`In-Reply-To` headers and a `[owner/repo]` subject, and carry
153 `X-Diffinery-Reason` (like `X-GitHub-Reason`) for filters.
154- **Delivery.** Emails go through the `diffinery-mail` queue (retries with
155 backoff, then the `diffinery-mail-dlq` dead-letter queue) and are logged for
156 90 days. Site admins see delivery status, the log and a preview of every
157 template under *Settings → Email*, and can send every template to
158 themselves.
159- **API.** `GET/PUT/DELETE /api/v3/repos/{owner}/{repo}/subscription`,
160 `GET/PUT /api/v3/diffinery/notification-settings`,
161 `GET /api/v3/diffinery/emails`, `POST /api/v3/diffinery/emails/test`; for
162 site admins `GET /api/v3/diffinery/admin/email` and
163 `POST /api/v3/diffinery/admin/emails/samples`.
164
165**Setting it up.** Onboard the sending domain once in the Cloudflare dashboard
166(*Compute → Email Service → Email Sending → Onboard Domain*); Cloudflare adds
167the bounce MX, SPF, DKIM and DMARC records. Each deploy runs
168`scripts/email-sending.sh`, which checks those records (with the *Email
169Sending: Read* permission on the API token, or the public DNS records without
170it) and warns if mail can't be delivered. In `wrangler dev`, Miniflare accepts
171emails without sending them; `GET /api/v3/diffinery/emails` shows the full
172message there.
173
174## Agents
175
176Create an agent under *Settings → Agents* or with `diffinery agent create reviewer-bot --org codician`.
177Then point any MCP client at it:
178
179```json
180{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_…" } } } }
181```
182
183Tools include `get_file`, `list_tree`, `commit_files` (no git client needed),
184`create_branch`, `create_pull_request` (with an `intent` for reviewers),
185`review_pull_request`, `merge_pull_request` (policy-guarded), issues,
186`list_workflow_runs` and `get_job_logs`, `rerun_workflow`, releases
187(`create_release`, `upload_release_asset`, `generate_release_notes`), and
188`sandbox_create`/`sandbox_exec` for running builds and tests.
189
190Agent guardrails apply everywhere: git, REST, MCP and the web UI.
191
192- An agent's role is capped at write, and never exceeds its owner's role.
193- Agents can't push to the default branch or protected branches, and can't
194 merge there. Turn this on per branch with `agentsMayPush` or `agentsMayMerge`.
195- A pull request opened by an agent needs at least one human approval, and
196 approvals from agents don't count toward it.
197- Agents can't change branch protection or read or set secrets.
198- Agents can't change or delete environments or their branch policies, and
199 can't dismiss reviews.
200- Commit statuses and check runs that agents report never satisfy a required
201 check.
202
203## Workflows
204
205Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`).
206Supported triggers are `push`, `pull_request`, `pull_request_target`,
207`merge_group`, `workflow_dispatch` (with inputs), `schedule` and `workflow_run`
208(up to three levels deep, like GitHub), with branch, tag and path filters.
209
210Pull requests behave as on GitHub:
211
212- `pull_request` runs check out the test merge commit, `refs/pull/<n>/merge`
213 (the head merged into the current base), with `GITHUB_REF` set to that ref.
214 While a pull request conflicts with its base, its `pull_request` workflows
215 don't run. `refs/pull/<n>/head` and `refs/pull/<n>/merge` can be fetched but
216 not pushed.
217- `pull_request_target` runs the base branch's own workflow on the base commit,
218 with the repository's secrets, so a pull request can't change what runs.
219 Check out the pull request's code yourself only if you trust it.
220- Both report their checks on the pull request's head commit. `opened`,
221 `synchronize`, `reopened` and `closed` (with `pull_request.merged`) fire.
222- Merge queue: turn on **Require a merge queue** in a branch protection rule
223 (or `"mergeQueue": {"method": "squash"}` through the API). "Merge when ready"
224 then adds a pull request to the queue. Each entry gets a merge group branch,
225 `gh-readonly-queue/<base>/pr-<n>-<head sha>`, holding the base, every pull
226 request ahead of it and its own changes, and `merge_group` (and `push`)
227 workflows run on it. Once its required checks pass and the entries ahead have
228 merged, the base fast-forwards to exactly the tested commit. A group that
229 fails, conflicts or exceeds the check timeout leaves the queue with a comment
230 saying why, and the groups behind it are rebuilt. The queue is at
231 `/<owner>/<repo>/queue/<branch>`; the API is
232 `PUT`/`DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue` and
233 `GET /repos/{owner}/{repo}/merge-queue/{branch}`, `diffinery pr merge` queues
234 automatically, and the MCP `merge_pull_request` tool does too.
235
236Supported workflow features:
237
238- `needs` and job outputs, `if` with `success()`, `failure()`, `always()` and
239 `cancelled()`, `strategy.matrix` with include and exclude, `fail-fast` and
240 `max-parallel`, `concurrency` with `cancel-in-progress`, and `timeout-minutes`.
241- `continue-on-error`, `defaults.run`, `env` at every level, `$GITHUB_OUTPUT`,
242 `$GITHUB_ENV`, `$GITHUB_PATH`, `$GITHUB_STEP_SUMMARY`, and masking of
243 secrets and `::add-mask::` values.
244- `secrets.*`, `vars.*`, `GITHUB_TOKEN` (a per-job token), and
245 `permissions: id-token: write` for OIDC (issuer `https://diffinery.com`).
246- `runs-on: ubuntu-latest` (and any `ubuntu-*` or `linux` label) runs in a
247 Cloudflare Container. `runs-on: [self-hosted, …]` jobs wait for a runner you
248 start with `diffinery runner -R owner/repo --labels gpu,macos`. Use that for
249 macOS, GPUs or private networks.
250- Reusable workflows: `jobs.<id>.uses: ./.github/workflows/x.yml` from the same
251 commit, or `owner/repo/.github/workflows/x.yml@ref` from another repository
252 on this instance (public ones, or private ones with the same
253 owner). `with`, `secrets` (a mapping or `inherit`), `on.workflow_call`
254 outputs and nesting up to four levels deep all work. A matrix on the calling
255 job isn't supported yet.
256- Docker, on hosted runners and on self-hosted runners with Docker installed:
257 a job `container:` (steps and JavaScript actions run inside it), `services:`
258 (reachable by name from the job container, or on `localhost` through their
259 mapped ports, with health checks awaited), Docker container actions
260 (`docker://image` steps and actions with `runs.using: docker`, built from
261 their Dockerfile), and plain `docker build` / `docker run` / `docker compose`
262 in steps. Hosted runners start the Docker daemon the first time a job uses
263 it and pull Docker Hub images through `mirror.gcr.io`.
264- A container registry at `diffinery.com` in place of ghcr.io: sign in with
265 the job's `GITHUB_TOKEN` (`permissions: packages: write`) or a personal
266 token, then push and pull `diffinery.com/<owner>/<name>`. Images are
267 private by default and can be linked to a repository. See
268 [Container registry](https://diffinery.com/docs/packages/container-registry).
269
270**Not supported yet:** `actions/github-script`,
271Windows and macOS hosted runners, and
272`deepen-not` / partial clone filters. A job that uses an unsupported feature fails
273with a clear message instead of misbehaving.
274
275## REST API
276
277The API lives at `/api/v3`, with the same paths, JSON shapes and errors as
278GitHub's REST API. Point a client at it and it works:
279
280```js
281const octokit = new Octokit({ auth: token, baseUrl: "https://diffinery.example.com/api/v3" });
282```
283
284Responses carry `node_id`, `url`, `html_url`, full user objects and label
285objects. Lists take `per_page` and `page` and send a `Link` header, so
286`octokit.paginate` works. Errors have `message` and `documentation_url`.
287Responses also keep a few Diffinery fields (such as `merge_blockers` on pull
288requests) for the CLI, MCP and web UI.
289
290What works:
291
292- **Repositories:** get, create, update, delete, topics, languages,
293 collaborators, branches, tags, readme, contents (GET, and PUT and DELETE
294 with `sha`; a stale `sha` returns 409), compare, commits, `merges`,
295 `dispatches`, and `zipball`/`tarball` archives. `private: false` makes a
296 repository public.
297- **Archives:** `zipball` and `tarball` are built on demand and cached in R2.
298 The cache can be evicted at any time; the archive is then built again. An
299 archive holds at most 24 MB of files and 100,000 tree entries. A zip holds
300 at most 65,535 files; use the tarball for more. Each repository can build
301 20 new archives per hour. Clone bigger repositories with git.
302- **Git data:** blobs, trees (with `base_tree` and `recursive`), commits,
303 annotated tags, `git/ref`, `git/refs` and `git/matching-refs`.
304- **Releases:** list, get by id or tag, latest, create (the tag is created
305 if missing), update, delete, `generate-notes` (honours
306 `.github/release.yml`), `make_latest`, and drafts (visible to writers only).
307 Upload assets to the release's `upload_url`. Download them from
308 `browser_download_url`, or from the asset URL with
309 `Accept: application/octet-stream`. Bytes live in R2. An asset can be up to
310 100 MB, the largest request body Workers accept.
311- **Commit statuses:** create, list and the combined status. A status counts
312 as a check, so a branch protection's required checks can name a status
313 context.
314- **Required checks:** when Diffinery Actions reports a check, it alone
315 decides that check. To have a required check decided by someone else, bind
316 it in branch protection, for example
317 `"requiredCheckSources": { "ci/external": "ci-bot" }`. A required check
318 fails if any counted report of it fails. Only the reporter that created a
319 check run can update it, and Actions check runs can't be changed over REST.
320- **Checks:** check runs (create, update, get, annotations) and check suites.
321- **Issues and pull requests:** labels, assignees, lock and unlock,
322 comments (by issue and repository-wide), reactions (issues, comments, review
323 comments and releases), events and the timeline, reviews, review comments
324 and replies, review dismissal, requested reviewers, `update-branch`, merge,
325 `commits/{sha}/pulls`, and list filters (`head`, `base`, `sort`,
326 `direction`, `labels`, `assignee`, `since`). Pull requests report
327 `mergeable`, `mergeable_state` and `rebaseable`. Comment, review, check
328 run, user and repository ids are numbers, as on GitHub. Locked
329 conversations refuse comments, review comments and reactions from people
330 without triage access. Dismissing a review takes maintain access, and the
331 pull request's author can't do it.
332- **Drafts and merging:** `PATCH /pulls/{n}` with `"draft": false` marks a
333 draft ready for review (`"draft": true` turns it back; GitHub only offers
334 this through GraphQL). The web page has a "Ready for review" button and the
335 CLI has `diffinery pr ready`. A merge commit is refused on a branch that
336 requires linear history. Admins can merge past unmet branch rules only by
337 sending `"bypass_rules": true` (the CLI's `pr merge --admin`); the bypass is
338 written to the audit log. When several rules match a branch, the rule that
339 names it exactly wins, then the oldest pattern.
340- **Who can write:** anonymous callers can read public repositories but can't
341 create or change anything. A `read`-scoped token only reads; creating
342 organizations, invitations, agents and tokens takes an `admin`-scoped token
343 or a browser session. Token scopes must be `read`, `write` or `admin`.
344 Agents can't add, change or delete secrets or variables, whatever their
345 role.
346- **Archived repositories** are read-only through the API, for everyone.
347- **Tree entry names:** every write refuses `.`, `..`, `.git` (in any case,
348 and spellings like `.git.` or `git~1`), and names with `\` or NUL.
349- **Deployments and environments:** deployments, deployment statuses,
350 environments, deployment branch policies, and environment secrets and
351 variables. A deployment without `required_contexts` needs every context on
352 the commit to have passed, as on GitHub; send `[]` to skip that. An
353 environment with no branch policy gets "protected branches only" when its
354 first secret is added. Environment secrets never go to `refs/pull/*` runs.
355 Runs started by agents only get them when the environment allows protected
356 branches only. Tags pushed by agents never match an environment's tag
357 patterns.
358- **Actions:** runs, run attempts, jobs, logs (a zip per run), artifacts,
359 workflows, secrets and variables.
360- **Search:** `/search/issues` (`repo:`, `is:`, `label:`, `author:`,
361 `assignee:`, `no:`, `head:`, `base:`, `in:`, date ranges and `sort:`) and
362 `/search/repositories` (`user:`, `org:`, `topic:`, `is:public`,
363 `archived:`). Search only covers repositories you can read. A query
364 can be up to 256 characters, and one search looks at up to 50 repositories.
365- `/rate_limit`. Diffinery doesn't rate-limit the API, so it reports a
366 generous budget.
367
368Workflows can react to these too. A published release triggers `on: release`,
369a `POST /dispatches` triggers `on: repository_dispatch`, and webhooks get
370`release`, `status`, `deployment`, `deployment_status` and
371`workflow_dispatch` events, and `edited`, `ready_for_review` and
372`converted_to_draft` actions on issues and pull requests. A delivery that
373fails with a network error, 408, 429 or 5xx is retried up to five times over
374about five hours. A job with
375`environment:` creates a deployment, gets that environment's secrets and
376variables, and is refused if the environment's branch policy doesn't allow
377the ref. As on GitHub, events caused by `GITHUB_TOKEN` don't start new
378workflow runs, except `repository_dispatch`.
379
380**Not supported yet:** GraphQL (`/api/graphql`), setting secrets with a
381sealed-box `encrypted_value` (send `value` instead), environment protection
382rules (required reviewers, wait timers), forks, team reviewers, and code and
383commit search. Search qualifiers for stars, forks and languages are accepted
384but ignored.
385
386## Developing
387
388```sh
389pnpm install
390pnpm -r build && pnpm -r typecheck && pnpm -r test
391pnpm --filter @diffinery/app db:migrate:local
392pnpm dev # http://localhost:8787, signed in as dev@diffinery.local
393pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent + Chromium
394```
395
396The end-to-end test does the following:
397
3981. Pushes a repository with real `git`.
3992. Runs its CI through the actual runner agent.
4003. Checks secret scanning and branch protection.
4014. Has an agent open a pull request that needs a human approval and a green
402 check, then merges it.
4035. Runs pull request workflows on test merge commits, `pull_request_target`
404 from the base branch, and a merge queue with `merge_group` runs, including a
405 group that fails and leaves the queue.
4066. Clones shallow, then deepens the clone.
4077. Drives the MCP server.
4088. In Chromium with a virtual authenticator, joins from an invite with a new
409 passkey, adds and removes passkeys, signs out and in, and uses an admin
410 sign-in link.
4119. Drives the REST API with the real `@octokit/rest` client: contents, git
412 data, statuses as required checks, releases with asset uploads and
413 downloads, issues, reactions, pagination, search and deployments.
414
415Team preferences for product and design decisions are in
416[docs/PREFERENCES.md](docs/PREFERENCES.md).