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.

612 lines · 35.7 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
84### Storage
85
86Durable and transient data live under separate R2 prefixes, because lifecycle
87rules can only select by prefix (`apps/diffinery/src/lib/storage-keys.ts`):
88
89| Prefix | What | Lifecycle rule |
90|---|---|---|
91| `repos/` | git objects, LFS objects, release assets | none: kept until the repository is deleted |
92| `registry/` | container images | none: unreferenced blobs are swept by the cron job |
93| `actions-cache/` | Actions caches | none: Diffinery evicts unused entries after 7 days, and the least recently used above 10 GB per repository |
94| `artifacts/` | workflow artifacts | deleted after 90 days (shorter `retention-days` are handled by Diffinery) |
95| `logs/` | archived job logs | deleted after 90 days, like GitHub's log retention |
96| `actions-staging/` | uploads in progress | deleted after a day |
97| `archives/` | zipball and tarball cache | deleted after 30 days (rebuilt when asked for again) |
98| `events/` | the event archive | deleted after 400 days |
99
100Unfinished multipart uploads are aborted after a day everywhere. `bootstrap.sh`
101applies these rules on every deploy and stops the deploy if that fails, or if
102any rule would still delete objects under `repos/`. Artifacts, caches and logs
103written before this layout (under `repos/<id>/...`) are still read from there.
104Deleting a repository deletes its objects under the Actions and archive
105prefixes.
106
107If the token can't read your Zero Trust organization, set a repository
108**variable** `ACCESS_TEAM_DOMAIN` (for example `codician.cloudflareaccess.com`).
109
110To run your own Diffinery on another domain, set `DIFFINERY_DOMAIN` (for
111example `git.example.com`) when running `scripts/bootstrap.sh`. The Worker's
112route, `PUBLIC_URL` and the email sender follow it. Set `DIFFINERY_ZONE` if
113the Cloudflare zone isn't the domain's last two labels (for example
114`example.co.uk`), and `MAIL_DOMAIN` to send email from another domain. The
115token then needs Workers Routes Edit on that zone instead of `diffinery.com`.
116
117Email recovery uses Access's email one-time PIN, which works without any setup.
118You can also add GitHub as a login method under Zero Trust → Settings → Authentication.
119
120## Signing in and inviting people
121
122Everyone signs in with a passkey. There are no passwords.
123
1241. **First sign-in for admins.** The emails in `BOOTSTRAP_ADMINS` (in
125 `apps/diffinery/wrangler.jsonc`) become site admins. Open
126 `https://diffinery.com/auth/access`, confirm your email with the one-time
127 code, and add a passkey on the page that follows.
1282. **Inviting someone.** An admin or org owner creates an invite from
129 *Settings → Site administration*, an organization's *People* tab, or
130 `diffinery invite friend@example.com --org codician`. Diffinery emails them
131 the link; they open it, pick a username and create a passkey, and they're in.
1323. **Lost passkey.** A site admin clicks *Sign-in link* next to the person in
133 *Settings → Site administration*; Diffinery emails them the one-time link,
134 which works for 24 hours. People can also recover through `/auth/access` by email.
1354. **More devices.** Add passkeys under *Settings → Passkeys and sessions*, or
136 keep one in a password manager that syncs. The same page signs out other
137 browsers.
1385. Repository and org roles (read, triage, write, maintain, admin) decide what
139 each person can do.
140
141Nobody needs a Cloudflare account. Git and the CLI use a personal token from
142*Settings → Access tokens*.
143
144```sh
145git clone https://diffinery.com/codician/website.git # username: anything, password: your dfy_ token
146diffinery login && diffinery auth setup-git # or let the CLI handle credentials
147```
148
149## Email
150
151Diffinery sends email through [Cloudflare Email Sending](https://developers.cloudflare.com/email-service/)
152from `notifications@diffinery.com` (`MAIL_FROM` in `wrangler.jsonc`). Every
153email has an HTML and a plain-text version, and they work like GitHub's:
154
155| Group | Emails | Can be turned off |
156| --- | --- | --- |
157| 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 |
158| Account | Invitations, sign-in links, welcome, account disabled or enabled, test email | No |
159| 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 |
160| Watching | Everything in repositories you watch with *All activity* | Yes |
161| Actions | Failed workflow runs you started (all results if you ask) | Yes |
162| Repositories | Repositories created or deleted in your organizations, repository access granted | Yes |
163| Organizations | Organization role changes, accepted invitations, access requests (admins) | Yes |
164
165- **Settings.** *Settings → Notifications* turns each group on or off, on
166 Diffinery and by email, lists the repositories you watch and sends a test
167 email. Each repository has a *Watch* menu (participating, all activity,
168 ignore) and each issue and pull request a *Subscribe / Unsubscribe* button.
169 Your own comments and changes don't email you unless you ask.
170- **Unsubscribing.** Emails carry `List-Unsubscribe` and one-click
171 unsubscribe headers (RFC 8058), so mail apps show an Unsubscribe button, and
172 the footer links mute the thread, stop watching the repository or turn off
173 that group. Links are signed, so they work without signing in.
174- **Threading.** Emails about one issue or pull request share
175 `References`/`In-Reply-To` headers and a `[owner/repo]` subject, and carry
176 `X-Diffinery-Reason` (like `X-GitHub-Reason`) for filters.
177- **Delivery.** Emails go through the `diffinery-mail` queue (retries with
178 backoff, then the `diffinery-mail-dlq` dead-letter queue) and are logged for
179 90 days. Site admins see delivery status, the log and a preview of every
180 template under *Settings → Email*, and can send every template to
181 themselves.
182- **API.** `GET/PUT/DELETE /api/v3/repos/{owner}/{repo}/subscription`,
183 `GET/PUT /api/v3/diffinery/notification-settings`,
184 `GET /api/v3/diffinery/emails`, `POST /api/v3/diffinery/emails/test`; for
185 site admins `GET /api/v3/diffinery/admin/email` and
186 `POST /api/v3/diffinery/admin/emails/samples`.
187
188**Setting it up.** Onboard the sending domain once in the Cloudflare dashboard
189(*Compute → Email Service → Email Sending → Onboard Domain*); Cloudflare adds
190the bounce MX, SPF, DKIM and DMARC records. Each deploy runs
191`scripts/email-sending.sh`, which checks those records (with the *Email
192Sending: Read* permission on the API token, or the public DNS records without
193it) and warns if mail can't be delivered. In `wrangler dev`, Miniflare accepts
194emails without sending them; `GET /api/v3/diffinery/emails` shows the full
195message there.
196
197## Agents
198
199Create an agent under *Settings → Agents* or with `diffinery agent create reviewer-bot --org codician`.
200Then point any MCP client at it:
201
202```json
203{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_…" } } } }
204```
205
206Tools include `get_file`, `list_tree`, `commit_files` (no git client needed),
207`create_branch`, `create_pull_request` (with an `intent` for reviewers),
208`review_pull_request`, `merge_pull_request` (policy-guarded), issues,
209`list_workflow_runs` and `get_job_logs`, `rerun_workflow`, releases
210(`create_release`, `upload_release_asset`, `generate_release_notes`),
211`sandbox_create`/`sandbox_exec` for running builds and tests, and
212`start_github_import`/`get_import`/`list_imports` to move repositories in
213from GitHub (see below).
214
215Agent guardrails apply everywhere: git, REST, MCP and the web UI.
216
217- An agent's role is capped at write, and never exceeds its owner's role.
218- Agents can't push to the default branch or protected branches, and can't
219 merge there. Turn this on per branch with `agentsMayPush` or `agentsMayMerge`.
220- A pull request opened by an agent needs at least one human approval, and
221 approvals from agents don't count toward it.
222- Agents can't change branch protection or read or set secrets.
223- Agents can't change or delete environments or their branch policies, and
224 can't dismiss reviews.
225- Commit statuses and check runs that agents report never satisfy a required
226 check.
227
228## Workflows
229
230Put workflow files in `.github/workflows/` (or `.diffinery/workflows/`).
231Supported triggers are `push`, `pull_request`, `pull_request_target`,
232`merge_group`, `workflow_dispatch` (with inputs), `schedule` and `workflow_run`
233(up to three levels deep, like GitHub), with branch, tag and path filters.
234
235Pull requests behave as on GitHub:
236
237- `pull_request` runs check out the test merge commit, `refs/pull/<n>/merge`
238 (the head merged into the current base), with `GITHUB_REF` set to that ref.
239 While a pull request conflicts with its base, its `pull_request` workflows
240 don't run. `refs/pull/<n>/head` and `refs/pull/<n>/merge` can be fetched but
241 not pushed.
242- `pull_request_target` runs the base branch's own workflow on the base commit,
243 with the repository's secrets, so a pull request can't change what runs.
244 Check out the pull request's code yourself only if you trust it.
245- Both report their checks on the pull request's head commit. `opened`,
246 `synchronize`, `reopened` and `closed` (with `pull_request.merged`) fire.
247- Merge queue: turn on **Require a merge queue** in a branch protection rule
248 (or `"mergeQueue": {"method": "squash"}` through the API). "Merge when ready"
249 then adds a pull request to the queue. Each entry gets a merge group branch,
250 `gh-readonly-queue/<base>/pr-<n>-<head sha>`, holding the base, every pull
251 request ahead of it and its own changes, and `merge_group` (and `push`)
252 workflows run on it. Once its required checks pass and the entries ahead have
253 merged, the base fast-forwards to exactly the tested commit. A group that
254 fails, conflicts or exceeds the check timeout leaves the queue with a comment
255 saying why, and the groups behind it are rebuilt. The queue is at
256 `/<owner>/<repo>/queue/<branch>`; the API is
257 `PUT`/`DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue` and
258 `GET /repos/{owner}/{repo}/merge-queue/{branch}`, `diffinery pr merge` queues
259 automatically, and the MCP `merge_pull_request` tool does too.
260
261Supported workflow features:
262
263- `needs` and job outputs, `if` with `success()`, `failure()`, `always()` and
264 `cancelled()`, `strategy.matrix` with include and exclude, `fail-fast` and
265 `max-parallel`, `concurrency` with `cancel-in-progress` (for runs and for
266 jobs), and `timeout-minutes`.
267- A job starts as soon as the jobs it `needs` have finished. It doesn't wait
268 for unrelated jobs.
269- As on GitHub, a job is skipped when a job above it in the `needs` chain
270 failed, was cancelled or was skipped, unless its `if:` uses `always()`,
271 `failure()` or `cancelled()`. `!cancelled() && !failure()` runs it after a
272 skip. `needs.<job>.result` is `skipped` for a skipped job.
273- `runs-on`, `name`, `environment`, `timeout-minutes`, `continue-on-error`
274 and job `concurrency` can use the `matrix` and `strategy` contexts, and are
275 worked out for each matrix job, e.g. `runs-on: ${{ matrix.os }}`.
276- A job-level `concurrency` group lets one job run at a time across the
277 repository. Others wait as pending; a newer pending job replaces an older
278 one.
279- Job `continue-on-error`: a failed job doesn't fail the run, doesn't cancel
280 the rest of its matrix, and counts as a success for the jobs that need it.
281- `continue-on-error`, `defaults.run`, `env` at every level, `$GITHUB_OUTPUT`,
282 `$GITHUB_ENV`, `$GITHUB_PATH`, `$GITHUB_STEP_SUMMARY`, and masking of
283 secrets and `::add-mask::` values.
284- `secrets.*`, `vars.*`, and `GITHUB_TOKEN`, a per-job token that follows
285 GitHub's `permissions:` model: workflow- or job-level `read-all`,
286 `write-all`, `{}` or per-scope `contents`, `issues`, `pull-requests`,
287 `statuses`, `checks`, `deployments`, `actions`, `packages`, `pages` and so
288 on. Without `permissions:` a job gets the repository's default, which is
289 GitHub's restricted one (`contents` and `packages` read) for new
290 repositories, and write for ones created before the setting existed. An
291 admin changes it, and whether
292 workflows may create and approve pull requests (off by default), with
293 `PUT /repos/{owner}/{repo}/actions/permissions/workflow`
294 (`default_workflow_permissions`, `can_approve_pull_request_reviews`). Even
295 when allowed, a workflow's approval doesn't count toward required approvals.
296 A reusable workflow never gets more than its caller, and a pull request from
297 another repository only reads. Each API endpoint needs its scope (an issue
298 write without `issues: write` is a 403, like GitHub's "Resource not
299 accessible by integration"). The token never administers the repository,
300 reads secrets or creates or changes workflow files (GitHub's `workflows`
301 permission), and branch protection applies to it. A run whose code came from
302 an agent (the agent pushed it or the branch, or opened the pull request, and
303 re-runs of such runs) is held to the agent rules: its token can't push or
304 merge to the default or protected branches. `permissions: id-token: write`
305 enables OIDC (issuer `https://diffinery.com`).
306- `runs-on: ubuntu-latest` (and any `ubuntu-*` or `linux` label) runs in a
307 Cloudflare Container. `runs-on: [self-hosted, …]` jobs wait for a runner you
308 start with `diffinery runner -R owner/repo --labels gpu,macos`. Use that for
309 macOS, GPUs or private networks.
310- Reusable workflows: `jobs.<id>.uses: ./.github/workflows/x.yml` from the same
311 commit, or `owner/repo/.github/workflows/x.yml@ref` from another repository
312 on this instance (public ones, or private ones with the same
313 owner). `with`, `secrets` (a mapping or `inherit`), `on.workflow_call`
314 outputs and nesting up to four levels deep all work. A matrix on the calling
315 job isn't supported yet.
316- Docker, on hosted runners and on self-hosted runners with Docker installed:
317 a job `container:` (steps and JavaScript actions run inside it), `services:`
318 (reachable by name from the job container, or on `localhost` through their
319 mapped ports, with health checks awaited), Docker container actions
320 (`docker://image` steps and actions with `runs.using: docker`, built from
321 their Dockerfile), and plain `docker build` / `docker run` / `docker compose`
322 in steps. Hosted runners start the Docker daemon the first time a job uses
323 it and pull Docker Hub images through `mirror.gcr.io`.
324- A container registry at `diffinery.com` in place of ghcr.io: sign in with
325 the job's `GITHUB_TOKEN` (`permissions: packages: write`) or a personal
326 token, then push and pull `diffinery.com/<owner>/<name>`. Images are
327 private by default and can be linked to a repository. See
328 [Container registry](https://diffinery.com/docs/packages/container-registry).
329- Actions from GitHub run unmodified, including `actions/github-script`,
330 `setup-java`, `setup-dotnet` and the rest. Jobs get the same runtime
331 services GitHub provides (`ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`,
332 `ACTIONS_CACHE_URL`), scoped to the job and expiring with it, so
333 `actions/cache` (v3 and v4), `actions/upload-artifact` and
334 `actions/download-artifact` (v4: `pattern`, `merge-multiple`, `run-id` with
335 `github-token`) and `docker/build-push-action` with `cache-to: type=gha` store
336 caches and artifacts in Diffinery. `GITHUB_EVENT_PATH` holds a complete event
337 payload and `github.run_id` is numeric.
338 Caches and artifacts are listed and deleted through the REST API
339 (`/actions/caches`, `/actions/artifacts`). Each repository keeps up to 10 GB
340 of caches (the least recently used are evicted) and 10 GB of artifacts
341 (`ARTIFACT_QUOTA_BYTES` changes it; uploads beyond it are refused). Uploads
342 in progress count toward both, and a job's unfinished uploads are removed
343 when it ends. As on GitHub, a cache is saved for the ref the job runs on and
344 restored from that ref, then the pull request's base branch, then the
345 default branch, so branches and pull requests never see each other's
346 caches. Artifacts keep their `retention-days` (1 to 90 days, 90 by default);
347 an expired artifact's content is deleted, and the REST API still lists it
348 with `expired: true` (downloads get 410) for another 90 days.
349- `actions/checkout`, `cache`, `upload-artifact`, `download-artifact`,
350 `setup-node`, `setup-python` and `setup-go` run as built-ins for speed. They
351 share storage and formats with the real actions (a cache saved by one
352 restores with the other) and support their inputs, including checkout's
353 `lfs`, `submodules`, `sparse-checkout`, `filter`, `fetch-tags`, `ref` and
354 `persist-credentials: false`, and the `cache:` input of the setup actions.
355 A built-in hands over to the real action when it sees an input or ref it
356 doesn't mirror (a commit SHA, a branch, a newer major). To always run the
357 real action, set `DIFFINERY_BUILTINS: false` in a workflow, job or step
358 `env:`, or start a self-hosted runner with `DIFFINERY_DISABLE_BUILTINS=1`.
359- Hosted runners look like GitHub's `ubuntu-24.04` image to actions:
360 `ImageOS=ubuntu24`, `RUNNER_TOOL_CACHE=/opt/hostedtoolcache`, and `git`,
361 `git-lfs`, `jq`, `zip`, `zstd`, `curl`, `python3`/`pip`, `build-essential`,
362 `docker` and `gh` installed. `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG`
363 (as secrets or variables) turn on debug logging.
364
365**Not supported yet:** Windows and macOS hosted runners, the GraphQL API
366(`github.graphql` in github-script), and `deepen-not` / partial clone filters
367on the server (checkout's `filter` falls back to a full fetch). One cache or
368artifact upload request goes through the Worker, so it is limited to the
369Worker's request size (100 MB on most plans); the built-ins and the official
370actions upload large files in blocks. A job that uses an unsupported feature fails
371with a clear message instead of misbehaving.
372
373## REST API
374
375The API lives at `/api/v3`, with the same paths, JSON shapes and errors as
376GitHub's REST API. Point a client at it and it works:
377
378```js
379const octokit = new Octokit({ auth: token, baseUrl: "https://diffinery.example.com/api/v3" });
380```
381
382Responses carry `node_id`, `url`, `html_url`, full user objects and label
383objects. Lists take `per_page` and `page` and send a `Link` header, so
384`octokit.paginate` works. Errors have `message` and `documentation_url`.
385Responses also keep a few Diffinery fields (such as `merge_blockers` on pull
386requests) for the CLI, MCP and web UI.
387
388What works:
389
390- **Repositories:** get, create, update, delete, topics, languages,
391 collaborators, branches, tags, readme, contents (GET, and PUT and DELETE
392 with `sha`; a stale `sha` returns 409), compare, commits, `merges`,
393 `dispatches`, and `zipball`/`tarball` archives. `private: false` makes a
394 repository public.
395- **Archives:** `zipball` and `tarball` are built on demand and cached in R2.
396 The cache can be evicted at any time; the archive is then built again. An
397 archive holds at most 24 MB of files and 100,000 tree entries. A zip holds
398 at most 65,535 files; use the tarball for more. Each repository can build
399 20 new archives per hour. Clone bigger repositories with git.
400- **Git data:** blobs, trees (with `base_tree` and `recursive`), commits,
401 annotated tags, `git/ref`, `git/refs` and `git/matching-refs`.
402- **Releases:** list, get by id or tag, latest, create (the tag is created
403 if missing), update, delete, `generate-notes` (honours
404 `.github/release.yml`), `make_latest`, and drafts (visible to writers only).
405 Upload assets to the release's `upload_url`. Download them from
406 `browser_download_url`, or from the asset URL with
407 `Accept: application/octet-stream`. Bytes live in R2. An asset can be up to
408 100 MB, the largest request body Workers accept.
409- **Commit statuses:** create, list and the combined status. A status counts
410 as a check, so a branch protection's required checks can name a status
411 context.
412- **Required checks:** when Diffinery Actions reports a check, it alone
413 decides that check. To have a required check decided by someone else, bind
414 it in branch protection, for example
415 `"requiredCheckSources": { "ci/external": "ci-bot" }`. A required check
416 fails if any counted report of it fails. Only the reporter that created a
417 check run can update it, and Actions check runs can't be changed over REST.
418- **Checks:** check runs (create, update, get, annotations) and check suites.
419- **Issues and pull requests:** labels, assignees, lock and unlock,
420 comments (by issue and repository-wide), reactions (issues, comments, review
421 comments and releases), events and the timeline, reviews, review comments
422 and replies, review dismissal, requested reviewers, `update-branch`, merge,
423 `commits/{sha}/pulls`, and list filters (`head`, `base`, `sort`,
424 `direction`, `labels`, `assignee`, `since`). Pull requests report
425 `mergeable`, `mergeable_state` and `rebaseable`. Comment, review, check
426 run, user and repository ids are numbers, as on GitHub. Locked
427 conversations refuse comments, review comments and reactions from people
428 without triage access. Dismissing a review takes maintain access, and the
429 pull request's author can't do it.
430- **Drafts and merging:** `PATCH /pulls/{n}` with `"draft": false` marks a
431 draft ready for review (`"draft": true` turns it back; GitHub only offers
432 this through GraphQL). The web page has a "Ready for review" button and the
433 CLI has `diffinery pr ready`. A merge commit is refused on a branch that
434 requires linear history. Admins can merge past unmet branch rules only by
435 sending `"bypass_rules": true` (the CLI's `pr merge --admin`); the bypass is
436 written to the audit log. When several rules match a branch, the rule that
437 names it exactly wins, then the oldest pattern.
438- **Who can write:** anonymous callers can read public repositories but can't
439 create or change anything. A `read`-scoped token only reads; creating
440 organizations, invitations, agents and tokens takes an `admin`-scoped token
441 or a browser session. Token scopes must be `read`, `write` or `admin`.
442 Agents can't add, change or delete secrets or variables, whatever their
443 role.
444- **Archived repositories** are read-only through the API, for everyone.
445- **Tree entry names:** every write refuses `.`, `..`, `.git` (in any case,
446 and spellings like `.git.` or `git~1`), and names with `\` or NUL.
447- **Deployments and environments:** deployments, deployment statuses,
448 environments, deployment branch policies, and environment secrets and
449 variables. A deployment without `required_contexts` needs every context on
450 the commit to have passed, as on GitHub; send `[]` to skip that. An
451 environment with no branch policy gets "protected branches only" when its
452 first secret is added. Environment secrets never go to `refs/pull/*` runs.
453 Runs started by agents only get them when the environment allows protected
454 branches only. Tags pushed by agents never match an environment's tag
455 patterns.
456- **Actions:** runs, run attempts, jobs, logs (a zip per run), artifacts,
457 workflows, secrets and variables.
458- **Search:** `/search/issues` (`repo:`, `is:`, `label:`, `author:`,
459 `assignee:`, `no:`, `head:`, `base:`, `in:`, date ranges and `sort:`) and
460 `/search/repositories` (`user:`, `org:`, `topic:`, `is:public`,
461 `archived:`). Search only covers repositories you can read. A query
462 can be up to 256 characters, and one search looks at up to 50 repositories.
463- `/rate_limit`. Diffinery doesn't rate-limit the API, so it reports a
464 generous budget.
465
466Workflows can react to these too. A published release triggers `on: release`,
467a `POST /dispatches` triggers `on: repository_dispatch`, and webhooks get
468`release`, `status`, `deployment`, `deployment_status` and
469`workflow_dispatch` events, and `edited`, `ready_for_review` and
470`converted_to_draft` actions on issues and pull requests. A delivery that
471fails with a network error, 408, 429 or 5xx is retried up to five times over
472about five hours. A job with
473`environment:` creates a deployment, gets that environment's secrets and
474variables, and is refused if the environment's branch policy doesn't allow
475the ref. As on GitHub, events caused by `GITHUB_TOKEN` don't start new
476workflow runs, except `repository_dispatch`.
477
478**Not supported yet:** GraphQL (`/api/graphql`), setting secrets with a
479sealed-box `encrypted_value` (send `value` instead), environment protection
480rules (required reviewers, wait timers), forks, team reviewers, and code and
481commit search. Search qualifiers for stars, forks and languages are accepted
482but ignored.
483
484## Moving from GitHub
485
486Diffinery imports a GitHub repository — or a whole organization — on the server:
487it copies the Git history in a container and the rest through GitHub's REST API,
488so you don't clone or push anything yourself and large repositories don't run
489through your laptop.
490
491Start an import three ways:
492
493- **Web** — *Settings → Imports → New*, or the *Import from GitHub* link on *New
494 repository*. Paste `owner/name` (or a GitHub URL), choose where it lands and
495 whether it stays public or private, and watch each step. For an organization,
496 pick the repositories from the list.
497- **CLI** — `diffinery migrate github octo/app` imports one repository and
498 follows it to completion; `diffinery migrate github-org octo --to my-org`
499 imports an organization. Both take `--json`. `--local` runs the older
500 client-side path instead (a mirror push plus a best-effort recreation of
501 issues and open pull requests).
502- **MCP / agents** — `start_github_import`, then `get_import` to poll and
503 `list_imports` to review. An agent can import only into its owner's account or
504 an organization the owner administers.
505
506A private repository, Git LFS, Actions variables and branch protection need a
507GitHub token (`GH_TOKEN`/`GITHUB_TOKEN`, or the token field in the form) with
508`repo` access. For GitHub Enterprise you must name the API base explicitly with
509`GITHUB_API_URL` (or the API URL field in the form): when a token is supplied for
510a non-github.com source, Diffinery requires a matching `github_api_url` on the
511same host and attaches the token only there, so a host taken from the source can
512never receive it. Server-side fetches only ever reach that one host, every
513response body is size-bounded, redirects are not followed across hosts, and the
514token goes to GitHub alone (never logged, never returned).
515
516**Limits.** Each account (and each organization) may run **one** organization
517import and **three** single-repository imports at a time. A repository larger
518than `IMPORT_MAX_REPO_KB` (default 10 GB) is refused up front. The Git transfer
519has a **6-hour** ceiling; an import that is still transferring after that fails
520cleanly. Draft imports left unstarted for seven days are cancelled and their
521stored token is erased.
522
523**Agents.** An agent may import only into its owner's account, or an organization
524the owner administers, and only when the owner holds at least **write** there.
525Agents never read or set secrets and never manage **variables**: the repository,
526organization and environment **variable** steps are skipped for an
527agent-started import (and re-checked against a human owner when they do run). An
528agent-started import also never bypasses push protection — if the history is not
529clean, it stops.
530
531What comes across, keeping original numbers and attribution:
532
533- **Git** — every branch and tag, and LFS objects. Pull-request head commits are
534 kept so merged and open PRs still point at real commits. Merge-queue refs are
535 skipped.
536- **Issues and pull requests** — with their **original numbers** (gaps and all),
537 bodies, comments, reviews and review threads, labels, milestones, assignees,
538 merged/closed state and who merged.
539- **Labels, milestones, branch protection**, and **Actions variables** (repository
540 and organization) and **deployment environments** (names, branch policies and
541 variables).
542- **Releases** and their assets, where the server's releases feature is enabled.
543
544What does **not** come across:
545
546- **Secret values.** GitHub never discloses them, and Diffinery never tries to.
547 Repository and environment secret **names** are listed so you can re-enter the
548 values by hand (`diffinery secret set <NAME> -R owner/repo`), which an admin
549 does — never an agent.
550- **People.** No accounts are created. A GitHub user is attributed to a Diffinery
551 account only through a verified link, matched by GitHub's **numeric id** so a
552 rename can't mis-map and keyed by the full API base so a github.com link and an
553 Enterprise link stay separate. Everyone else is imported as a **mannequin**: a
554 non-account placeholder (`github:<login>`, linking to the GitHub profile) that
555 carries the history but can't sign in, be assigned work or cast a vote. Where a
556 mannequin authors a squashed merge, the commit uses GitHub's `noreply` address.
557
558Link a GitHub account under *Settings → Linked accounts → GitHub* one of two ways:
559
560- **Gist proof (no token).** Diffinery shows a one-time code; you put it in a
561 **public gist** and confirm your login. Diffinery reads your public profile and
562 gists **unauthenticated** on the API base you named and links the account only
563 when it finds the code. Nothing but your public data is read.
564- **OAuth (optional).** When `GITHUB_OAUTH_CLIENT_ID` and
565 `GITHUB_OAUTH_CLIENT_SECRET` are configured, a *Connect GitHub* button runs the
566 OAuth flow with state bound to your account so a callback can't be replayed.
567
568A site admin can clear any link (audited). Imported reviews are kept as history
569but **never count as approvals**, even when their author is a linked account — a
570pull request still needs a fresh review on Diffinery to be approved.
571
572Old history that contains a credential is imported with an audited push-protection
573bypass (the history is already public on GitHub); choose *stop* to fail instead.
574
575**Retrying.** Only the person who started an import may retry it, and a retry
576needs the GitHub token again (it is never stored past the run). While an import
577is in progress the target repository is **locked** against other writes, and a
578retry refuses to touch a repository that already holds content the import did not
579write — it will not adopt or overwrite an existing repository. An agent may not
580retry once the repository has been created.
581
582## Developing
583
584```sh
585pnpm install
586pnpm -r build && pnpm -r typecheck && pnpm -r test
587pnpm --filter @diffinery/app db:migrate:local
588pnpm dev # http://localhost:8787, signed in as dev@diffinery.local
589pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent + Chromium
590```
591
592The end-to-end test does the following:
593
5941. Pushes a repository with real `git`.
5952. Runs its CI through the actual runner agent.
5963. Checks secret scanning and branch protection.
5974. Has an agent open a pull request that needs a human approval and a green
598 check, then merges it.
5995. Runs pull request workflows on test merge commits, `pull_request_target`
600 from the base branch, and a merge queue with `merge_group` runs, including a
601 group that fails and leaves the queue.
6026. Clones shallow, then deepens the clone.
6037. Drives the MCP server.
6048. In Chromium with a virtual authenticator, joins from an invite with a new
605 passkey, adds and removes passkeys, signs out and in, and uses an admin
606 sign-in link.
6079. Drives the REST API with the real `@octokit/rest` client: contents, git
608 data, statuses as required checks, releases with asset uploads and
609 downloads, issues, reactions, pagination, search and deployments.
610
611Team preferences for product and design decisions are in
612[docs/PREFERENCES.md](docs/PREFERENCES.md).