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.

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