| .github | ||
| apps | ||
| containers | ||
| docs | ||
| packages | ||
| scripts | ||
| .gitignore | 181 B | |
| package.json | 430 B | |
| pnpm-lock.yaml | 136.4 KB | |
| pnpm-workspace.yaml | 53 B | |
| README.md | 40.3 KB | |
| tsconfig.base.json | 391 B | |
| turbo.json | 259 B |
Diffinery
A git forge for teams of people and AI agents, running entirely on Cloudflare. Git hosting, pull requests and reviews, issues, GitHub-Actions-compatible CI, an MCP server for agents, and sandboxes, with no servers to run.
- Drop-in for GitHub workflows.
.github/workflows/*.ymlruns as-is: same triggers, expressions, matrices,needs, outputs, caches, artifacts, secrets, OIDC,actions/checkout,setup-node/python/go, and remote JavaScript and composite actions. - Built for agents. Agents are first-class accounts owned by a person or
team, with their own tokens and limits. They can't push or merge to protected
branches, and their pull requests always need a human approval. Every commit
they make is stamped with who they act for. They work through MCP
(
/mcp) or the REST API, and can start sandboxes to build and test. - Secure by default. People sign in with passkeys, with no passwords to
leak or phish. Invites, roles and branch protection decide what they can do. Pushes are scanned for
leaked credentials and blocked if they contain any; nothing from a blocked
push is kept. A repository admin (a person, never an agent) can push anyway
with
git push -o allow-secrets=<reason>, which is audited. A file can opt out with adiffinery:allow-secretcomment, for test fixtures. Secrets use envelope encryption, and every sensitive action goes to an append-only audit log.
Documentation
The full documentation is public at diffinery.com/docs:
guides for every feature, plus REST API, CLI and MCP references generated from
this code. For AI agents there's llms.txt,
llms-full.txt, a Markdown version of
every page (add .md to its URL) and an
OpenAPI document. The pages live
in apps/diffinery/docs.
How it fits on Cloudflare
| Piece | Cloudflare product |
|---|---|
| HTTP, git smart HTTP, REST API, MCP, web UI | Workers (Hono) |
| One repository: refs, objects, PRs, issues, reviews, checks, runs | Durable Object with SQLite (RepoDO), one per repository |
| Large objects, Git LFS, container images, logs, caches, artifacts, event archive | R2 |
| 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) |
| Webhooks, notifications, audit fan-out | Queues (with a dead-letter queue) |
| Workflow runs (durable, survive restarts, 6 h job timeout) | Workflows |
| Job runners and agent sandboxes | Containers (Ubuntu 24.04 image in containers/runner) |
Scheduled workflows (on: schedule) | Cron Triggers |
| Live run logs and PR updates | Durable Object WebSockets (hibernation) |
| Sign-in | Passkeys (WebAuthn) and sessions in D1. Cloudflare Access guards only /auth/access, the email recovery path |
apps/diffinery the Worker (src/index.ts), D1 migrations, wrangler config, e2e test
apps/cli the `diffinery` command-line client
containers/runner runner agent + sandbox server, and the container image
packages/git-core git objects, packfiles, deltas, diff/merge (pure TypeScript)
packages/workflow-yaml GitHub Actions workflow parser, expressions, matrix, cron
packages/shared types shared by the Worker and the runner
scripts bootstrap.sh, ensure-secrets.sh, render-config.mjs
Deploying
Diffinery deploys itself: this repository lives at
diffinery.com/codician/diffinery, and every push to main there runs
CI (.github/workflows/ci.yml: typecheck, unit tests and the Worker bundle) on
Diffinery's hosted runners. When it passes, .github/workflows/deploy.yml
deploys that commit, followed by the live test (.github/workflows/live-test.yml);
the end-to-end suite (.github/workflows/e2e.yml) runs alongside. Deploys
reuse the runner image from Cloudflare's registry and rebuild it only when
containers/runner changed, or weekly for base image updates (scripts/runner-image.sh). Diffinery is this repository's only home: GitHub's
copy (github.com/codician-team/diffinery) stopped at the cut-off on 14 October 2026 and
is kept as a record. Changes land through pull requests on Diffinery; agents land
theirs with auto-merge once the required checks pass. A checkout cloned from GitHub
switches over with scripts/use-diffinery.sh (it needs DIFFINERY_TOKEN).
The workflow needs two repository secrets:
CLOUDFLARE_API_TOKEN: account permissions Workers Scripts, D1, R2, Queues, Workflows, Containers, Access: Apps and Policies and Turnstile (all Edit), and zone permission Workers Routes Edit ondiffinery.com.CLOUDFLARE_ACCOUNT_ID
Agents land main through auto-merge, so Deploy (and the live test after it)
runs as agent code and gets repository secrets only because the repository has
Give runs of agent code the repository's and organization's secrets turned on
(Settings → Actions). A copy where agents land changes needs the same, or the
secrets in a production environment limited to protected branches.
On each deploy, scripts/bootstrap.sh creates anything that is missing: the D1
database, R2 bucket, queues, the Turnstile widget for anonymous security reports
(scripts/turnstile.sh; needs Account → Turnstile → Edit, else it warns) and one
Access application that covers only /auth/access (email-based account recovery).
It then applies D1 migrations. wrangler deploy builds and pushes the runner image.
ensure-secrets.sh generates MASTER_KEY and the OIDC signing key inside the job
on the first deploy, and sets TURNSTILE_SECRET whenever it is missing; no secret
value is ever printed.
Storage
Durable and transient data live under separate R2 prefixes, because lifecycle
rules can only select by prefix (apps/diffinery/src/lib/storage-keys.ts):
| Prefix | What | Lifecycle rule |
|---|---|---|
repos/ | git objects, LFS objects, release assets | none: kept until the repository is deleted |
registry/ | container images | none: unreferenced blobs are swept by the cron job |
actions-cache/ | Actions caches | none: Diffinery evicts unused entries after 7 days, and the least recently used above 10 GB per repository |
artifacts/ | workflow artifacts | deleted after 90 days (shorter retention-days are handled by Diffinery) |
logs/ | archived job logs | deleted after 90 days, like GitHub's log retention |
actions-staging/ | uploads in progress | deleted after a day |
archives/ | zipball and tarball cache | deleted after 30 days (rebuilt when asked for again) |
events/ | the event archive | deleted after 400 days |
Unfinished multipart uploads are aborted after a day everywhere. bootstrap.sh
applies these rules on every deploy and stops the deploy if that fails, or if
any rule would still delete objects under repos/. Artifacts, caches and logs
written before this layout (under repos/<id>/...) are still read from there.
Deleting a repository deletes its objects under the Actions and archive
prefixes.
If the token can't read your Zero Trust organization, set a repository
variable ACCESS_TEAM_DOMAIN (for example codician.cloudflareaccess.com).
To run your own Diffinery on another domain, set DIFFINERY_DOMAIN (for
example git.example.com) when running scripts/bootstrap.sh. The Worker's
route, PUBLIC_URL and the email sender follow it. Set DIFFINERY_ZONE if
the Cloudflare zone isn't the domain's last two labels (for example
example.co.uk), and MAIL_DOMAIN to send email from another domain. The
token then needs Workers Routes Edit on that zone instead of diffinery.com.
Email recovery uses Access's email one-time PIN, which works without any setup. You can also add GitHub as a login method under Zero Trust → Settings → Authentication.
Signing in and inviting people
Everyone signs in with a passkey. There are no passwords.
- First sign-in for admins. The emails in
BOOTSTRAP_ADMINS(inapps/diffinery/wrangler.jsonc) become site admins. Openhttps://diffinery.com/auth/access, confirm your email with the one-time code, and add a passkey on the page that follows. - Inviting someone. An admin or org owner creates an invite from
Settings → Site administration, an organization's People tab, or
diffinery invite friend@example.com --org codician. Diffinery emails them the link; they open it, pick a username and create a passkey, and they're in. - Lost passkey. A site admin clicks Sign-in link next to the person in
Settings → Site administration; Diffinery emails them the one-time link,
which works for 24 hours. People can also recover through
/auth/accessby email. - More devices. Add passkeys under Settings → Passkeys and sessions, or keep one in a password manager that syncs. The same page signs out other browsers.
- Repository and org roles (read, triage, write, maintain, admin) decide what each person can do.
Nobody needs a Cloudflare account. Git and the CLI use a personal token from Settings → Access tokens.
git clone https://diffinery.com/codician/website.git # username: anything, password: your dfy_ token
diffinery login && diffinery auth setup-git # or let the CLI handle credentials
Diffinery sends email through Cloudflare Email Sending
from notifications@diffinery.com (MAIL_FROM in wrangler.jsonc). Every
email has an HTML and a plain-text version, and they work like GitHub's:
| Group | Emails | Can be turned off |
|---|---|---|
| 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 |
| Account | Invitations, sign-in links, welcome, account disabled or enabled, test email | No |
| 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 |
| Watching | Everything in repositories you watch with All activity | Yes |
| Actions | Failed workflow runs you started (all results if you ask) | Yes |
| Repositories | Repositories created or deleted in your organizations, repository access granted | Yes |
| Organizations | Organization role changes, accepted invitations, access requests (admins) | Yes |
- Settings. Settings → Notifications turns each group on or off, on Diffinery and by email, lists the repositories you watch and sends a test email. Each repository has a Watch menu (participating, all activity, ignore) and each issue and pull request a Subscribe / Unsubscribe button. Your own comments and changes don't email you unless you ask.
- Unsubscribing. Emails carry
List-Unsubscribeand one-click unsubscribe headers (RFC 8058), so mail apps show an Unsubscribe button, and the footer links mute the thread, stop watching the repository or turn off that group. Links are signed, so they work without signing in. - Threading. Emails about one issue or pull request share
References/In-Reply-Toheaders and a[owner/repo]subject, and carryX-Diffinery-Reason(likeX-GitHub-Reason) for filters. - Delivery. Emails go through the
diffinery-mailqueue (retries with backoff, then thediffinery-mail-dlqdead-letter queue) and are logged for 90 days. Site admins see delivery status, the log and a preview of every template under Settings → Email, and can send every template to themselves. - API.
GET/PUT/DELETE /api/v3/repos/{owner}/{repo}/subscription,GET/PUT /api/v3/diffinery/notification-settings,GET /api/v3/diffinery/emails,POST /api/v3/diffinery/emails/test; for site adminsGET /api/v3/diffinery/admin/emailandPOST /api/v3/diffinery/admin/emails/samples.
Setting it up. Onboard the sending domain once in the Cloudflare dashboard
(Compute → Email Service → Email Sending → Onboard Domain); Cloudflare adds
the bounce MX, SPF, DKIM and DMARC records. Each deploy runs
scripts/email-sending.sh, which checks those records (with the Email
Sending: Read permission on the API token, or the public DNS records without
it) and warns if mail can't be delivered. In wrangler dev, Miniflare accepts
emails without sending them; GET /api/v3/diffinery/emails shows the full
message there.
Agents
Create an agent under Settings → Agents or with diffinery agent create reviewer-bot --org codician.
Then point any MCP client at it:
{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_…" } } } }
Tools include get_file, list_tree, commit_files (no git client needed),
create_branch, create_pull_request (with an intent for reviewers),
review_pull_request, merge_pull_request (policy-guarded),
enable_auto_merge/disable_auto_merge, issues,
list_workflow_runs and get_job_logs, rerun_workflow, releases
(create_release, upload_release_asset, generate_release_notes),
sandbox_create/sandbox_exec for running builds and tests, and
start_github_import/get_import/list_imports to move repositories in
from GitHub (see below).
Agent guardrails apply everywhere: git, REST, MCP and the web UI.
- An agent's role is capped at write, and never exceeds its owner's role.
- Agents can't push to the default branch or protected branches, and can't
merge there. Turn this on per branch with
agentsMayPushoragentsMayMerge, or let CI decide withallowAgentAutoMerge(below). - A pull request opened by an agent needs at least one human approval, and
approvals from agents don't count toward it. An admin can waive that for
what auto-merge lands on green required checks (
agentAutoMergeWithoutApproval). - Agents can't change branch protection or read or set secrets.
- Agents can't change or delete environments or their branch policies, and can't dismiss reviews.
- Commit statuses and check runs that agents report never satisfy a required check.
How agents land changes
Agents never merge into the default branch or a protected branch themselves,
and never get a bypass. Instead, a person who administers the repository turns
on Allow agents to land pull requests through auto-merge
(allowAgentAutoMerge) on the branch's protection rule, which must also have
at least one required status check. An agent then opens its pull request and
turns on auto-merge (PUT /repos/{owner}/{repo}/pulls/{n}/auto-merge or the
MCP tool enable_auto_merge). Diffinery merges it, as the agent on behalf of
its owner, as soon as every rule is met: required checks green on the head,
approvals from people of its latest commit (an agent's pull request still
needs one, unless an admin turned on agentAutoMergeWithoutApproval for the
branch, which lets green required checks alone land it), linear history and an up-to-date branch where required. On a branch with a merge
queue it joins the queue instead. Without the setting the agent gets a 403,
and without a required check a 422; a pull request that changes a workflow
file needs a person. A push by anyone else turns auto-merge off, the merge
commit carries the diffinery-agent header, and the workflows it triggers run
under the agent rules. That includes secrets: once an agent has landed on a
branch, runs there get no repository or organization secrets unless an admin
turns on Give runs of agent code the repository's and organization's secrets
(agent_runs_use_secrets), and only get environment secrets from environments
limited to protected branches. A deploy after an agent's merge needs one of the two. Workflow tokens are held to the same rule. Enabling,
disabling, the merge and changes to the setting are audited.
Workflows
Put workflow files in .github/workflows/ (or .diffinery/workflows/).
Supported triggers are push, pull_request, pull_request_target,
merge_group, workflow_dispatch (with inputs), schedule and workflow_run
(up to three levels deep, like GitHub), with branch, tag and path filters.
Pull requests behave as on GitHub:
pull_requestruns check out the test merge commit,refs/pull/<n>/merge(the head merged into the current base), withGITHUB_REFset to that ref. While a pull request conflicts with its base, itspull_requestworkflows don't run.refs/pull/<n>/headandrefs/pull/<n>/mergecan be fetched but not pushed.pull_request_targetruns the base branch's own workflow on the base commit, with the repository's secrets, so a pull request can't change what runs. Check out the pull request's code yourself only if you trust it.- Both report their checks on the pull request's head commit.
opened,synchronize,reopenedandclosed(withpull_request.merged) fire. - Merge queue: turn on Require a merge queue in a branch protection rule
(or
"mergeQueue": {"method": "squash"}through the API). "Merge when ready" then adds a pull request to the queue. Each entry gets a merge group branch,gh-readonly-queue/<base>/pr-<n>-<head sha>, holding the base, every pull request ahead of it and its own changes, andmerge_group(andpush) workflows run on it. Once its required checks pass and the entries ahead have merged, the base fast-forwards to exactly the tested commit. A group that fails, conflicts or exceeds the check timeout leaves the queue with a comment saying why, and the groups behind it are rebuilt. The queue is at/<owner>/<repo>/queue/<branch>; the API isPUT/DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queueandGET /repos/{owner}/{repo}/merge-queue/{branch},diffinery pr mergequeues automatically, and the MCPmerge_pull_requesttool does too.
Supported workflow features:
needsand job outputs,ifwithsuccess(),failure(),always()andcancelled(),strategy.matrixwith include and exclude,fail-fastandmax-parallel,concurrencywithcancel-in-progress(for runs and for jobs), andtimeout-minutes.- A job starts as soon as the jobs it
needshave finished. It doesn't wait for unrelated jobs. - As on GitHub, a job is skipped when a job above it in the
needschain failed, was cancelled or was skipped, unless itsif:usesalways(),failure()orcancelled().!cancelled() && !failure()runs it after a skip.needs.<job>.resultisskippedfor a skipped job. runs-on,name,environment,timeout-minutes,continue-on-errorand jobconcurrencycan use thematrixandstrategycontexts, and are worked out for each matrix job, e.g.runs-on: ${{ matrix.os }}.- A job-level
concurrencygroup lets one job run at a time across the repository. Others wait as pending; a newer pending job replaces an older one. - Job
continue-on-error: a failed job doesn't fail the run, doesn't cancel the rest of its matrix, and counts as a success for the jobs that need it. continue-on-error,defaults.run,envat every level,$GITHUB_OUTPUT,$GITHUB_ENV,$GITHUB_PATH,$GITHUB_STEP_SUMMARY, and masking of secrets and::add-mask::values.secrets.*,vars.*, andGITHUB_TOKEN, a per-job token that follows GitHub'spermissions:model: workflow- or job-levelread-all,write-all,{}or per-scopecontents,issues,pull-requests,statuses,checks,deployments,actions,packages,pagesand so on. Withoutpermissions:a job gets the repository's default, which is GitHub's restricted one (contentsandpackagesread) for new repositories, and write for ones created before the setting existed. An admin changes it, and whether workflows may create and approve pull requests (off by default), withPUT /repos/{owner}/{repo}/actions/permissions/workflow(default_workflow_permissions,can_approve_pull_request_reviews). Even when allowed, a workflow's approval doesn't count toward required approvals. A reusable workflow never gets more than its caller, and a pull request from another repository only reads. Each API endpoint needs its scope (an issue write withoutissues: writeis a 403, like GitHub's "Resource not accessible by integration"). The token never administers the repository, reads secrets or creates or changes workflow files (GitHub'sworkflowspermission), and branch protection applies to it. A run whose code came from an agent (the agent pushed it or the branch, or opened the pull request, and re-runs of such runs) is held to the agent rules: its token can't push or merge to the default or protected branches.permissions: id-token: writeenables OIDC (issuerhttps://diffinery.com).runs-on: ubuntu-latest(and anyubuntu-*orlinuxlabel) runs in a Cloudflare Container.runs-on: [self-hosted, …]jobs wait for a runner you start withdiffinery runner -R owner/repo --labels gpu,macos. Use that for macOS, GPUs or private networks.- Reusable workflows:
jobs.<id>.uses: ./.github/workflows/x.ymlfrom the same commit, orowner/repo/.github/workflows/x.yml@reffrom another repository on this instance (public ones, or private ones with the same owner).with,secrets(a mapping orinherit),on.workflow_calloutputs and nesting up to four levels deep all work. A matrix on the calling job isn't supported yet. - Docker, on hosted runners and on self-hosted runners with Docker installed:
a job
container:(steps and JavaScript actions run inside it),services:(reachable by name from the job container, or onlocalhostthrough their mapped ports, with health checks awaited), Docker container actions (docker://imagesteps and actions withruns.using: docker, built from their Dockerfile), and plaindocker build/docker run/docker composein steps. Hosted runners start the Docker daemon the first time a job uses it and pull Docker Hub images throughmirror.gcr.io. - A container registry at
diffinery.comin place of ghcr.io: sign in with the job'sGITHUB_TOKEN(permissions: packages: write) or a personal token, then push and pulldiffinery.com/<owner>/<name>. Images are private by default and can be linked to a repository. See Container registry. - Actions from GitHub run unmodified, including
actions/github-script,setup-java,setup-dotnetand the rest. Jobs get the same runtime services GitHub provides (ACTIONS_RUNTIME_TOKEN,ACTIONS_RESULTS_URL,ACTIONS_CACHE_URL), scoped to the job and expiring with it, soactions/cache(v3 and v4),actions/upload-artifactandactions/download-artifact(v4:pattern,merge-multiple,run-idwithgithub-token) anddocker/build-push-actionwithcache-to: type=ghastore caches and artifacts in Diffinery.GITHUB_EVENT_PATHholds a complete event payload andgithub.run_idis numeric. Caches and artifacts are listed and deleted through the REST API (/actions/caches,/actions/artifacts). Each repository keeps up to 10 GB of caches (the least recently used are evicted) and 10 GB of artifacts (ARTIFACT_QUOTA_BYTESchanges it; uploads beyond it are refused). Uploads in progress count toward both, and a job's unfinished uploads are removed when it ends. As on GitHub, a cache is saved for the ref the job runs on and restored from that ref, then the pull request's base branch, then the default branch, so branches and pull requests never see each other's caches. Artifacts keep theirretention-days(1 to 90 days, 90 by default); an expired artifact's content is deleted, and the REST API still lists it withexpired: true(downloads get 410) for another 90 days. actions/checkout,cache,upload-artifact,download-artifact,setup-node,setup-pythonandsetup-gorun as built-ins for speed. They share storage and formats with the real actions (a cache saved by one restores with the other) and support their inputs, including checkout'slfs,submodules,sparse-checkout,filter,fetch-tags,refandpersist-credentials: false, and thecache:input of the setup actions. A built-in hands over to the real action when it sees an input or ref it doesn't mirror (a commit SHA, a branch, a newer major). To always run the real action, setDIFFINERY_BUILTINS: falsein a workflow, job or stepenv:, or start a self-hosted runner withDIFFINERY_DISABLE_BUILTINS=1.- Hosted runners look like GitHub's
ubuntu-24.04image to actions:ImageOS=ubuntu24,RUNNER_TOOL_CACHE=/opt/hostedtoolcache, andgit,git-lfs,jq,zip,zstd,curl,python3/pip,build-essential,dockerandghinstalled.ACTIONS_STEP_DEBUGandACTIONS_RUNNER_DEBUG(as secrets or variables) turn on debug logging.
Not available: Windows and macOS hosted runners (Cloudflare Containers run
Linux only; those jobs run on self-hosted runners). Not supported yet: the GraphQL API
(github.graphql in github-script), and deepen-not / partial clone filters
on the server (checkout's filter falls back to a full fetch). One cache or
artifact upload request goes through the Worker, so it is limited to the
Worker's request size (100 MB on most plans); the built-ins and the official
actions upload large files in blocks. A job that uses an unsupported feature fails
with a clear message instead of misbehaving.
REST API
The API lives at /api/v3, with the same paths, JSON shapes and errors as
GitHub's REST API. Point a client at it and it works:
const octokit = new Octokit({ auth: token, baseUrl: "https://diffinery.example.com/api/v3" });
Responses carry node_id, url, html_url, full user objects and label
objects. Lists take per_page and page and send a Link header, so
octokit.paginate works. Errors have message and documentation_url.
Responses also keep a few Diffinery fields (such as merge_blockers on pull
requests) for the CLI, MCP and web UI.
What works:
- Repositories: get, create, update, delete, topics, languages,
collaborators, branches, tags, readme, contents (GET, and PUT and DELETE
with
sha; a staleshareturns 409), compare, commits,merges,dispatches, andzipball/tarballarchives.private: falsemakes a repository public. - Transfers and renames:
POST /repos/{owner}/{repo}/transfermoves a repository to your account or an organization you own or administer (people only, never agents orGITHUB_TOKEN), with its history, issues, pull requests, runs, secrets and linked container images. Like GitHub, the old name keeps working for anyone who can read the repository: pages and API reads answer 301, other API calls 307, and Git, MCP and image names are served under it, until a repository is created there again. Renamed repositories and accounts redirect the same way. An organization that still owns repositories can't be deleted (DELETE /orgs/{org}). - Archives:
zipballandtarballare built on demand and cached in R2. The cache can be evicted at any time; the archive is then built again. An archive holds at most 24 MB of files and 100,000 tree entries. A zip holds at most 65,535 files; use the tarball for more. Each repository can build 20 new archives per hour. Clone bigger repositories with git. - Git data: blobs, trees (with
base_treeandrecursive), commits, annotated tags,git/ref,git/refsandgit/matching-refs. - Releases: list, get by id or tag, latest, create (the tag is created
if missing), update, delete,
generate-notes(honours.github/release.yml),make_latest, and drafts (visible to writers only). Upload assets to the release'supload_url. Download them frombrowser_download_url, or from the asset URL withAccept: application/octet-stream. Bytes live in R2. An asset can be up to 100 MB, the largest request body Workers accept. - Commit statuses: create, list and the combined status. A status counts as a check, so a branch protection's required checks can name a status context.
- Required checks: when Diffinery Actions reports a check, it alone
decides that check. To have a required check decided by someone else, bind
it in branch protection, for example
"requiredCheckSources": { "ci/external": "ci-bot" }. A required check fails if any counted report of it fails. Only the reporter that created a check run can update it, and Actions check runs can't be changed over REST. - Checks: check runs (create, update, get, annotations) and check suites.
- Issues and pull requests: labels, assignees, lock and unlock,
comments (by issue and repository-wide), reactions (issues, comments, review
comments and releases), events and the timeline, reviews, review comments
and replies, review dismissal, requested reviewers,
update-branch, merge,commits/{sha}/pulls, and list filters (head,base,sort,direction,labels,assignee,since). Pull requests reportmergeable,mergeable_stateandrebaseable. Comment, review, check run, user and repository ids are numbers, as on GitHub. Locked conversations refuse comments, review comments and reactions from people without triage access. Dismissing a review takes maintain access, and the pull request's author can't do it. - Drafts and merging:
PATCH /pulls/{n}with"draft": falsemarks a draft ready for review ("draft": trueturns it back; GitHub only offers this through GraphQL). The web page has a "Ready for review" button and the CLI hasdiffinery pr ready. A merge commit is refused on a branch that requires linear history. Admins can merge past unmet branch rules only by sending"bypass_rules": true(the CLI'spr merge --admin); the bypass is written to the audit log. When several rules match a branch, the rule that names it exactly wins, then the oldest pattern. - Who can write: anonymous callers can read public repositories but can't
create or change anything. A
read-scoped token only reads; creating organizations, invitations, agents and tokens takes anadmin-scoped token or a browser session. Token scopes must beread,writeoradmin. Agents can't add, change or delete secrets or variables, whatever their role. - Archived repositories are read-only through the API, for everyone.
- Tree entry names: every write refuses
.,..,.git(in any case, and spellings like.git.orgit~1), and names with\or NUL. - Deployments and environments: deployments, deployment statuses,
environments, deployment branch policies, and environment secrets and
variables. A deployment without
required_contextsneeds every context on the commit to have passed, as on GitHub; send[]to skip that. An environment with no branch policy gets "protected branches only" when its first secret is added. Environment secrets never go torefs/pull/*runs. Runs started by agents only get them when the environment allows protected branches only. Tags pushed by agents never match an environment's tag patterns. - Actions: runs, run attempts, jobs, logs (a zip per run), artifacts, workflows, secrets and variables.
- Search:
/search/issues(repo:,is:,label:,author:,assignee:,no:,head:,base:,in:, date ranges andsort:) and/search/repositories(user:,org:,topic:,is:public,archived:). Search only covers repositories you can read. A query can be up to 256 characters, and one search looks at up to 50 repositories. /rate_limit. Diffinery doesn't rate-limit the API, so it reports a generous budget.
Workflows can react to these too. A published release triggers on: release,
a POST /dispatches triggers on: repository_dispatch, and webhooks get
release, status, deployment, deployment_status and
workflow_dispatch events, and edited, ready_for_review and
converted_to_draft actions on issues and pull requests. A delivery that
fails with a network error, 408, 429 or 5xx is retried up to five times over
about five hours. A job with
environment: creates a deployment, gets that environment's secrets and
variables, and is refused if the environment's branch policy doesn't allow
the ref. As on GitHub, events caused by GITHUB_TOKEN don't start new
workflow runs, except repository_dispatch.
Not supported yet: GraphQL (/api/graphql), setting secrets with a
sealed-box encrypted_value (send value instead), environment protection
rules (required reviewers, wait timers), forks, team reviewers, and code and
commit search. Search qualifiers for stars, forks and languages are accepted
but ignored.
Moving from GitHub
Diffinery imports a GitHub repository — or a whole organization — on the server: it copies the Git history in a container and the rest through GitHub's REST API, so you don't clone or push anything yourself and large repositories don't run through your laptop.
Start an import three ways:
- Web — Settings → Imports → Import from GitHub, or the Import a repository
link on New repository. Paste
owner/name(or a GitHub URL), choose where it lands and whether it stays public or private, and watch each step. For an organization, pick the repositories from the list. - CLI —
diffinery migrate github octo/appimports one repository and follows it to completion;diffinery migrate github-org octo --to my-orgimports an organization. Both take--json.--localruns the older client-side path instead (a mirror push plus a best-effort recreation of issues and open pull requests). - MCP / agents —
start_github_import, thenget_importto poll andlist_importsto review. An agent can import only where its owner may: the owner's own account or an organization the owner administers.
A private repository, Git LFS, Actions variables and branch protection need a
GitHub token (GH_TOKEN/GITHUB_TOKEN, or the token field in the form) with
repo access. For GitHub Enterprise you must name the API base explicitly with
GITHUB_API_URL (or the API URL field in the form): when a token is supplied for
a non-github.com source, Diffinery requires a matching github_api_url on the
same host and attaches the token only there, so a host taken from the source can
never receive it. Server-side fetches only ever reach that one host, every
response body is size-bounded, redirects are not followed across hosts, and the
token goes to GitHub alone (never logged, never returned).
Limits. Each account (and each organization) may run one organization
import and three single-repository imports at a time. A repository larger
than IMPORT_MAX_REPO_KB (default 10 GB) is refused up front. The Git transfer
has a 6-hour ceiling; an import that is still transferring after that fails
cleanly. Draft imports left unstarted for seven days are cancelled and their
stored token is erased.
Agents. An agent may import only where its owner may import: the owner's own account, or an organization the owner owns or administers (an organization's own agent imports into that organization). The agent's own policy must also allow write, the most an agent holds by default. Agents never read or set secrets and never manage variables: the repository, organization and environment variable steps are skipped for an agent-started import (and re-checked against a human owner when they do run). An agent-started import also never bypasses push protection — if the history is not clean, it stops.
What comes across, keeping original numbers and attribution:
- Git — every branch and tag, and LFS objects. Pull-request head commits are kept so merged and open PRs still point at real commits. Merge-queue refs are skipped.
- Issues and pull requests — with their original numbers (gaps and all), bodies, comments, reviews and review threads, labels, milestones, assignees, merged/closed state and who merged.
- Labels, milestones, branch protection, and Actions variables (repository and organization) and deployment environments (names, branch policies and variables).
- Releases and their assets.
What does not come across:
- Secret values. GitHub never discloses them, and Diffinery never tries to.
Repository and environment secret names are listed so you can re-enter the
values by hand (
diffinery secret set <NAME> -R owner/repo, or for an environmentdiffinery api PUT /repos/owner/repo/environments/<env>/secrets/<NAME> -f value=...), which an admin does — never an agent. - People. No accounts are created. A GitHub user is attributed to a Diffinery
account only through a verified link, matched by GitHub's numeric id so a
rename can't mis-map and keyed by the full API base so a github.com link and an
Enterprise link stay separate. Everyone else is imported as a mannequin: a
non-account placeholder (
github:<login>, linking to the GitHub profile) that carries the history but can't sign in, be assigned work or cast a vote. Where a mannequin authors a squashed merge, the commit uses GitHub'snoreplyaddress.
Link a GitHub account under Settings → Linked accounts one of two ways:
- Gist proof (no token). Diffinery shows a one-time code; you put it in a public gist and confirm your login. Diffinery reads your public profile and gists unauthenticated on the API base you named and links the account only when it finds the code. Nothing but your public data is read.
- OAuth (optional). When
GITHUB_OAUTH_CLIENT_IDandGITHUB_OAUTH_CLIENT_SECRETare configured, Link with GitHub → Continue to GitHub runs the OAuth flow with state bound to your account so a callback can't be replayed.
A site admin can clear any link (audited). Imported reviews are kept as history but never count as approvals, even when their author is a linked account — a pull request still needs a fresh review on Diffinery to be approved.
Old history that contains a credential is imported with an audited push-protection bypass (the history is already public on GitHub); choose stop to fail instead.
Retrying. Only whoever started an import may retry it, and a retry needs the GitHub token again (it is never stored past the run). While an import is in progress the target repository is locked against other writes (pushes, issues, pull requests, labels, webhooks, branch protection, renames and transfers). Actions secrets and variables, environments and collaborators can still be changed, so imported secrets' values can be entered while the history arrives. A retry refuses to touch a repository that already holds content the import did not write — it will not adopt or overwrite an existing repository. An agent may not retry once the repository has been created; a person who administers the target account takes over that retry instead.
Developing
pnpm install
pnpm -r build && pnpm -r typecheck && pnpm -r test
pnpm --filter @diffinery/app db:migrate:local
pnpm dev # http://localhost:8787, signed in as dev@diffinery.local
pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent + Chromium
The end-to-end test does the following:
- Pushes a repository with real
git. - Runs its CI through the actual runner agent.
- Checks secret scanning and branch protection.
- Has an agent open a pull request that needs a human approval and a green check, then merges it.
- Runs pull request workflows on test merge commits,
pull_request_targetfrom the base branch, and a merge queue withmerge_groupruns, including a group that fails and leaves the queue. - Clones shallow, then deepens the clone.
- Drives the MCP server.
- In Chromium with a virtual authenticator, joins from an invite with a new passkey, adds and removes passkeys, signs out and in, and uses an admin sign-in link.
- Drives the REST API with the real
@octokit/restclient: contents, git data, statuses as required checks, releases with asset uploads and downloads, issues, reactions, pagination, search and deployments.
On Diffinery, CI runs as three jobs (Typecheck and bundle, Unit tests (1/2)
and (2/2)) and the end-to-end suite as six shards of about the same length
(git, PRs, Actions; real histories; large pushes; agent reviews, tasks,
Today; security; passkeys, registry, email). All nine are required checks
on main, entered one name per line in its branch protection rule, so a pull
request lands only when every one of them is green.
Team preferences for product and design decisions are in docs/PREFERENCES.md.