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.

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