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.

381 lines · 21.5 KBRaw History

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/*.yml runs 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 a diffinery:allow-secret comment, for test fixtures. Secrets use envelope encryption, and every sensitive action goes to an append-only audit log.

How it fits on Cloudflare

PieceCloudflare product
HTTP, git smart HTTP, REST API, MCP, web UIWorkers (Hono)
One repository: refs, objects, PRs, issues, reviews, checks, runsDurable Object with SQLite (RepoDO), one per repository
Large objects, Git LFS, container images, logs, caches, artifacts, event archiveR2
Accounts, orgs, tokens, invites, notifications, audit logD1, read from nearby replicas through the Sessions API (each browser keeps a bookmark so it reads its own writes)
Webhooks, notifications, audit fan-outQueues (with a dead-letter queue)
Workflow runs (durable, survive restarts, 6 h job timeout)Workflows
Job runners and agent sandboxesContainers (Ubuntu 24.04 image in containers/runner)
Scheduled workflows (on: schedule)Cron Triggers
Live run logs and PR updatesDurable Object WebSockets (hibernation)
Sign-inPasskeys (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-team/diffinery, and every push to main there runs .github/workflows/deploy.yml on Diffinery's hosted runners, followed by the live test (.github/workflows/live-test.yml). GitHub stays the upstream for now: .github/workflows/sync-to-diffinery.yml copies every branch and tag, and the secrets and variables below, to Diffinery on each push, then waits for the Actions runs there and fails if any of them fail. Running Deploy by hand on GitHub still deploys, as a fallback.

The workflow needs two repository secrets:

  • CLOUDFLARE_API_TOKEN: account permissions Workers Scripts, D1, R2, Queues, Workflows, Containers and Access: Apps and Policies (all Edit), and zone permission Workers Routes Edit on diffinery.com.
  • CLOUDFLARE_ACCOUNT_ID

On each deploy, scripts/bootstrap.sh creates anything that is missing: the D1 database, R2 bucket, queues 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. On the first deploy, ensure-secrets.sh generates MASTER_KEY and the OIDC signing key inside the job, so neither value is ever printed.

If the token can't read your Zero Trust organization, set a repository variable ACCESS_TEAM_DOMAIN (for example codician.cloudflareaccess.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.

  1. First sign-in for admins. The emails in BOOTSTRAP_ADMINS (in apps/diffinery/wrangler.jsonc) become site admins. Open https://diffinery.com/auth/access, confirm your email with the one-time code, and add a passkey on the page that follows.
  2. 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.
  3. 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/access by email.
  4. 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.
  5. 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 → 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

Email

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:

GroupEmailsCan be turned off
SecurityNew 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 blockedNo
AccountInvitations, sign-in links, welcome, account disabled or enabled, test emailNo
ParticipatingIssues 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 reopensYes
WatchingEverything in repositories you watch with All activityYes
ActionsFailed workflow runs you started (all results if you ask)Yes
RepositoriesRepositories created or deleted in your organizations, repository access grantedYes
OrganizationsOrganization 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-Unsubscribe and 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-To headers and a [owner/repo] subject, and carry X-Diffinery-Reason (like X-GitHub-Reason) for filters.
  • Delivery. Emails go through the diffinery-mail queue (retries with backoff, then the diffinery-mail-dlq dead-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 admins GET /api/v3/diffinery/admin/email and POST /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), issues, list_workflow_runs and get_job_logs, rerun_workflow, releases (create_release, upload_release_asset, generate_release_notes), and sandbox_create/sandbox_exec for running builds and tests.

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 agentsMayPush or agentsMayMerge.
  • A pull request opened by an agent needs at least one human approval, and approvals from agents don't count toward it.
  • 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.

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_request runs check out the test merge commit, refs/pull/<n>/merge (the head merged into the current base), with GITHUB_REF set to that ref. While a pull request conflicts with its base, its pull_request workflows don't run. refs/pull/<n>/head and refs/pull/<n>/merge can be fetched but not pushed.
  • pull_request_target runs 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, reopened and closed (with pull_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, and merge_group (and push) 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 is PUT/DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue and GET /repos/{owner}/{repo}/merge-queue/{branch}, diffinery pr merge queues automatically, and the MCP merge_pull_request tool does too.

Supported workflow features:

  • needs and job outputs, if with success(), failure(), always() and cancelled(), strategy.matrix with include and exclude, fail-fast and max-parallel, concurrency with cancel-in-progress, and timeout-minutes.
  • continue-on-error, defaults.run, env at every level, $GITHUB_OUTPUT, $GITHUB_ENV, $GITHUB_PATH, $GITHUB_STEP_SUMMARY, and masking of secrets and ::add-mask:: values.
  • secrets.*, vars.*, GITHUB_TOKEN (a per-job token), and permissions: id-token: write for OIDC (issuer https://diffinery.com).
  • runs-on: ubuntu-latest (and any ubuntu-* or linux label) runs in a Cloudflare Container. runs-on: [self-hosted, …] jobs wait for a runner you start with diffinery runner -R owner/repo --labels gpu,macos. Use that for macOS, GPUs or private networks.
  • Reusable workflows: jobs.<id>.uses: ./.github/workflows/x.yml from the same commit, or owner/repo/.github/workflows/x.yml@ref from another repository on this instance (public or internal ones, or private ones with the same owner). with, secrets (a mapping or inherit), on.workflow_call outputs 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 on localhost through their mapped ports, with health checks awaited), Docker container actions (docker://image steps and actions with runs.using: docker, built from their Dockerfile), and plain docker build / docker run / docker compose in steps. Hosted runners start the Docker daemon the first time a job uses it and pull Docker Hub images through mirror.gcr.io.
  • A container registry at diffinery.com in place of ghcr.io: sign in with the job's GITHUB_TOKEN (permissions: packages: write) or a personal token, then push and pull diffinery.com/<owner>/<name>. Images are private by default and can be linked to a repository. See docs/container-registry.md.

Not supported yet: actions/github-script, Windows and macOS hosted runners, and deepen-not / partial clone filters. 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 stale sha returns 409), compare, commits, merges, dispatches, and zipball/tarball archives. private: false makes a repository public.
  • Archives: zipball and tarball are 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_tree and recursive), commits, annotated tags, git/ref, git/refs and git/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's upload_url. Download them from browser_download_url, or from the asset URL with Accept: 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 report mergeable, mergeable_state and rebaseable. 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.
  • Archived repositories are read-only through the API, for everyone.
  • Tree entry names: every write refuses ., .., .git (in any case, and spellings like .git. or git~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_contexts needs 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 to refs/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 and sort:) 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 and deployment_status events. 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.

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:

  1. Pushes a repository with real git.
  2. Runs its CI through the actual runner agent.
  3. Checks secret scanning and branch protection.
  4. Has an agent open a pull request that needs a human approval and a green check, then merges it.
  5. Runs pull request workflows on test merge commits, pull_request_target from the base branch, and a merge queue with merge_group runs, including a group that fails and leaves the queue.
  6. Clones shallow, then deepens the clone.
  7. Drives the MCP server.
  8. 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.
  9. Drives the REST API with the real @octokit/rest client: contents, git data, statuses as required checks, releases with asset uploads and downloads, issues, reactions, pagination, search and deployments.

Team preferences for product and design decisions are in docs/PREFERENCES.md.