# Diffinery > Diffinery is open-source code hosting and CI for the agentic era: Git repositories, pull requests with intent, GitHub Actions compatible workflows and AI agents as first-class collaborators, running on your own Cloudflare account. This file holds diffinery.com as Markdown: the home page, the product, comparison and company pages, then every page of the Diffinery docs (https://diffinery.com/docs) in reading order. Each page starts with a level-1 heading and its source URL. The REST API is also available as OpenAPI 3.1 at https://diffinery.com/docs/api/openapi.json. --- # Diffinery > Diffinery is open-source code hosting and CI for the agentic era, running on your own Cloudflare account: Git repositories, pull requests, GitHub Actions–compatible CI on 4-vCPU runners, and AI agents with their own accounts and guardrails. Source: https://diffinery.com/ (last updated 2026-10-25) ## Code hosting and CI for the agentic era Diffinery is an open-source GitHub alternative for teams of people and AI agents. Git repositories, pull requests, GitHub Actions–compatible CI on fast runners, and agents with their own accounts and guardrails, running entirely on your own Cloudflare account. - **4 vCPU**: per CI job by default. GitHub's standard runner for private repositories has 2. - **3–5 s**: from a job being queued to its first step. - **Apache-2.0**: Open source, with nothing to buy. You run it on your own Cloudflare account. - **1 person**: approves every agent pull request, enforced by the server. ## What you get - **[Git hosting](https://diffinery.com/features#git-hosting)**: Clone, fetch and push over HTTPS with Git LFS. Every repository is its own strongly consistent store, and pushes that leak a credential are refused. - **[Pull requests with intent](https://diffinery.com/features#pull-requests-reviews-and-issues)**: Reviews, required approvals and checks, auto-merge and a merge queue. Each pull request says what it's meant to do, so reviewers check the right thing. - **[GitHub Actions–compatible CI](https://diffinery.com/features#actions-ci-that-runs-your-github-workflows)**: The same triggers, expressions, matrices, reusable workflows, secrets, OIDC and Docker. Actions from GitHub run as they do there. - **[Fast, right-sized runners](https://diffinery.com/performance)**: 4 vCPUs and 12 GiB per job by default, started in seconds. Jobs that only wait move to smaller runners on their own, never at the cost of speed. - **[AI agents with guardrails](https://diffinery.com/agents)**: Agents get their own accounts and tokens, run tasks with Claude Code or Codex, review pull requests and merge on green. They can't push to protected branches or reach your secrets. - **[Secure by default](https://diffinery.com/security)**: Passkeys instead of passwords, invite-only sign-up, encrypted secrets, scoped job tokens and an append-only audit log. ## Built for AI coding agents Claude Code, Codex and other agents work through Git, the GitHub CLI, the REST API or the built-in MCP server, as accounts of their own, and Diffinery can run them for you. Every agent feature is opt-in, people bring their own model keys or subscriptions, and the server keeps a person in charge of what lands. - **[Agent accounts with guardrails](https://diffinery.com/docs/agents/overview#guardrails)**: Each agent has its own login and tokens, owned by a person or an organization. It can't push to protected branches, a person approves its pull requests, and runs of its code get no secrets. - **[Agent tasks](https://diffinery.com/docs/agents/tasks)**: Give an agent a goal: Claude Code or Codex works in a sandbox with your own model key and opens a pull request. Plan first, best of N attempts, and fixes for its own failing checks. - **[Agent review](https://diffinery.com/docs/agents/review)**: An agent reviews every pull request for bugs, security issues and logic errors, posts inline findings with suggested fixes and reports an Agent review check. - **[Today and Needs you](https://diffinery.com/docs/agents/today)**: One page each morning: what needs you, what your agents are working on, and what merged or failed since yesterday, across every repository. - **[Merge on green](https://diffinery.com/docs/collaboration/merge-on-green)**: Pull requests merge by themselves once their checks pass, with or without a person's approval, as you choose. An agent's change to a workflow file still needs a person to merge it. - **[Your own subscription](https://diffinery.com/docs/agents/terminal)**: Open a terminal in the browser, sign in to Claude Code or Codex with your own Claude or ChatGPT plan, and give it tasks that open pull requests. ## Works where you and your agents already work - **[Claude Code plugin](https://diffinery.com/docs/connect/claude-code)**: The MCP server, a skill for the branch → pull request → CI → merge loop, /diffinery:pr-status, and a live band of the pull request's CI checks above the prompt. `claude plugin marketplace add codician-team/diffinery` - **[Codex plugin](https://diffinery.com/docs/connect/codex)**: The same MCP server, skills and session hook for OpenAI's Codex CLI. `codex plugin marketplace add https://diffinery.com/codician/diffinery.git` - **[MCP server, in the official registry](https://diffinery.com/docs/agents/mcp)**: 52 tools for code, pull requests, CI logs, sandboxes, Today and the docs, with a browser sign-in, for Claude, Claude Code, Codex, Cursor and any MCP client. Listed as com.diffinery/diffinery. `https:///mcp` - **[The diffinery CLI and gh](https://diffinery.com/docs/agents/github-cli)**: Pull requests with intent, auto-merge, runs and logs, runners and migrations. gh works as it does with GitHub Enterprise, so agents use the commands they know. `curl -fsSL https://diffinery.com/install | bash` - **[VS Code, Cursor and Windsurf](https://diffinery.com/docs/repositories/clone-in-your-editor)**: Clone from Diffinery with a passkey sign-in, and push and pull with it. `https://diffinery.com/install-editor` - **[JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides)**: Get from Version Control with a passkey sign-in, in IntelliJ IDEA, PyCharm, WebStorm and the rest (2024.3 or later). `https://diffinery.com/-/jetbrains/updatePlugins.xml` ## Your workflows, as they are ```yaml name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest # 4 vCPU, 12 GiB on Diffinery steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22, cache: pnpm } - run: pnpm install --frozen-lockfile - run: pnpm test ``` ## How Diffinery compares | | Diffinery | GitHub | GitHub with a runner service | | --- | --- | --- | --- | | Git hosting, pull requests, issues | Yes | Yes | Yes, on GitHub | | Runs GitHub Actions workflows | Yes, as they are | Yes | Yes, with a new runs-on label | | Default Linux runner, private repositories | 4 vCPU, 12 GiB | 2 vCPU, 8 GB | Your choice of size, billed by the vendor | | Guardrails for AI agents | Built in for every agent account | Rulesets and permissions you configure | Rulesets and permissions you configure | | Built-in MCP server and agent sandboxes | Yes | MCP server, Codespaces | GitHub's | | Hosted macOS and Windows runners | No (self-hosted) | Yes | Depends on the vendor | | Open source | Yes, Apache-2.0 | No | No | | Who runs it | You, on your own Cloudflare account | GitHub | GitHub and the runner vendor | Runner services include Blacksmith and Depot. See [all comparisons](https://diffinery.com/compare). ## Open source, on your own Cloudflare account Diffinery is open source under the Apache License 2.0: [source code](https://diffinery.com/codician/diffinery), [mirror on GitHub](https://github.com/codician-team/diffinery). Deploy it with [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy); you pay Cloudflare only for what your instance uses. To try it first, [request early access](https://diffinery.com/early-access) to diffinery.com. See [Open source](https://diffinery.com/open-source). ## Frequently asked questions ### What is Diffinery? Diffinery is a git forge and CI platform for teams of people and AI agents. It hosts Git repositories, pull requests, issues and releases, runs GitHub Actions workflows on its own hosted runners, and gives AI agents their own accounts, tokens and server-enforced guardrails. It runs entirely on Cloudflare, and it's open source under the Apache License 2.0: you deploy it to your own Cloudflare account. ### Is Diffinery an alternative to GitHub? Yes, for teams whose work fits it: Diffinery replaces GitHub's code hosting, pull requests and GitHub Actions in one platform. It doesn't have GitHub's hosted macOS and Windows runners, forks, app marketplace, Codespaces, Pages or Discussions. See [Diffinery vs GitHub](https://diffinery.com/compare/github). ### Do my GitHub Actions workflows run on Diffinery? Most run without changes. Diffinery reads `.github/workflows/*.yml` with the same syntax, triggers, expressions and actions. Hosted runners are Linux x64; macOS, Windows and Arm jobs need a self-hosted runner. Every difference is listed in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ### Is Diffinery built on Cloudflare? Yes, entirely: Git hosting, pull requests with reviews in a web UI and GitHub Actions–compatible CI, marketplace actions included, all run on Cloudflare. One Cloudflare Worker serves the site, the API, Git and the MCP server; each repository is its own Durable Object, with large objects in R2 and accounts in D1; Workflows and Queues drive CI runs, webhooks and email; and every CI job runs in a Cloudflare Container that exists only while the job runs. There are no servers to manage. See [Built on Cloudflare](https://diffinery.com/built-on-cloudflare). ### How is Diffinery different from Blacksmith or Depot? Blacksmith sells faster runners for GitHub Actions, and Depot sells runners, its own CI engine and container builds. With either, your code and pull requests usually stay on GitHub and jobs run on their machines. Diffinery replaces GitHub and GitHub Actions together, with fast runners built in, as one open-source platform you run on your own Cloudflare account. See [Diffinery vs Blacksmith](https://diffinery.com/compare/blacksmith) and [Diffinery vs Depot](https://diffinery.com/compare/depot). ### How does Diffinery work with Claude Code, Codex and other AI agents? Agents connect over MCP at `/mcp` on your instance with a browser sign-in, or use Git, the GitHub CLI and the REST API with an agent token. Each agent is an account of its own: it can't push to protected branches, its pull requests need a person's approval, and runs of its code get no secrets. See [Diffinery for AI agents](https://diffinery.com/agents). ### How do I move from GitHub? Diffinery's importer copies a repository or a whole organization from GitHub on the server, with history, issues, pull requests, reviews, releases and branch protection, keeping their numbers. Then you enter your secrets' values and push. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). ### Is Diffinery open source? Yes, under the Apache License 2.0, with no paid edition and nothing sold. The source code is at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery) and mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). See [Open source](https://diffinery.com/open-source). ### Can I try Diffinery without deploying it? Yes. [Request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance: its administrators review requests and email invitations, and you try Diffinery there. To run Diffinery for your team, deploy your own instance. See [Early access](https://diffinery.com/docs/accounts/early-access). ### How do I start using Diffinery, and what does it cost? Deploy your own instance to your Cloudflare account and domain with one command, `scripts/setup.sh`, then sign in as its first administrator and invite your team. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). Diffinery is free; you pay Cloudflare for what your instance uses, on the Workers Paid plan (a minimum of $5 USD per month per account, plus usage; [Cloudflare's pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026). There's no hosted service to sign up for; to try Diffinery first, [request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance. --- # Git hosting, pull requests and GitHub Actions–compatible CI in one place > Everything open-source Diffinery does: Git hosting, pull requests and a merge queue, GitHub Actions–compatible CI, a container registry and AI agents. Source: https://diffinery.com/features (last updated 2026-10-25) Diffinery is an open-source (Apache-2.0) git forge for teams of people and AI agents. It hosts your repositories, reviews and CI in one place, runs your existing GitHub Actions workflows as they are, and treats AI coding agents as accounts with their own tokens and guardrails. It runs entirely on Cloudflare: you [deploy your own instance](https://diffinery.com/docs/self-hosting/deploy) to your own Cloudflare account and domain, and there are no servers to manage. The source code is at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), mirrored on [GitHub](https://github.com/codician-team/diffinery). This page lists what Diffinery does today, what it doesn't do yet, and where each feature is documented. ## Git hosting - **Clone, fetch and push over HTTPS** with a token or a browser sign-in from the CLI or your editor. Shallow clones, Git LFS, tags and `refs/pull//head` and `/merge` work as on GitHub. See [Git over HTTPS](https://diffinery.com/docs/repositories/git). - **Every repository is its own strongly consistent store.** Each repository is a Cloudflare Durable Object with its own SQLite database, so one busy repository never slows another. - **Push protection on by default.** Every push, and every commit made through the web editor, the API or MCP, is scanned for leaked credentials, and a push that contains one is refused with nothing kept. See [Push protection](https://diffinery.com/docs/security/push-protection). - **Branch protection** with required pull requests, approvals, required checks, linear history, merge queues and rules for agents. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection). - **Browse code** in the web UI with highlighted files and diffs, history, branches and tags, a command palette and an in-browser editor. See [Browsing code](https://diffinery.com/docs/repositories/browsing-code). ## Pull requests, reviews and issues - **Pull requests with intent.** Every pull request can say what the change is meant to do, so human and AI reviewers check the right thing. Drafts, reviews with line comments, required approvals and checks, and merge, squash or rebase. See [Pull requests](https://diffinery.com/docs/collaboration/pull-requests). - **Auto-merge and a merge queue.** Pull requests land on their own once they're approved and green, and a merge queue tests each one on top of the base branch and the pull requests ahead of it, with the same `merge_group` event and `gh-readonly-queue/` branches as GitHub. See [Merge queue](https://diffinery.com/docs/collaboration/merge-queue). - **Issues** with labels, assignees, milestones, reactions, locking and search. See [Issues](https://diffinery.com/docs/collaboration/issues). - **Releases** with notes and downloadable files, following GitHub's model and REST API. See [Releases](https://diffinery.com/docs/collaboration/releases). - **Checks and commit statuses** from Diffinery Actions, external CI and bots. See [Checks and commit statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). ## Actions: CI that runs your GitHub workflows - **Drop-in for GitHub Actions.** Diffinery reads `.github/workflows/*.yml` with the same triggers, expressions, matrices, `needs`, outputs, reusable workflows, environments, secrets, variables, OIDC, service containers and Docker. JavaScript, composite and Docker actions from GitHub run as they do there. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility), which lists every difference. - **Fast built-in actions.** `actions/checkout`, `setup-node`, `setup-python`, `setup-go`, `cache`, `upload-artifact` and `download-artifact` are built into the runner, sharing storage and formats with the official actions. Caches restore in parallel ranges while they extract. - **Hosted runners on Cloudflare Containers.** Each job gets a fresh Ubuntu 24.04 x64 container with 4 vCPUs and 12 GiB of memory by default. Jobs start in about 3 to 5 seconds. See [Runners](https://diffinery.com/docs/actions/runners) and the [measured performance](https://diffinery.com/performance). - **Right-sized automatically.** Four sizes, from 1/4 vCPU to 4 vCPUs. Diffinery measures what each job uses and moves a job to a smaller runner only when its own runs show it fits with room to spare on the slowest hosts, and moves it back if it ever runs slower. See [Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes). - **Self-hosted runners** for macOS, Windows, Arm, GPUs or machines on your own network, with `diffinery runner`. See [Self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). - **Caches and artifacts** with the official `actions/cache` and `upload-artifact` formats. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). ## Container registry Push and pull images at `//` (such as `diffinery.com//` on diffinery.com) with `docker`, `podman`, `buildx`, `crane` or the `docker/*` actions, instead of ghcr.io. Images are private by default and can be linked to a repository whose collaborators then share access. See [Container registry](https://diffinery.com/docs/packages/container-registry). ## Built for AI agents - **Agents are accounts.** Each AI agent gets its own login and tokens, acts for a person or an organization, and is held to guardrails: unless a branch protection rule allows it, an agent can't push to or merge into protected branches, its pull requests need a person's approval, its check results never count as required checks, and runs of code it pushed get no secrets. See [Guardrails](https://diffinery.com/docs/agents/overview#guardrails). - **Agent tasks.** Give an agent a goal and get a pull request back: Claude Code, Codex or your own command works in a sandbox with your own model key, which never enters the sandbox. Tasks can plan first, run up to four attempts side by side, and fix their own failing checks. See [Agent tasks](https://diffinery.com/docs/agents/tasks). - **Agent review.** An agent reviews pull requests for bugs, security issues and logic errors, posts inline findings with suggested fixes, and reports an Agent review check; **Fix it** and autofix hand a finding to an agent task. See [Agent review](https://diffinery.com/docs/agents/review). - **Today and Needs you.** One page, the first after you sign in, with what needs you, what your agents are working on and what merged or failed since yesterday, also as a morning email and the `today` MCP tool. See [Today](https://diffinery.com/docs/agents/today). - **Merge on green.** Pull requests, agents' included, merge by themselves once their checks pass and their branch's rules are met. See [Merge on green](https://diffinery.com/docs/collaboration/merge-on-green). - **Terminals on your own subscription.** A shell in your browser with the repository checked out, where Claude Code or Codex runs on your own Claude or ChatGPT plan and can take tasks. See [Terminals](https://diffinery.com/docs/agents/terminal). - **Projects, sandboxes and an MCP server.** A board of each repository's agent tasks with shared memory and an optional coordinator, short-lived sandboxes to build and test in, and an MCP server at `/mcp` (`https://diffinery.com/mcp` on diffinery.com), listed in the official MCP Registry as `com.diffinery/diffinery`. ![The Today page on Diffinery: what needs you, the agent pull requests at work, your agent tasks and what changed since yesterday](https://diffinery.com/docs/images/today.webp) See [Diffinery for AI agents](https://diffinery.com/agents) for the whole picture. ## Works with the tools you already use | Tool | How it works with Diffinery | | --- | --- | | `git` | Standard Git over HTTPS. `diffinery auth setup-git` signs git in through the browser. | | GitHub CLI (`gh`) | Works as with GitHub Enterprise Server, through Diffinery's GitHub-compatible REST and GraphQL APIs. See [Use gh](https://diffinery.com/docs/agents/github-cli). | | VS Code, Cursor, Windsurf | "Clone from Diffinery" with a passkey sign-in. See [Clone in your editor](https://diffinery.com/docs/repositories/clone-in-your-editor). | | JetBrains IDEs | Get from Version Control with a passkey sign-in. See [Clone in JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides). | | Claude Code | The `diffinery` plugin (the MCP server, a skill, `/diffinery:pr-status` and a session hook) and the `diffinery-ci` plugin (a live band of the pull request's CI checks above the prompt). `claude plugin marketplace add codician-team/diffinery`. See [Claude Code](https://diffinery.com/docs/connect/claude-code) and [CI status in Claude](https://diffinery.com/docs/connect/ci-status-in-claude). | | Codex | The `diffinery` plugin with the MCP server, skills and a session hook. See [Codex](https://diffinery.com/docs/connect/codex). | | Claude, Cursor and other MCP clients | The MCP server at `/mcp` with a browser sign-in, listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery) as `com.diffinery/diffinery`. See [Connect AI apps](https://diffinery.com/docs/connect). | | Scripts and integrations | A GitHub-style REST API at `/api/v3`, described as [OpenAPI 3.1](https://diffinery.com/docs/api/openapi.json), and GitHub-compatible [webhooks](https://diffinery.com/docs/repositories/webhooks). | | The `diffinery` CLI | Repositories, pull requests, issues, runs, secrets, runners and migrations from a terminal. See the [CLI reference](https://diffinery.com/docs/reference/cli). | ## Moving from GitHub A server-side importer brings over a GitHub repository, or every repository of an organization, with its full Git history, issues, pull requests, reviews, comments, labels, milestones, releases and branch protection, keeping issue and pull request numbers. Secret names come across with a field to enter each value. Start it from the web, the CLI (`diffinery migrate github`), the REST API or MCP; nothing runs on your machine. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). ## Security Passkeys instead of passwords, invite-only sign-up by default, branch and push protection, envelope-encrypted secrets, job tokens limited by `permissions:`, signed webhooks and an append-only audit log. See [Security](https://diffinery.com/security). ## How it runs Everything runs on Cloudflare, in the account you deploy your instance to: a Worker serves the web UI, Git, the REST API and MCP; each repository is a Durable Object with SQLite; large objects, caches, artifacts and images live in R2; accounts live in D1; workflow runs are durable Cloudflare Workflows; and jobs and agent sandboxes run in Cloudflare Containers. See [Architecture](https://diffinery.com/docs/self-hosting/architecture). Diffinery itself is free. You pay Cloudflare for what your instance uses: it needs the Workers Paid plan, plus usage above the included amounts. See [Built on Cloudflare](https://diffinery.com/built-on-cloudflare#run-diffinery-on-your-own-cloudflare-account). ## What Diffinery doesn't do yet Diffinery is young, and we'd rather you know its limits before you move: - **Hosted runners are Linux x64 only.** macOS, Windows and Arm jobs need [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). - **HTTPS only for Git.** There's no SSH access. - **No forks.** Pull requests are between branches of the same repository. - **No wikis, discussions, GitHub Pages-style sites, Codespaces-style cloud editors or app marketplace.** - **Reviews are simpler than GitHub's.** No pending reviews, suggested changes or thread resolution yet, and branch protection has no code-owner reviews or signed-commit requirement. - **No hosted service.** Nobody runs Diffinery for you: you deploy and run your own instance in your own Cloudflare account, and you pay Cloudflare for what it uses. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). --- # Open source > Diffinery is free, open-source software (Apache-2.0). Get the source, deploy it to your own Cloudflare account, see what it costs, and contribute. Source: https://diffinery.com/open-source (last updated 2026-10-25) Diffinery is open source under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). Everything on this site, from Git hosting and pull requests to GitHub Actions–compatible CI, hosted runners, the MCP server and the agent features, is in one public repository. There are no plans, prices or sales, and no paid edition. You run Diffinery yourself, on your own Cloudflare account and domain. ## Source code | | | | --- | --- | | Repository | [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), where Diffinery is developed and its CI runs | | Mirror on GitHub | [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery) | | License | Apache-2.0, in the repository's `LICENSE` file, with a `NOTICE` file ("Copyright 2026 The Diffinery Authors") | | Language and platform | TypeScript, on Cloudflare Workers, Durable Objects, R2, D1, Queues, Workflows and Containers. See [Architecture](https://diffinery.com/docs/self-hosting/architecture). | Clone it from either place: ```bash git clone https://diffinery.com/codician/diffinery.git # or git clone https://github.com/codician-team/diffinery.git ``` The Apache License lets you use, change and redistribute Diffinery, commercially or not, as long as you keep the license and notices; it also grants a patent license from contributors. Read the [license text](https://www.apache.org/licenses/LICENSE-2.0) for the exact terms. ## Run your own Diffinery Diffinery is one Cloudflare Worker plus the Cloudflare products it binds to, so an instance has no servers, virtual machines or databases of its own. One command, `scripts/setup.sh` from a clone, checks your tools, API token and zone and then deploys: it creates what's missing in your Cloudflare account (the D1 database, R2 bucket, queues, Turnstile widget and Cloudflare Access application), applies migrations, uploads the Worker and builds the runner image. Then you sign in as the first administrator and invite your team. What you need: - A Cloudflare account on the Workers Paid plan, with R2 turned on. - A domain added to that account, for your instance's hostname. - A Cloudflare Zero Trust organization, for the first administrator's email sign-in and passkey recovery. - A machine with Node.js 22, pnpm, `jq`, `curl`, `openssl` and Docker to run the deploy, or a forge with GitHub-compatible Actions to run the Deploy workflow. Nobody who uses your instance needs a Cloudflare account; only the deployment does. Follow [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) step by step, and see [Self-hosting](https://diffinery.com/docs/self-hosting) for an overview and [Configuration](https://diffinery.com/docs/self-hosting/configuration) for every setting. ## What it costs Diffinery is free. You pay Cloudflare for what your instance uses, on your own Cloudflare bill: - The Workers Paid plan has "a minimum cost of $5 USD per month per account", which includes Workers and Durable Objects usage up to its allowances, and you pay for usage above them ([Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026). - Hosted CI runners and agent sandboxes are Cloudflare Containers. Cloudflare bills a container by the 10 ms while it runs ([Containers pricing](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026), and Diffinery starts one only when a job starts and stops it when the job ends, so idle time costs nothing. Jobs that mostly wait move to smaller runners on their own ([Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes)). - R2, D1, Queues and Workflows bill by use under Cloudflare's own pricing for each product. What a month costs depends on how much your team pushes and builds. Each instance sets per-account quotas for hosted minutes, jobs at once, sandboxes, repositories and storage, with defaults its administrators change; see [Limits](https://diffinery.com/docs/reference/limits#account-quotas). ## What diffinery.com is diffinery.com is the project's home: this site, the [documentation](https://diffinery.com/docs), and the Diffinery instance that Diffinery itself is developed on, with its CI, deploys and agents. Accounts there are by invitation. If you'd like to try Diffinery before running your own, [request early access](https://diffinery.com/early-access): its administrators review requests and email invitations. It isn't a hosted service for teams; to use Diffinery for your work, run your own instance. ## Contribute - **Issues and pull requests:** on [the GitHub mirror](https://github.com/codician-team/diffinery) if you don't have a diffinery.com account, or on [the Diffinery repository](https://diffinery.com/codician/diffinery). The repository's `CONTRIBUTING.md` explains how to build, test and send changes. - **Security problems:** report them privately with the [report form](https://diffinery.com/security/report), not in a public issue. See [Vulnerability disclosure](https://diffinery.com/docs/security/disclosure). - **Docs:** every page at [/docs](https://diffinery.com/docs) is Markdown in the repository, under `apps/diffinery/docs/`. ## Frequently asked questions ### Is Diffinery open source? Yes, under the Apache License 2.0. The source is at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery) and mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). ### Is there a paid or enterprise edition? No. There's one edition, the open-source one, and nothing is sold. ### Is there a hosted version I can sign up for? Not one to sign up for. To try Diffinery, [request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance, where accounts are by invitation. To use Diffinery for your team, deploy your own instance to your Cloudflare account; see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). ### What does running Diffinery cost? Diffinery itself is free. Your instance runs on your Cloudflare account, which needs the Workers Paid plan (a minimum of $5 USD per month per account, plus usage above its allowances), and pays for containers, storage and the other Cloudflare products by use. See [What it costs](https://diffinery.com/open-source#what-it-costs). ### Can I run Diffinery somewhere other than Cloudflare? No. Diffinery is built on Cloudflare's products (Workers, Durable Objects, R2, D1, Queues, Workflows and Containers) and has no version for other clouds or for your own servers. Self-hosted runners, though, run jobs on any machine you control; see [Self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). --- # Git hosting and CI built for AI coding agents > AI agents get accounts with guardrails, tasks with Claude Code or Codex, agent review, Today, merge on green and plugins. People decide what lands. Source: https://diffinery.com/agents (last updated 2026-10-25) Coding agents such as Claude Code and Codex now write a large share of many teams' pull requests. Diffinery is an open-source git forge designed for that, which you run on your own Cloudflare account ([Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy)): agents work through the same Git, REST API and CI as people, but as accounts of their own, with limits the server enforces, so a person stays in charge of what reaches your main branch. Diffinery can also run your agents for you. Give one a goal and Claude Code or Codex opens a pull request from a sandbox; have another review every pull request; open **Today** in the morning to see what needs you; approve, and let it merge on green. Plugins for Claude Code and Codex, an MCP server in the official MCP Registry, the `diffinery` CLI, `gh` and editor extensions bring it into the tools you already use. | What | In one line | | --- | --- | | [Agent accounts](https://diffinery.com/agents#agents-are-accounts-not-borrowed-tokens) | Their own logins and tokens, owned by a person or an organization | | [Guardrails](https://diffinery.com/agents#guardrails-the-server-enforces) | No pushes to protected branches, a person approves, no secrets for agent code | | [Agent tasks](https://diffinery.com/agents#agent-tasks-a-goal-in-a-pull-request-out) | A goal in, a pull request out, with Claude Code or Codex in a sandbox | | [Agent review](https://diffinery.com/agents#agent-review-on-every-pull-request) | Inline findings and an Agent review check on every pull request | | [Today and Needs you](https://diffinery.com/agents#today-what-needs-you) | What needs you and what your agents did since yesterday | | [Merge on green](https://diffinery.com/agents#merge-on-green) | Agent pull requests land when their checks pass, after a person approves if you want that | | [Plugins and integrations](https://diffinery.com/agents#plugins-and-integrations) | Claude Code, Codex, MCP, the CLI, gh, VS Code and JetBrains | ## Agents are accounts, not borrowed tokens On most forges an agent acts with a person's token, so its work is indistinguishable from theirs and it can do anything they can. On Diffinery: - **Each agent has its own login and tokens**, owned by a person or an organization. Every commit it makes is stamped with who it acts for. - **Its role is capped** by a maximum role (write by default) and by its owner's own role. - **It can't sign in to the web UI.** Agents use tokens over Git, the REST API, the CLI and MCP; only people have passkeys and browser sessions. See [Agents](https://diffinery.com/docs/agents/overview). ## Guardrails the server enforces These hold for Git pushes, the REST API, MCP tools and sandboxes alike: | Rule | What it means | | --- | --- | | No pushes to protected branches | Agents open pull requests instead. A branch protection rule can allow it. | | A person approves agent pull requests | At least one approval from a person, even on a branch with no rule. An agent's approval never counts. | | No merging protected branches | Unless a rule allows agents to merge, or to land through auto-merge once CI and a person agree. | | Agent check results never satisfy required checks | An agent can't report its own CI as green. | | No secrets for agent code | A workflow run of code an agent pushed gets only its job token, so an agent can't push a workflow that sends your secrets elsewhere. | | No secret, variable, environment or branch protection changes | Whatever the agent's role. | | No push protection bypass | A credential in an agent's push is always refused. | The full list is in [Guardrails](https://diffinery.com/docs/agents/overview#guardrails). ## An MCP server for every AI app Every Diffinery instance runs a Model Context Protocol server at `/mcp` on its domain (`https://diffinery.com/mcp` on diffinery.com). Claude (claude.ai, Desktop and mobile), Claude Code, Codex, Cursor, VS Code and other MCP clients sign in through the browser with OAuth 2.1, or headless with an agent token. Its tools read code, commit files, open, review and merge pull requests, follow CI and read job logs, publish releases, import from GitHub, run commands in sandboxes and search these docs, all through the same permission checks and guardrails as Git and the REST API. - [Connect AI apps](https://diffinery.com/docs/connect): one URL and a browser sign-in for Claude, Claude Code, Codex and others. - [MCP tool reference](https://diffinery.com/docs/reference/mcp-tools): every tool and its arguments, generated from the server. - Listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery) as `com.diffinery/diffinery`, for anyone's own instance. - Plugins for Claude Code and Codex add the MCP server, skills and a session hook in one install: see [Plugins and integrations](https://diffinery.com/agents#plugins-and-integrations). ## gh, Git and the REST API work as agents expect Agents trained on GitHub already know how to use Diffinery. The GitHub CLI works with Diffinery as it does with GitHub Enterprise Server (`diffinery auth setup-gh` signs it in), so `gh pr create`, `gh pr checks` and `gh pr merge` just work, and Claude Code shows the pull request and CI status of the branch it's on. The REST API follows GitHub's at `/api/v3`, with a GraphQL endpoint for the queries `gh` makes. See [Use gh with Diffinery](https://diffinery.com/docs/agents/github-cli). ## Sandboxes: a machine for every agent A [sandbox](https://diffinery.com/docs/agents/sandboxes) is a short-lived Linux container with the repository already cloned, driven over MCP or the REST API: run commands, read and write files, build, test and push. An agent working from a chat app gets a real machine without anyone setting one up. ## Agent tasks: a goal in, a pull request out Give one of your agents a goal. Diffinery starts a sandbox as that agent with the repository checked out, runs Claude Code, Codex or your own command with your own model key, streams its transcript to the task page, then pushes a branch and opens a pull request for a person to review. Your API key never enters the sandbox: each run gets a token that only Diffinery's model proxy accepts. A task can **plan first** and wait for your approval, run up to four **attempts** side by side for you to pick from, and **verify and land**: start a fix run when its checks fail, and merge once a person approves. ![An agent task on Diffinery: the goal, Claude Code running as the acme-coder agent, its transcript with file edits and test runs, and the pull request it opened](https://diffinery.com/docs/images/agent-task.webp) To use your own **Claude or ChatGPT subscription** instead of an API key, open a [terminal](https://diffinery.com/docs/agents/terminal): a shell in your browser with the repository checked out, where Claude Code or Codex runs on your own plan, and which can take tasks too. See [Agent tasks](https://diffinery.com/docs/agents/tasks). ## Agent review on every pull request One of your agents reviews pull requests for bugs, security issues and logic errors, the way Cursor's Bugbot does. It runs in a read-only sandbox with the base branch's `REVIEW.md` rules, posts its findings as inline comments with suggested fixes, and reports an **Agent review** check that fails when a finding is blocking. **Fix it** under a finding starts an agent task on the pull request's branch, and autofix does it for every finding. See [Agent review](https://diffinery.com/docs/agents/review). ![A pull request opened by an agent on Diffinery, with its intent, the task that wrote it, and an agent review with an inline finding, a suggested fix and a Fix it button](https://diffinery.com/docs/images/agent-pull-request.webp) ## Today: what needs you [Today](https://diffinery.com/docs/agents/today) is the first page after you sign in: what needs you (agent pull requests waiting for a person, reviews you were asked for, your own blocked pull requests), what your agents are working on, your agent tasks, and what merged, shipped or failed since yesterday, across every repository you can see. Agents read the same view with the `today` MCP tool, and it can come by email each morning. ![The Today page on Diffinery: counts of what needs you, agent pull requests and failed runs, then the Needs you, Agents at work, Your agent tasks and Since yesterday sections](https://diffinery.com/docs/images/today.webp) ## Merge on green In **Settings → Merge on green**, a repository admin picks the checks that must pass and the agents whose pull requests land by themselves, and chooses whether a person's approval is still needed. Then pull requests merge the moment their checks pass and their branch's rules are met, with nobody clicking Merge. An agent's change to a workflow file still needs a person to merge it. See [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green). ![The merge box of an agent's pull request on Diffinery: its required check passed and auto-merge is on, waiting for a person's approval](https://diffinery.com/docs/images/merge-on-green.webp) ## Projects with shared memory Every repository is also a [project](https://diffinery.com/docs/agents/projects): a board of its agent tasks, a conversation for the people working on it, instructions every task follows and a memory every task reads. Each person can turn on a coordinator, an agent that turns their posts into tasks within the limits admins set. Every agent feature is optional and off until someone turns it on. People bring their own model keys or subscriptions; Diffinery doesn't sell or include model access. ## Plugins and integrations | Tool | What it adds | How to get it | | --- | --- | --- | | Claude Code | The `diffinery` plugin: the MCP server, a skill for the branch → pull request → CI → merge loop, `/diffinery:pr-status` and a session hook. The `diffinery-ci` plugin: a live band of the pull request's checks above the prompt and a note to Claude when CI settles. | `claude plugin marketplace add codician-team/diffinery`, then `claude plugin install diffinery@diffinery` and `diffinery-ci@diffinery`. See [Claude Code](https://diffinery.com/docs/connect/claude-code). | | Codex | The same MCP server, skills and session hook. | `codex plugin marketplace add https://diffinery.com/codician/diffinery.git`, then `codex plugin add diffinery@diffinery`. See [Codex](https://diffinery.com/docs/connect/codex). | | Any MCP client | The MCP server, listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery) as `com.diffinery/diffinery` (checked October 25, 2026). | `https:///mcp`. See [MCP](https://diffinery.com/docs/agents/mcp). | | Claude (claude.ai, Desktop, mobile) | Diffinery as a custom connector. | Settings → Connectors → Add custom connector. See [Claude](https://diffinery.com/docs/connect/claude). | | Terminal | The `diffinery` CLI: pull requests with intent, auto-merge, runs and logs. `diffinery setup` signs in and sets up git, gh and both Claude Code plugins. | `curl -fsSL https://diffinery.com/install \| bash`. See [CLI sign-in](https://diffinery.com/docs/accounts/cli-sign-in). | | GitHub CLI | `gh pr create`, `gh pr checks`, `gh pr merge` and the rest. | `diffinery auth setup-gh`. See [Use gh](https://diffinery.com/docs/agents/github-cli). | | VS Code, Cursor, Windsurf | Clone from Diffinery with a passkey sign-in. | [Clone in your editor](https://diffinery.com/docs/repositories/clone-in-your-editor). | | JetBrains IDEs | Get from Version Control with a passkey sign-in. | [Clone in JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides). | ## Docs written for agents to read Every docs page is available as Markdown (add `.md` to its URL, or send `Accept: text/markdown`). [/llms.txt](https://diffinery.com/llms.txt) indexes the site and the docs, [/llms-full.txt](https://diffinery.com/llms-full.txt) holds all of it in one file, the REST API is described as [OpenAPI 3.1](https://diffinery.com/docs/api/openapi.json), and agents connected over MCP can call `search_docs` and `read_docs`. See [Docs for AI agents](https://diffinery.com/docs/agents/docs-for-agents). ## Frequently asked questions ### Which AI coding agents work with Diffinery? Any agent that uses Git, the GitHub CLI, a REST API or MCP. There are step-by-step guides for Claude, Claude Code, Codex, Cursor and VS Code, plugins for Claude Code and Codex, and agent tasks and agent review run Claude Code or Codex for you. ### Can I use my Claude or ChatGPT subscription? In a [terminal](https://diffinery.com/docs/agents/terminal), yes: you sign in to Claude Code or Codex there with your own plan, and can give it tasks that open pull requests. Headless agent tasks and agent review use an API key (Anthropic, OpenAI or OpenRouter), because the providers allow automated use with API keys. Codex on a ChatGPT plan is offered on private repositories only. ### Can an agent merge its own pull request? Not by default. A person has to approve an agent's pull request, and agents can't merge into the default branch or protected branches. A repository admin can let agents land approved pull requests through auto-merge, so CI and a person decide rather than the agent. ### Do I need to give Diffinery my model API key? Only to run agent tasks or agent review with an API key. The key is stored encrypted and never enters the sandbox; runs reach the model through Diffinery's proxy with a token scoped to that run. Terminals use your own Claude or ChatGPT sign-in instead, and nothing else needs a key. ### Is Diffinery only for agents? No. People use the same web UI, Git, pull requests and CI as on any forge. Agents are first-class collaborators alongside them. --- # How fast Diffinery Actions runs, measured > Measured job start, checkout, cache, install, Docker and toolchain times on Diffinery's hosted runners, next to GitHub's published runner specs. Source: https://diffinery.com/performance (last updated 2026-10-25) We measured every stage of typical CI jobs on Diffinery's live hosted runners on October 16, 2026, and set them next to GitHub's published runner specifications. Nothing was run on GitHub for this page: GitHub's figures are its own published specs and documentation, linked below. Our numbers are ranges from repeated runs, because Cloudflare places containers on hosts of different speeds. ## Summary - **CPU-heavy jobs are faster.** A Diffinery job gets 4 vCPUs and 12 GiB of memory by default. GitHub's standard Linux runner for private repositories has 2 vCPUs and 8 GB, so builds and test suites that use several cores finish up to about twice as fast. For public repositories GitHub's standard runner also has 4 vCPUs, so there it's about even. - **Jobs start in seconds.** About 3 to 5 seconds from a job being queued to its first step, and 6 to 8 seconds from a push to the first job running. - **Setup steps are at or near parity.** Checkout takes 1 to 3 seconds, restoring a 216 MB pnpm cache 3 to 4 seconds on fast hosts, and `pnpm install` from that cache 3 to 7 seconds. - **Where GitHub is still a little faster:** GitHub's image preinstalls Go, Java and a running Docker daemon. Here `setup-go` takes 1 to 3 seconds and `setup-java` 2 to 5 seconds against about a second there, and the first `docker` command waits 0 to 2 seconds for the daemon. ## Machine | | Diffinery (default, large) | GitHub standard runner, private repository | GitHub standard runner, public repository | | --- | --- | --- | --- | | vCPU | 4 | 2 | 4 | | Memory | 12 GiB | 8 GB | 16 GB | | Disk | 20 GB | 14 GB SSD | 14 GB SSD | | Architecture | x64 | x64 (arm64 available) | x64 (arm64 available) | | Smaller sizes | 2, 1/2 and 1/4 vCPU, chosen per job from its own measured runs | 1 vCPU `ubuntu-slim` (15-minute job limit) | Same | GitHub's figures are from [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked October 16, 2026. GitHub sells 4-core and larger Linux runners for private repositories on its Team and Enterprise plans, at twice the per-minute price of the 2-core runner ([runner pricing](https://docs.github.com/en/billing/reference/actions-runner-pricing)). ## Stage by stage: a typical Node.js CI job Measured on Diffinery's large runner (4 vCPU) with the jobs of Diffinery's own CI and benchmark workflows: | Stage | Diffinery | Notes | | --- | --- | --- | | Push to first job running | 6 to 8 s | From `git push` returning to the first job's first step. | | Job start | About 3 to 5 s | A fresh container per job. | | `actions/checkout` | 1 to 3 s | Built into the runner. | | Restore a 216 MB pnpm cache | 3 to 4 s on fast hosts, about 10 s on the slowest | Downloads 16 ranges at once and extracts while it downloads. The whole `setup-node` step takes 5 to 10 s. | | `pnpm install` from that cache | 3 to 7 s | | | Upload a 200 MB artifact | 7 to 9 s | Blocks uploaded in parallel and joined as parallel parts. | | `setup-python`, `setup-node` | 0 s and 2 s | Python 3.12 and Node.js 22 are preinstalled. | | `setup-go`, `setup-java` (Temurin) | 1 to 3 s and 2 to 5 s | Installed while checkout runs, checked against the published SHA-256. GitHub preinstalls them, about 1 s. | | First `docker` command after checkout | 0.03 to 0.3 s | The daemon starts as the job begins, only for jobs that use Docker. | | First `docker` command as a job's very first step | 1.4 to 2.3 s | GitHub's daemon is already running: 0 s. | ## The same small job on every runner size A lint job: checkout, `npm ci` (89 packages), eslint, prettier and `tsc` on 40 files, two runs per size. | Size | vCPU | Whole job | `tsc` step | | --- | --- | --- | --- | | tiny | 1/4 | 15 to 27 s | 7 to 14 s | | small | 1/2 | 13 to 14 s | 5 s | | medium | 2 | 10 to 13 s | 2 to 3 s | | large | 4 | 9 to 14 s | 2 to 4 s | A job that only makes API calls (ten REST requests) took 4 seconds on both tiny and large. That's why Diffinery sizes each job from what its own runs used: jobs that wait can run on a small machine at no cost in speed, and jobs that compute keep the large one. A job moves to a smaller size only when every one of its recent runs fits there with room to spare on the slowest hosts, and goes back to large for its next 20 runs if a smaller size ever makes it slower. See [Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes). ## A real pipeline: Diffinery's own pull requests Diffinery is developed on Diffinery. Every pull request runs 10 required checks: a typecheck and bundle, two unit test shards and seven end-to-end suites that start a real Diffinery server and drive Git, pull requests, Actions, passkeys, agents, Docker and large pushes against it. From the first job starting to the last job finishing, the checks take about 3 minutes, and up to about 6 minutes when the jobs land on the slowest hosts. ## Why it's fast - **4 vCPUs by default**, so parallel test runners, bundlers and compilers have room. - **The common actions are built into the runner.** `checkout`, `setup-node`, `setup-python`, `setup-go`, `cache` and the artifact actions run natively instead of being downloaded and started in Node.js for every job. - **Head starts.** The runner reads the workflow before the first step and starts Docker, Go or a JDK while checkout runs, only for jobs that use them. - **Parallel transfers.** Caches download in 16 ranges at once and extract as they arrive; artifacts upload in parallel parts. - **A fresh machine per job.** A container exists only while its job runs, so jobs never wait behind another job's leftovers or share a dirty workspace. ## What isn't faster yet - **Toolchains GitHub preinstalls.** Go, Java, Ruby, Rust, .NET and browsers are installed per job here. Go and Java get a head start; others install as the step runs. Shipping them all in the image would make it bigger for every job and slower to roll out to new hosts, so the image stays small on purpose. - **Slow hosts.** Cloudflare's hosts span three AMD EPYC generations about 2x apart per core, and we don't choose which one a job lands on. The ranges above include them. - **Linux x64 only.** There are no hosted macOS, Windows or Arm runners; use [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners) for those. ## How we measured - All Diffinery numbers come from runs on the production hosted runners of diffinery.com, the instance Diffinery is developed on, on October 16, 2026, after the changes that produced them were deployed. Benchmark workflows ran on a separate scratch repository, not on Diffinery's own CI. - Stage times are from step timestamps in job logs. Upload times were measured by timing the upload and join calls separately. Each benchmark ran at least twice per configuration, Docker, Go and Java three times before and after each change. - GitHub's specs and prices are from GitHub's documentation, read on October 16, 2026. We did not run benchmarks on GitHub. - Results depend on your workflow. If you run your own Diffinery instance ([Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy)) with your own workflows, every job's log ends with what it used (busy cores, peak memory and disk) and names the runner size it got. See also: [Diffinery vs GitHub](https://diffinery.com/compare/github) and [Runners](https://diffinery.com/docs/actions/runners). --- # Security built in, not bolted on > How Diffinery protects code and secrets: passkeys instead of passwords, push protection, encrypted secrets, scoped job tokens, agent guardrails, audit log. Source: https://diffinery.com/security (last updated 2026-10-25) A forge holds a team's most valuable asset and the keys to its production systems. When AI agents push code and run workflows too, the forge has to enforce limits on its own, not trust that every token is used well. These are the protections every account and repository on a Diffinery instance gets by default. Diffinery is open source (Apache-2.0), so you can read the code that enforces them at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery). ## People sign in with passkeys There are no passwords to guess, reuse or phish. People sign in with a passkey (WebAuthn) kept by their device, password manager or security key, with user verification required. Passkeys are bound to your instance's domain (`diffinery.com` on diffinery.com), so a look-alike site can't use them. Sensitive actions, such as creating an agent or a token, ask you to confirm with your passkey again when your sign-in is older than two hours. See [Passkeys and sign-in](https://diffinery.com/docs/accounts/passkeys-and-sign-in). ## Invite-only by default Every Diffinery instance is invite-only by default: nobody joins without an invitation from a site administrator, an organization owner or admin, or a repository admin. Invite links work once and expire after 14 days. An instance's administrators can open sign-up with `SIGNUP=open` ([Configuration](https://diffinery.com/docs/self-hosting/configuration#signup)). See [Invitations](https://diffinery.com/docs/accounts/invitations). ## Leaked credentials never land Every push, and every commit made through the web editor, the REST API or MCP, is scanned for credentials such as cloud keys and tokens. A push that contains one is refused, and nothing from it is kept. Only a repository admin who is a person can push past it, with an audited reason; an agent never can. See [Push protection](https://diffinery.com/docs/security/push-protection). ## Secrets stay secret - **Envelope encryption** with a key per repository, environment or organization, derived from a master key that can be rotated. - **Masked in logs:** secret values are replaced with `***`. - **Never handed to agent code:** a workflow run of code an agent pushed gets no repository or organization secrets, so an agent can't push a workflow that sends them elsewhere. - **Job tokens** valid for one job and limited by the workflow's `permissions:`, read-only by default for new repositories, plus short-lived OIDC id tokens so deploys need no long-lived cloud keys. See [OIDC](https://diffinery.com/docs/actions/oidc). ## A human in the loop for agents AI agents are accounts with their own tokens and server-enforced guardrails: no pushes to protected branches, a person's approval on every agent pull request, agent check results never counted as required checks, and no secret, variable, environment or branch protection changes. See [Diffinery for AI agents](https://diffinery.com/agents) and [Guardrails](https://diffinery.com/docs/agents/overview#guardrails). ## Tokens with limits Personal and agent tokens (`dfy_...`) are stored hashed, expire, and can be limited to read, write or admin scope and to specific repositories. A read token never writes. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). ## Every sensitive action is recorded An append-only audit log records sign-ins, token and agent changes, permission changes, secret changes, push protection bypasses and more, and security emails tell you when something important changes on your account. See [Audit log](https://diffinery.com/docs/security/audit-log). ## Isolation Each repository is its own Durable Object with its own database, each workflow job runs in a fresh container thrown away after the job, and each agent sandbox is its own container. The web UI sends a strict, nonce-based Content Security Policy, HSTS with preload and frame protection on every page, and images from other sites in Markdown are fetched through Diffinery's own proxy, so those sites never see who reads a page. ## Reviewed before release Diffinery's code was audited area by area in October 2026: identity and tokens, Git and storage, Actions and secrets, the agent features and MCP, the web UI and REST API, the GitHub importer, and Diffinery's own CI and deploys. The [Security overview](https://diffinery.com/docs/security/overview) documents the whole model in detail. ## Report a vulnerability If you find a security problem, please tell us privately through the [report form](https://diffinery.com/security/report), the contact in [`/.well-known/security.txt`](https://diffinery.com/.well-known/security.txt). Good-faith research is covered by a safe harbour. See [Reporting a vulnerability](https://diffinery.com/docs/security/disclosure). --- # Git hosting and CI built entirely on Cloudflare > How open-source Diffinery runs Git hosting and GitHub Actions–compatible CI in your own Cloudflare account on Workers, Durable Objects, R2, D1 and Containers. Source: https://diffinery.com/built-on-cloudflare (last updated 2026-10-25) Diffinery is an open-source (Apache-2.0) git forge and CI platform that runs entirely on Cloudflare, and you deploy it to your own Cloudflare account. There are no servers, virtual machines or databases behind it that anyone has to run: one Cloudflare Worker and the Cloudflare products it uses serve Git, pull requests, the REST API, the MCP server and the container registry, and every workflow job runs in a Cloudflare Container started for that job. Diffinery is an open-source platform where Git hosting, pull requests with reviews in a web UI and GitHub Actions–compatible CI, marketplace actions included, all run on Cloudflare (checked October 25, 2026; see [Other Git and CI projects on Cloudflare](https://diffinery.com/built-on-cloudflare#other-git-and-ci-projects-on-cloudflare)). This page explains how to run your own instance, which Cloudflare product does what, what that means for the people using it, how it differs from Cloudflare's own Workers Builds and from other projects on Cloudflare, and the limits that come with it. ## Run Diffinery on your own Cloudflare account Diffinery is free, open-source software under the Apache-2.0 license. There is no hosted service to sign up for: you deploy your own instance to your own Cloudflare account, with your own domain, and run it as you like. Because Diffinery is one Worker plus the Cloudflare products it binds to, an instance needs a Cloudflare account on the Workers Paid plan, a domain on that account and a Cloudflare Zero Trust organization, and no machines to look after. The repository's Deploy workflow creates what's missing in your account and deploys the Worker, and every step of it is safe to repeat. - **Source code:** [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), where Diffinery is developed, mirrored on GitHub at [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). Issues and pull requests are welcome on the GitHub mirror from people without a diffinery.com account. - **Deploying:** [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) walks through it step by step. [Self-hosting](https://diffinery.com/docs/self-hosting) gives the overview, [Configuration](https://diffinery.com/docs/self-hosting/configuration) lists every setting and [Architecture](https://diffinery.com/docs/self-hosting/architecture) explains how the parts fit. - **What it costs:** Diffinery itself is free. You pay Cloudflare for what your instance uses. It needs the Workers Paid plan, which has "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026). Runner containers are billed by the 10 ms while a job runs ([Containers pricing](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). - **Who needs a Cloudflare account:** only the deployment. Nobody who uses your instance needs one; they sign in with passkeys. Every instance is invite-only by default, and `SIGNUP=open` opens sign-up. ## Which Cloudflare product runs what This is the same mapping as the [Architecture](https://diffinery.com/docs/self-hosting/architecture) documentation, which goes into more detail. | Cloudflare product | What it runs in Diffinery | | --- | --- | | Workers | The web UI and the public docs, Git over HTTPS, Git LFS, the REST API, the MCP server, the container registry, the runner API, and the Actions cache and artifact services. The Worker holds no state of its own: it signs the caller in, checks their role and hands the request to the right repository. | | Durable Objects with SQLite, one per repository | Everything inside one repository: branches and tags, git objects, issues, pull requests, reviews, checks and commit statuses, releases, environments, workflow runs, secrets, webhooks and the merge queue. Live run logs and pull request updates reach the browser over the object's WebSockets. | | R2 | Anything too large for SQLite: git objects over 512 KiB compressed, Git LFS files, archived job logs, Actions caches and artifacts, container image layers, release assets and cached source archives. | | D1 | Data that belongs to no single repository: accounts, organizations, tokens, the list of repositories and who can reach them, invitations, notifications, the audit log and the container registry's index. | | Queues | Webhook deliveries and their retries, notifications, audit log entries and email jobs, sent in the background so the request that caused them doesn't wait. | | Workflows | Every workflow run, as a durable Cloudflare Workflow whose steps and waits survive restarts and deploys. GitHub imports and account exports run as Workflows too. | | Containers | Hosted job runners (one container per job), agent sandboxes, and the Git transfer of each GitHub import. | | Durable Object alarms and Cron Triggers | Scheduled workflows, checked every 5 minutes, and housekeeping such as registry cleanup and token expiry reminders. | | Email Sending | Every email Diffinery sends: invitations, notifications and security emails. | | Cloudflare Access | Only email-based account recovery and the first administrator's sign-in. Everyday sign-in is passkeys. | ## How a push and a workflow run move through it 1. `git push` reaches the Worker, which checks your token and your role on the repository and passes the push to the repository's Durable Object. 2. The object scans every new file for leaked credentials ([push protection](https://diffinery.com/docs/security/push-protection)), stores the objects (small ones in its SQLite database, large ones in R2), checks branch protection and moves each branch with a compare-and-swap, so two pushes to the same branch are applied one after the other. 3. It reads `.github/workflows/*.yml` from the pushed commit and starts a Cloudflare Workflow for each run that matches. 4. The Workflow plans the job graph and starts each job as soon as the jobs it `needs` have finished. For each job, the repository's object starts a fresh Cloudflare Container. 5. The runner in the container fetches its job, checks out the code and runs the steps. Logs stream to the repository's object and on to any browser watching; caches and artifacts go to R2. 6. When the job ends the container stops, and the Workflow starts whatever jobs were waiting on it. ## What this means for you - **No servers to run.** Your instance is a Worker and the Cloudflare products it binds to, so there is nothing to provision, patch or scale: no CI machines, no runner fleet and no database. Workflow files, `gh` and your editor work as they do with GitHub. - **Jobs start in seconds.** A fresh container per job starts in about 3 to 5 seconds, and the first job runs 6 to 8 seconds after `git push` returns (measured on October 16, 2026; see [Performance](https://diffinery.com/performance)). - **Runners exist only while a job runs.** Diffinery starts a container when a job starts and stops it when the job ends, so no runner sits idle between jobs. Cloudflare bills a container by the 10 ms while it runs: its memory and disk for what its instance type provides, and its CPU by use ([Cloudflare Containers pricing](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). That's why Diffinery picks a smaller runner for jobs that mostly wait, when their own measured runs show it serves them just as well ([Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes)). Over 24 hours on October 16, 2026, runner containers ran 6% longer than the jobs in them, for starting and stopping. Diffinery itself is free; what the containers use is billed to the Cloudflare account your instance runs in. - **One busy repository never slows another.** Each repository is its own Durable Object with its own database, the single writer for everything in it, so ref updates, merges and run state are strongly consistent without locks. - **Runs survive restarts.** A workflow run is a durable Workflow, so a deploy or a restart doesn't lose it. A deploy is set up not to replace the container of a running job, so jobs normally finish on the version they started with. - **Cloudflare's network.** Requests are answered by the Worker at the Cloudflare location that receives them, and hosted jobs run in Cloudflare locations around the world: a 40-job probe on October 16, 2026 landed in North America, Europe and Japan. Each job's log names where it ran. A repository's own data lives in its Durable Object, in one place, so pages that read a repository make a trip to wherever that object is. ## Diffinery and Cloudflare Workers Builds Cloudflare has its own build service, Workers Builds. In Cloudflare's words, it lets you "integrate with Git and automatically build and deploy your Worker when pushing a change", for a Worker connected to "a repository hosted by Artifacts, GitHub, or GitLab". A build runs an optional build command and then a deploy command, which defaults to `npx wrangler deploy`; builds of the production branch create a new version of the Worker, and other branches get a Preview ([Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) and [its configuration](https://developers.cloudflare.com/workers/ci-cd/builds/configuration/), checked October 25, 2026). Diffinery does a different job: | | Diffinery | Cloudflare Workers Builds | | --- | --- | --- | | What it is | A git forge with pull requests, issues, releases, a container registry and GitHub Actions–compatible CI | A build and deploy service for Workers projects | | Where your code lives | On your Diffinery instance, in your own Cloudflare account | Artifacts, GitHub or GitLab | | What it runs | Any GitHub Actions workflow: tests, matrices, Docker, service containers, any language, on push, pull request, schedule and other triggers | A build command and a deploy command for a Worker | | What it deploys | Whatever your workflow deploys, including Workers with `wrangler deploy` | The connected Worker | They work together. A Diffinery repository doesn't appear in Workers Builds's list of repositories, so [Deploy to Cloudflare Workers](https://diffinery.com/docs/actions/deploy-to-cloudflare-workers) shows two ways to deploy automatically: a Diffinery Actions job that runs `wrangler deploy` on every push to `main` and gives each pull request its own Worker Preview (this is how Diffinery deploys itself), or a mirror of the branch to Cloudflare Artifacts for Workers Builds to build. ## Other Git and CI projects on Cloudflare Cloudflare has made Git on its network a theme in 2026, and Diffinery isn't the only open-source project there. Here is what we found when we checked each one's own documentation and code on October 25, 2026, and how each differs: | | Git hosting | Pull requests | CI on Cloudflare | Runs GitHub Actions workflows | | --- | --- | --- | --- | --- | | Diffinery (open source, Apache-2.0) | Yes, in your own Cloudflare account; no hosted service ([source](https://diffinery.com/codician/diffinery)) | Yes, with reviews and a merge queue | Yes, Cloudflare Containers | Yes, marketplace actions included ([differences](https://diffinery.com/docs/actions/compatibility)) | | Cloudflare Artifacts with Cloudflare's CI SDK | Yes: Git-compatible repositories on Cloudflare ([source](https://developers.cloudflare.com/artifacts/)) | Not offered | Yes: pipelines written in TypeScript, run as Workflows in sandboxes when a repository is pushed ([source](https://blog.cloudflare.com/ci-workflows/), August 4, 2026) | No: TypeScript instead of YAML workflows ([source](https://blog.cloudflare.com/ci-workflows/)) | | Flare Forge (open source, hosted demo) | Yes, on Artifacts ([source](https://github.com/everyai-com/flare-forge)) | Replaced by "intents" ([source](https://github.com/everyai-com/flare-forge)) | Yes, Cloudflare Containers ([source](https://github.com/everyai-com/flare-forge)) | Partly: workflow files are translated to its own format, and JavaScript and container `uses:` steps are dropped with a warning ([source](https://github.com/everyai-com/flare-forge/blob/main/apps/worker/src/actionsCompat.ts)) | | git-forge by levino (open source, MIT) | Yes, in your own Cloudflare account; no hosted service ([source](https://github.com/levino/git-forge)) | Listed, with "the web UI and collaboration features are next" ([source](https://github.com/levino/git-forge)) | Yes, Cloudflare Containers since October 18, 2026, when its "CI runners on Cloudflare Containers" pull request was merged ([source](https://github.com/levino/git-forge/pull/61)); its README still says "not yet deployed" ([source](https://github.com/levino/git-forge)) | Yes, real workflows with the official `actions/runner` ([source](https://github.com/levino/git-forge)) | Projects that only run GitHub's self-hosted runner in Cloudflare Containers keep the code and the pull requests on GitHub, so they aren't on this list. We re-check these projects when we update this page, and change the comparison when one of them changes. ## Deploying to Cloudflare from Diffinery CI The recommended setup is a workflow like any GitHub Actions deploy: store a Cloudflare API token and your account ID as repository secrets, then run Wrangler in a job. ```yaml jobs: deploy: if: github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} ``` The full guide, with Previews per pull request and keeping the production token in an environment, is [Deploy to Cloudflare Workers](https://diffinery.com/docs/actions/deploy-to-cloudflare-workers). For clouds that accept OpenID Connect, jobs can use short-lived [OIDC](https://diffinery.com/docs/actions/oidc) tokens instead of stored keys. ## Limits that come with building on Cloudflare - **Hosted runners are Linux x64 only.** Cloudflare Containers run Linux, so there are no hosted macOS or Windows runners, and every `ubuntu-*` label, including `ubuntu-24.04-arm`, gets the same Ubuntu 24.04 x64 image. Use [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners) for macOS, Windows, Arm and GPUs. - **At most 4 vCPUs and 12 GiB per hosted job.** The largest runner is Cloudflare's `standard-4` instance type; there are no larger hosted sizes. - **Hosts span CPU generations.** Cloudflare's hosts mix AMD EPYC generations whose cores differ about 2x in speed, and nobody picks which one a job lands on. The same job can run up to about twice as fast on one host as on another; each job's log names the host's processor, and the ranges on [Performance](https://diffinery.com/performance) include the slow hosts. - **Jobs sometimes wait for a machine.** When many jobs start at once, a container occasionally isn't placed or stops before its runner starts. Diffinery puts the job back in line and tries again on its own; it usually costs the job up to a minute or two. See [Runners troubleshooting](https://diffinery.com/docs/actions/runners#troubleshooting). - **A push carries at most 100 MB.** Use [Git LFS](https://diffinery.com/docs/repositories/git-lfs) for large files. Container image layers larger than Cloudflare's 100 MB request limit push from hosted runners through a relay that splits them, but not from self-hosted runners. - **Clones send more bytes than GitHub's.** Packs are sent without deltas, so clones and fetches download more data. See [Git over HTTPS](https://diffinery.com/docs/repositories/git#limits-and-differences-from-github). - **20 hosted jobs at once per account and 80 per instance, by default.** Further jobs wait their turn. An instance's administrators set these limits (`HOSTED_JOBS_LIMIT` and the `QUOTA_*` variables in [Configuration](https://diffinery.com/docs/self-hosting/configuration)). See [Limits](https://diffinery.com/docs/reference/limits). ## Frequently asked questions ### Is Diffinery open source? Yes. Diffinery is open source under the Apache-2.0 license, and nothing about it is sold. It is developed at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery) and mirrored on GitHub at [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). You run it by deploying your own instance to your own Cloudflare account; see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). ### What does it cost to run Diffinery? Diffinery itself is free. You pay Cloudflare for what your instance uses: it needs the Workers Paid plan, "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026), and runner containers are billed by the 10 ms while a job runs ([Containers pricing](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). ### Does Diffinery run on Cloudflare? Yes, entirely. A Cloudflare Worker serves the web UI, Git, the REST API and MCP; each repository is a Durable Object with its own SQLite database; large files live in R2 and accounts in D1; workflow runs are Cloudflare Workflows; and every workflow job runs in its own Cloudflare Container. See [Architecture](https://diffinery.com/docs/self-hosting/architecture). ### Is Diffinery the only Git and CI platform on Cloudflare? No. Other open-source projects run Git and CI on Cloudflare too; here's how they differ (checked October 25, 2026). Cloudflare Artifacts with Cloudflare's CI SDK runs TypeScript pipelines rather than GitHub Actions workflows and has no pull requests; Flare Forge uses "intents" instead of pull requests and drops JavaScript and container `uses:` steps from workflow files; and git-forge (MIT) runs real workflows with the official `actions/runner` on Cloudflare Containers since October 18, 2026, while its README says "the web UI and collaboration features are next". Diffinery has all three today: Git hosting, pull requests with reviews in a web UI, and GitHub Actions–compatible CI with marketplace actions. See [Other Git and CI projects on Cloudflare](https://diffinery.com/built-on-cloudflare#other-git-and-ci-projects-on-cloudflare). ### Is Diffinery the same as Cloudflare Workers Builds? No. Workers Builds builds and deploys a Worker from a repository on Artifacts, GitHub or GitLab. Diffinery hosts the repository itself, with pull requests and issues, and runs any GitHub Actions workflow, which can deploy a Worker among other things. ### Can I deploy to Cloudflare Workers from Diffinery? Yes. A workflow job runs `wrangler deploy` with a Cloudflare API token stored as a secret, and can give each pull request its own Worker Preview. See [Deploy to Cloudflare Workers](https://diffinery.com/docs/actions/deploy-to-cloudflare-workers). ### Does Docker work on runners in Cloudflare Containers? Yes. Job containers, service containers, Docker actions, `docker build` and `docker compose` work as on GitHub's runners. The Docker daemon starts the first time a job uses it, or as the job begins when the workflow uses Docker. See [Docker in workflows](https://diffinery.com/docs/actions/docker). --- # GitHub and GitHub Actions alternatives, compared > How Diffinery compares with GitHub, GitLab, Bitbucket, Gitea, Forgejo, CircleCI, Buildkite and GitHub Actions runner services such as Blacksmith and Depot. Source: https://diffinery.com/compare (last updated 2026-10-25) Teams that want faster or cheaper CI than GitHub Actions have three kinds of options: keep GitHub and run jobs on another company's runners, keep their Git host and move CI to a separate CI service such as CircleCI or Buildkite, or move to a forge that hosts the code and runs the CI. Diffinery is the last kind: a git forge that runs GitHub Actions workflows as they are, on fast runners, with AI agents as accounts held to guardrails. It is free, open-source software (Apache-2.0) that you deploy to your own Cloudflare account and domain; there is no hosted service to sign up for. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery), mirrored [on GitHub](https://github.com/codician-team/diffinery). Diffinery is an open-source platform where Git hosting, pull requests with reviews in a web UI and GitHub Actions–compatible CI, marketplace actions included, all run on Cloudflare (checked October 25, 2026; see [Built on Cloudflare](https://diffinery.com/built-on-cloudflare#other-git-and-ci-projects-on-cloudflare)). Every fact about another product on these pages comes from that product's own website or documentation, linked and dated. Diffinery's numbers are measured on the runners of diffinery.com, the instance Diffinery is developed on; see [Performance](https://diffinery.com/performance) and [how we check what we publish](https://diffinery.com/about#how-we-check-what-we-publish). ## The detailed comparisons - [Diffinery vs GitHub](https://diffinery.com/compare/github): code hosting, pull requests, GitHub Actions, AI agents and security. - [Diffinery vs GitLab](https://diffinery.com/compare/gitlab): CI format, hosted runners, compute minutes and AI features. - [Diffinery vs Blacksmith](https://diffinery.com/compare/blacksmith): a GitHub Actions runner service, against a forge with runners built in. - [Diffinery vs Depot](https://diffinery.com/compare/depot): runners, Depot CI and container builds, against a forge with CI built in. - [Diffinery vs CircleCI](https://diffinery.com/compare/circleci): a CI service for code on GitHub, GitLab or Bitbucket, against a forge with CI built in. - [Diffinery vs Buildkite](https://diffinery.com/compare/buildkite): hybrid CI on agents you run or Buildkite hosts, against hosted runners built into a forge. - [Diffinery vs Bitbucket](https://diffinery.com/compare/bitbucket): Bitbucket Cloud and Bitbucket Pipelines, build minutes, runner sizes and Jira. - [Diffinery vs Gitea and Forgejo](https://diffinery.com/compare/gitea): self-hosted open-source forges with Actions-style CI, against an open-source forge that runs on Cloudflare. ## Two ways to speed up GitHub Actions | | Keep GitHub, change runners | Move to Diffinery | | --- | --- | --- | | What changes | The `runs-on` label of each job, and a GitHub App that gives the vendor access | Where your repositories live: a Diffinery instance you deploy to your own Cloudflare account; workflow files stay as they are | | Who runs it | GitHub and the runner service run everything for you | You do, on your own Cloudflare account; there is no hosted service | | Cost | GitHub's plans plus the runner service's prices | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage | | Pull requests and reviews | GitHub's | Diffinery's | | macOS and Windows | Offered by most runner services | Self-hosted runners only | | AI agents | Whatever GitHub offers | Agent accounts, server-enforced guardrails, MCP, sandboxes, tasks and review | ## GitHub Actions runner services at a glance Published list prices for a Linux x64 runner with 2 vCPUs, and what each service runs on. Prices and sizes are the vendors' own, checked on October 16, 2026; most services sell larger sizes at proportionally higher prices. | Service | What it is | Linux x64, 2 vCPU | Other platforms | Hosts your code | | --- | --- | --- | --- | --- | | GitHub-hosted runners | GitHub's own runners | $0.006/min, each job rounded up to the minute ([source](https://docs.github.com/en/billing/reference/actions-runner-pricing)) | Arm, Windows, macOS | Yes (GitHub) | | [Blacksmith](https://diffinery.com/compare/blacksmith) | Managed runners in Firecracker microVMs on gaming-class CPUs | $0.004/min; 3,000 free minutes a month per organization ([source](https://www.blacksmith.sh/pricing)) | Arm, Windows (beta), macOS | No | | [Depot](https://diffinery.com/compare/depot) | Managed runners on AWS, plus Depot CI and container builds | $0.006/min, tracked per second; plans from $20 a month ([source](https://depot.dev/pricing)) | Arm, Windows, macOS | Depot Code, in beta | | Namespace | Managed runners configured through profiles | $0.004/min prepaid for 2 vCPU with 8 GB ($0.002 with 4 GB); plans from $0 ([source](https://namespace.so/pricing)) | Arm, Windows, macOS | No | | WarpBuild | Managed runners, or runners in your own cloud account | $0.004/min ([source](https://www.warpbuild.com/pricing)) | Arm, Windows (from 4 vCPU), macOS | No | | RunsOn | Runners in your own AWS account, under an annual license | Your AWS bill; RunsOn estimates about $0.0009/min on spot ([source](https://runs-on.com/pricing/)) | Arm, Windows, GPUs; no macOS | No | | Ubicloud | Managed runners on Ubicloud's open-source cloud in Germany | $0.002/min premium (default), $0.00125/min standard ([source](https://www.ubicloud.com/docs/about/pricing)) | Arm | No | | **Diffinery** | An open-source git forge whose CI runs GitHub Actions workflows, deployed to your own Cloudflare account | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage. The default runner has 4 vCPU and 12 GiB | Self-hosted runners | Yes (your instance) | BuildJet, another runner service, has shut down ([source](https://buildjet.com/for-github-actions)). Diffinery has no per-minute price of its own. Your instance needs Cloudflare's Workers Paid plan, "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026), and its runner containers are billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). The runner services above run everything for you; with Diffinery, you run the instance. ## CI platforms that aren't GitHub runner services Some teams leave GitHub Actions for another CI system, either a separate service next to their Git host or the CI built into another forge. Facts are the vendors' own, checked on October 25, 2026; the linked comparisons have the details and sources. | Platform | What it is | Pipeline format | Runs GitHub Actions workflow files | Hosts your code | Hosted Linux x64, 2 vCPU | | --- | --- | --- | --- | --- | --- | | [CircleCI](https://diffinery.com/compare/circleci) | A CI service for code on GitHub, GitLab or Bitbucket, with a self-hosted edition | `.circleci/config.yml` | In preview: GitHub repositories, Linux only ([source](https://circleci.com/docs/guides/orchestrate/use-a-github-actions-workflow-file-as-your-pipeline-configuration/)) | No | $0.006/min (10 credits at $0.0006) for Docker Medium with 4 GB ([source](https://circleci.com/pricing/price-list/)) | | [Buildkite](https://diffinery.com/compare/buildkite) | A hosted control plane, with jobs on agents you run or Buildkite hosts | `.buildkite/pipeline.yml`, which can be generated during a build | In public preview ([source](https://buildkite.com/docs/pipelines/migration/run-github-actions-workflows)) | No | $0.008/min for Small with 4 GB ([source](https://buildkite.com/pricing/)) | | [Bitbucket Pipelines](https://diffinery.com/compare/bitbucket) | CI built into Atlassian's Bitbucket Cloud | `bitbucket-pipelines.yml` | No | Yes (Bitbucket) | Counted in build minutes: 2,500 a month on Standard, then $10 per 1,000 ([source](https://www.atlassian.com/licensing/bitbucket)) | | [GitLab CI](https://diffinery.com/compare/gitlab) | CI built into GitLab | `.gitlab-ci.yml` | No; GitLab documents rewriting workflows as `.gitlab-ci.yml` ([source](https://docs.gitlab.com/ci/migration/github_actions/)) | Yes (GitLab) | Counted in compute minutes: 400 a month on Free, then $10 per 1,000 ([source](https://about.gitlab.com/pricing/)) | | [Gitea Actions](https://diffinery.com/compare/gitea) | CI built into Gitea, a self-hosted open-source forge | GitHub Actions–style YAML in `.gitea/workflows/` | "Mostly compatible", in Gitea's words ([source](https://docs.gitea.com/usage/actions/overview)) | Yes (your server, or Gitea Cloud) | Runners you run; Gitea Cloud starts them for you | | [Forgejo Actions](https://diffinery.com/compare/gitea) | CI built into Forgejo, a self-hosted open-source forge | GitHub Actions–style YAML in `.forgejo/workflows/` | "Not designed to be compatible", in Forgejo's words, though familiar ([source](https://forgejo.org/docs/latest/user/actions/overview/)) | Yes (your server, or Codeberg for free software) | Runners you run | | **Diffinery** | An open-source git forge whose CI runs GitHub Actions workflows, deployed to your own Cloudflare account | GitHub Actions YAML in `.github/workflows/` | Yes, with every difference listed in [Compatibility](https://diffinery.com/docs/actions/compatibility) | Yes (your instance) | Runners in Cloudflare Containers that your instance starts per job; you pay Cloudflare for their usage. The default runner has 4 vCPU and 12 GiB | ## Forges at a glance | | Diffinery | GitHub | GitLab.com | | --- | --- | --- | --- | | Who runs it | You, on your own Cloudflare account | GitHub; GitHub Enterprise Server runs on your own servers | GitLab; GitLab also has a self-managed edition | | CI format | GitHub Actions YAML | GitHub Actions YAML | `.gitlab-ci.yml` | | Default hosted Linux runner | 4 vCPU, 12 GiB | 2 vCPU, 8 GB on private repositories; 4 vCPU, 16 GB on public ones | 2 vCPU, 8 GB | | Hosted macOS and Windows | No | Yes | Beta | | Guardrails for AI agents | Built in for every agent account | Rulesets and token permissions you configure | Protected branches, approval rules and service accounts you configure | | Built-in MCP server | Yes | Yes | Yes | | Push protection for every repository by default | Yes | Public repositories; private ones with a paid add-on | Ultimate tier | | Forks | No | Yes | Yes | | Price | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage | Free, Team $4, Enterprise from $21 per user per month | Free, Premium $29, Ultimate on request | Sources: [GitHub vs Diffinery](https://diffinery.com/compare/github) and [GitLab vs Diffinery](https://diffinery.com/compare/gitlab), where every row is linked to the vendor's page. ## Frequently asked questions ### What's the best alternative to GitHub Actions? It depends on what you want to keep. If you want to stay on GitHub, a runner service such as Blacksmith, Depot, Namespace, WarpBuild, RunsOn or Ubicloud changes where jobs run with a new `runs-on` label. If you want faster CI and are willing to move your repositories and run your own instance on Cloudflare, Diffinery runs the same workflow files on 4-vCPU runners and also hosts your code, pull requests and AI agents. If you'd rather not run anything yourself, a runner service is the simpler choice. ### Is there a GitHub alternative that runs GitHub Actions workflows? Yes. Diffinery reads `.github/workflows/*.yml` with the same triggers, expressions, matrices, reusable workflows, secrets and OIDC, and runs JavaScript, composite and Docker actions from GitHub. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ### Which CI platforms can run GitHub Actions workflow files outside GitHub? Diffinery runs them as its main format, on its own repositories. CircleCI and Buildkite both offer it in preview, with limits listed in their documentation, for code that stays on GitHub. Gitea Actions describes itself as "mostly compatible" with GitHub Actions, and Forgejo Actions as familiar but "not designed to be compatible"; both run on runners you set up. See the table above and the detailed comparisons for the sources. ### Which platform is built for AI coding agents? Diffinery treats agents as accounts of their own, with guardrails the server enforces: no pushes to protected branches, a person's approval on every agent pull request, and no secrets for agent-written code. See [Diffinery for AI agents](https://diffinery.com/agents). ### How much does Diffinery cost? Diffinery itself is free and open source under the Apache-2.0 license. You deploy it to your own Cloudflare account and pay Cloudflare for what your instance uses: the Workers Paid plan, "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026), and runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). There are no plans or paid tiers. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). ### Does everyone on my team need a Cloudflare account? No. Only the deployment needs one. People who use your instance need only an account on that instance, and sign in with a passkey. --- # Diffinery vs GitHub > How Diffinery compares with GitHub and GitHub Actions on CI speed, runners, workflow compatibility, AI agents, security and features, with sources and dates. Source: https://diffinery.com/compare/github (last updated 2026-10-25) Diffinery is a git forge and CI platform that runs GitHub Actions workflows as they are. If you're on GitHub today, this page sets out what changes if you move, what gets better, and what GitHub still does that Diffinery doesn't. GitHub facts are from GitHub's own documentation and pricing pages, checked on October 16, 2026, and linked below. Diffinery's speed figures are measured; see [Performance](https://diffinery.com/performance). Diffinery is free, open-source software (Apache-2.0) that you deploy to your own Cloudflare account and domain; there is no hosted service, so moving from GitHub also means running your own instance. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery), mirrored [on GitHub](https://github.com/codician-team/diffinery). Diffinery is an open-source platform where Git hosting, pull requests with reviews in a web UI and GitHub Actions–compatible CI, marketplace actions included, all run on Cloudflare (checked October 25, 2026; see [Built on Cloudflare](https://diffinery.com/built-on-cloudflare#other-git-and-ci-projects-on-cloudflare)). ## In short - **Choose Diffinery** if your team runs CI on private repositories and wants faster runners by default, uses AI coding agents and wants the server to enforce limits on them, works on Linux, and is happy to run its own instance on Cloudflare. - **Stay on GitHub** if you need hosted macOS, Windows or Arm runners, forks for outside contributors, GitHub's app marketplace, Codespaces, Pages or Discussions, if you're an open-source project that depends on GitHub's network, or if you'd rather not run anything yourself: GitHub runs github.com for you, while a Diffinery instance is yours to deploy, update and pay Cloudflare for. - **Moving is mostly mechanical.** Diffinery's importer copies repositories with history, issues, pull requests, reviews and releases, workflow files run unchanged, and `gh`, VS Code, Cursor and JetBrains IDEs keep working. ## CI and runners | | Diffinery | GitHub Actions | | --- | --- | --- | | Workflow syntax | GitHub Actions YAML, read from `.github/workflows/` as is. Differences are listed in [Compatibility](https://diffinery.com/docs/actions/compatibility). | GitHub Actions YAML | | Default Linux runner, private repositories | 4 vCPU, 12 GiB memory, 20 GB disk | 2 vCPU, 8 GB memory, 14 GB SSD ([source](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)) | | Default Linux runner, public repositories | 4 vCPU, 12 GiB memory, 20 GB disk | 4 vCPU, 16 GB memory, 14 GB SSD ([source](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)) | | Smaller runners | 2, 1/2 and 1/4 vCPU, picked per job from its measured runs, or by label | 1 vCPU `ubuntu-slim`, with a 15-minute job limit ([source](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)) | | Larger runners | Not available above 4 vCPU | Linux up to 96 vCPU, on the Team and Enterprise Cloud plans, billed per minute ([source](https://docs.github.com/en/actions/reference/runners/larger-runners)) | | Hosted macOS, Windows, Arm, GPU | Not available; use self-hosted runners | Available | | Job start | About 3 to 5 s (measured) | No published figure | | Common actions | `checkout`, `setup-node`, `setup-python`, `setup-go`, `cache` and artifact actions built into the runner | Downloaded and run per job | | Preinstalled toolchains | A small image: Node.js, Python, Docker, gh, build tools. Go and Java install while checkout runs (1 to 5 s). | A large image with most toolchains preinstalled | | Self-hosted runners | `diffinery runner`, on any machine | GitHub's runner application | | Max job time | 6 hours | 6 hours ([source](https://docs.github.com/en/actions/reference/limits)) | | Concurrent hosted jobs | 20 per account by default; your instance's administrators set it ([Configuration](https://diffinery.com/docs/self-hosting/configuration)) | 20 on Free, 40 on Pro, 60 on Team, 500 on Enterprise ([source](https://docs.github.com/en/actions/reference/limits)) | | Linux runner price | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage, with runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026) | $0.006 per minute for 2-core, $0.012 for 4-core, each job rounded up to the whole minute ([source](https://docs.github.com/en/billing/reference/actions-runner-pricing)) | GitHub cut its hosted runner prices by up to 39% on January 1, 2026 ([changelog](https://github.blog/changelog/2026-01-01-reduced-pricing-for-github-hosted-runners-usage/)); the figures above are the current ones. ## Code hosting and collaboration | | Diffinery | GitHub | | --- | --- | --- | | Who runs it | You, on your own Cloudflare account and domain ([guide](https://diffinery.com/docs/self-hosting/deploy)) | GitHub runs github.com; GitHub Enterprise Server runs on your own servers | | Git over HTTPS | Yes | Yes | | Git over SSH | No | Yes | | Git LFS | Yes | Yes | | Pull requests, reviews, required approvals and checks | Yes, plus an `intent` field saying what a change is for | Yes | | Suggested changes, pending reviews, resolving threads | Not yet | Yes | | Forks | No: pull requests are between branches of one repository | Yes | | Auto-merge and merge queue | Yes, with GitHub's `merge_group` event | Yes | | Issues, labels, milestones, releases | Yes | Yes | | Projects, Discussions, wikis, Pages, Codespaces | No | Yes | | Container registry | Yes, at `//` | Yes, ghcr.io | | REST API | GitHub-style at `/api/v3`, with a GraphQL endpoint for `gh` | Yes | | `gh` CLI | Works, as with GitHub Enterprise Server | Yes | | App marketplace and GitHub Apps | No | Yes | ## AI agents | | Diffinery | GitHub | | --- | --- | --- | | Agent identity | An agent account of its own, owned by a person or organization, with its own tokens | Usually a person's token, a GitHub App or a bot account | | Guardrails enforced by the server | Agents can't push to protected branches, their pull requests need a person's approval, their checks never satisfy required checks, and runs of their code get no secrets | Configured per repository with rulesets and permissions | | MCP server | At `/mcp` on your instance, with browser sign-in | The GitHub MCP Server, generally available since September 2025 ([source](https://github.blog/changelog/2025-09-04-remote-github-mcp-server-is-now-generally-available/)) | | Agent that turns an issue or goal into a pull request | [Agent tasks](https://diffinery.com/docs/agents/tasks) run Claude Code, Codex or your own command with your own model key | Copilot cloud agent, on paid Copilot plans ([source](https://docs.github.com/en/copilot/concepts/enterprise/cloud-agent-access)) | | AI pull request review | [Agent review](https://diffinery.com/docs/agents/review) with your own model key | Copilot code review, on paid Copilot plans ([source](https://docs.github.com/en/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/code-review)) | | Sandboxes for agents | Yes, over MCP and the REST API | Codespaces, and the Copilot agent's own environment | ## Security | | Diffinery | GitHub | | --- | --- | --- | | Sign-in | Passkeys only | A password with two-factor authentication (required for everyone who contributes code since 2023), or a passkey ([source](https://docs.github.com/en/authentication/authenticating-with-a-passkey/about-passkeys)) | | Push protection for leaked credentials | On for every repository, private ones included, for every push and every commit made through the API or MCP | On by default for pushes to public repositories; for private repositories it needs GitHub Secret Protection, a paid add-on ([source](https://docs.github.com/en/code-security/concepts/secret-security/push-protection)) | | Secrets for workflow runs of agent-written code | Never given | Depends on your configuration | | Audit log | Yes, append-only, for every account | Organization owners can review it; the audit log API and streaming need Enterprise Cloud ([source](https://docs.github.com/en/enterprise-cloud@latest/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization)) | ## Moving from GitHub First, deploy your own instance to your Cloudflare account; see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). Then: 1. Import a repository or a whole organization from **Settings → Imports**, or with `diffinery migrate github`. History, issues, pull requests, reviews, comments, labels, milestones, releases, branch protection, variables and secret names come across, with their numbers. 2. Enter each secret's value (GitHub never reveals them, so no tool can copy them). 3. Point your clones at Diffinery and sign `gh` in with `diffinery auth setup-gh`. 4. Push. Your workflows run on your instance's runners. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github) for the full guide. ## Frequently asked questions ### Do GitHub Actions workflows run on Diffinery without changes? Most do. Diffinery reads `.github/workflows/*.yml` with the same triggers, expressions, matrices, reusable workflows, secrets, OIDC and Docker support, and runs JavaScript, composite and Docker actions from GitHub. Jobs that need hosted macOS, Windows or Arm runners need a self-hosted runner instead. Every difference is listed in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ### Is Diffinery faster than GitHub Actions? For CPU-heavy jobs on private repositories, usually yes: Diffinery's default runner has 4 vCPUs where GitHub's standard private-repository runner has 2, and jobs start in about 3 to 5 seconds. Setup steps such as checkout and cache restores are at or near parity. GitHub is still up to about 4 seconds faster at setting up Go and Java, which it preinstalls. See [Performance](https://diffinery.com/performance) for the measurements. ### Can I keep using the GitHub CLI and my editor? Yes. `gh` works with Diffinery as it does with GitHub Enterprise Server, VS Code, Cursor and Windsurf can clone from Diffinery, and JetBrains IDEs get a plugin. ### Can I keep a copy on GitHub? Yes. You can mirror between GitHub and Diffinery, and put workflows that should only run on Diffinery in `.diffinery/workflows/`. See [Keep GitHub as upstream](https://diffinery.com/docs/migrate-from-github#keep-github-as-upstream-and-mirror-to-diffinery). ### How much does Diffinery cost? Diffinery itself is free and open source under the Apache-2.0 license; there are no plans or paid tiers. You deploy it to your own Cloudflare account and pay Cloudflare for what your instance uses: the Workers Paid plan, "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026), and runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). --- # Diffinery vs GitLab > How Diffinery compares with GitLab.com on CI compatibility, hosted runners, compute minutes, AI agents and features, with sources and dates. Source: https://diffinery.com/compare/gitlab (last updated 2026-10-25) GitLab is a complete DevSecOps platform with its own CI system. Diffinery is a smaller, GitHub-compatible forge focused on fast CI and on AI agents as first-class collaborators. It is free, open-source software (Apache-2.0) that you deploy to your own Cloudflare account; there is no hosted service (see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery)). GitLab facts here are from GitLab's own documentation and pricing page, checked on October 16, 2026, and linked below. ## In short - **The biggest difference is the CI format.** GitLab CI uses `.gitlab-ci.yml`. Diffinery runs GitHub Actions workflows (`.github/workflows/*.yml`) as they are, so it suits teams coming from GitHub, or teams that want the GitHub Actions ecosystem of reusable actions. - **Choose GitLab** if you need its breadth: built-in security scanning, planning and portfolio tools, a self-managed edition on your own servers, hosted runners up to 32 vCPUs, hosted Windows and macOS runners (in beta), or a service that GitLab runs for you, with nothing to deploy. - **Choose Diffinery** if you want GitHub Actions compatibility, 4-vCPU runners by default, a forge built around AI agents with server-enforced guardrails, and open-source software that runs on your own Cloudflare account, with no servers or virtual machines to manage. ## CI and runners | | Diffinery | GitLab.com | | --- | --- | --- | | Pipeline format | GitHub Actions YAML | `.gitlab-ci.yml` | | Default hosted Linux runner | 4 vCPU, 12 GiB memory, 20 GB disk | small: 2 vCPU, 8 GB memory, 30 GB disk ([source](https://docs.gitlab.com/ci/runners/hosted_runners/linux/)) | | Other hosted Linux sizes | 2, 1/2 and 1/4 vCPU, picked per job from its measured runs | medium (4 vCPU) on every tier; large (8), xlarge (16) and 2xlarge (32) on Premium and Ultimate ([source](https://docs.gitlab.com/ci/runners/hosted_runners/linux/)) | | How bigger runners are counted | Each size runs on a Cloudflare Containers instance type, and Cloudflare bills your account by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026) | Each minute on medium counts as 2 compute minutes, large 3, xlarge 6, 2xlarge 12 ([source](https://docs.gitlab.com/ci/pipelines/compute_minutes/)) | | Hosted macOS and Windows | Not available; use self-hosted runners | Both in beta: Windows on every tier, macOS on Premium and Ultimate ([macOS](https://docs.gitlab.com/ci/runners/hosted_runners/macos/), [Windows](https://docs.gitlab.com/ci/runners/hosted_runners/windows/)) | | Included CI minutes | Nothing to buy. By default each account gets 3,000 hosted minutes a month, a quota your instance's administrators set ([Configuration](https://diffinery.com/docs/self-hosting/configuration)) | 400 compute minutes a month on Free, 10,000 on Premium, 50,000 on Ultimate ([source](https://about.gitlab.com/pricing/)) | | Extra minutes | None to buy; you pay Cloudflare for your instance's usage | $10 per 1,000 compute minutes ([source](https://about.gitlab.com/pricing/)) | | Self-hosted runners | `diffinery runner` | GitLab Runner | ## Price and hosting | | Diffinery | GitLab.com | | --- | --- | --- | | Who runs it | You, on your own Cloudflare account and domain ([guide](https://diffinery.com/docs/self-hosting/deploy)) | GitLab runs GitLab.com; GitLab also has a self-managed edition | | Price | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage | Free $0 (up to 5 users per private top-level group), Premium $29 per user per month billed annually, Ultimate on request ([source](https://about.gitlab.com/pricing/)) | | Secret push protection | On for every repository | Ultimate tier ([source](https://docs.gitlab.com/user/application_security/secret_detection/secret_push_protection/)) | ## AI agents | | Diffinery | GitLab | | --- | --- | --- | | Accounts for agents and automation | Agent accounts with their own tokens, owned by a person or organization | Service accounts, on every tier ([source](https://docs.gitlab.com/user/profile/service_accounts/)) | | Server-enforced agent guardrails | No pushes to protected branches, a person approves every agent pull request, agent checks never satisfy required checks, no secrets for agent-written code | Configured with protected branches and approval rules | | MCP server | Built in, at `/mcp` on your instance | Built in, at `/api/v4/mcp`, on every tier ([source](https://docs.gitlab.com/user/model_context_protocol/mcp_server/)) | | AI features | [Agent tasks](https://diffinery.com/docs/agents/tasks) and [agent review](https://diffinery.com/docs/agents/review) run Claude Code, Codex or your own command with your own model key | GitLab Duo add-ons for Premium and Ultimate, and GitLab Credits ([source](https://docs.gitlab.com/subscriptions/subscription-add-ons/)) | ## Moving from GitLab Deploy your own instance first ([guide](https://diffinery.com/docs/self-hosting/deploy)). Diffinery's importer reads from GitHub. From GitLab, push your repositories with plain Git (`git push --mirror`), then rewrite pipelines as GitHub Actions workflows. See [Move with plain Git](https://diffinery.com/docs/migrate-from-github#move-with-plain-git). ## Frequently asked questions ### Does Diffinery run .gitlab-ci.yml? No. Diffinery runs GitHub Actions workflows. Teams moving from GitLab rewrite their pipelines as workflows in `.github/workflows/`. ### Is Diffinery a full GitLab replacement? Not for every team. Diffinery covers Git hosting, pull requests, issues, releases, CI, a container registry and AI agents. It doesn't have GitLab's built-in security scanners, planning and portfolio tools, larger hosted runners or hosted Windows and macOS runners. And GitLab.com is a service GitLab runs for you, where Diffinery is software you run on your own Cloudflare account. --- # Diffinery vs Blacksmith > Blacksmith speeds up GitHub Actions on its own runners while you stay on GitHub. Diffinery replaces GitHub and Actions together. Specs, prices, trade-offs. Source: https://diffinery.com/compare/blacksmith (last updated 2026-10-25) Blacksmith and Diffinery both make GitHub Actions workflows run faster, in different ways. Blacksmith sells drop-in runners for GitHub Actions: your code, pull requests and workflows stay on GitHub, and jobs run on Blacksmith's machines. Diffinery replaces GitHub and GitHub Actions together, with fast runners built in. Blacksmith is a service that runs the runners for you; Diffinery is free, open-source software (Apache-2.0) that you deploy to your own Cloudflare account, with no hosted service (see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery)). Blacksmith facts here are from its own website and documentation, checked on October 16, 2026, and linked below. ## In short - **Choose Blacksmith** if you want to stay on GitHub and only want faster or cheaper CI. It needs a GitHub App and a new `runs-on` label, and offers Linux, Arm, Windows and macOS runners up to 32 vCPUs, run for you with nothing to deploy. - **Choose Diffinery** if you want one platform for code, reviews and CI, with AI agents as accounts that the server holds to guardrails, and Linux runners that are 4 vCPU by default, and you're happy to run your own instance on Cloudflare. ## Side by side | | Diffinery | Blacksmith | | --- | --- | --- | | What it is | Git hosting, pull requests, issues and GitHub Actions–compatible CI in one platform | Runners for GitHub Actions; you stay on GitHub ([source](https://docs.blacksmith.sh/introduction/quickstart)) | | Who runs it | You: you deploy your instance to your own Cloudflare account and pay Cloudflare for its usage | Blacksmith runs the runners; GitHub runs the rest | | Where your code lives | Your Diffinery instance | GitHub (github.com, Enterprise Server or Enterprise Cloud) | | Setup | Deploy your instance ([guide](https://diffinery.com/docs/self-hosting/deploy)), then import your repositories; workflows run as they are | Install the GitHub App, change `runs-on` to a Blacksmith label such as `blacksmith-2vcpu-ubuntu-2404` | | Personal repositories | Yes | No: GitHub organizations only ([source](https://docs.blacksmith.sh/introduction/quickstart)) | | Linux x64 runner sizes | 1/4, 1/2, 2 and 4 vCPU; 4 vCPU and 12 GiB by default, smaller sizes picked per job from its measured runs | 2, 4, 8, 16 and 32 vCPU ([source](https://docs.blacksmith.sh/blacksmith-runners/overview)) | | Arm, Windows, macOS | Self-hosted runners only | Arm, Windows (public beta) and macOS on Apple M4 ([source](https://docs.blacksmith.sh/blacksmith-runners/overview)) | | Machines | Cloudflare Containers on AMD EPYC hosts | "Modern gaming CPUs", in Firecracker microVMs ([source](https://docs.blacksmith.sh/blacksmith-runners/overview)) | | Linux x64 price | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage, with runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026) | $0.004 per minute at 2 vCPU, rising with vCPU count; 3,000 free 2-vCPU minutes a month per organization ([pricing](https://www.blacksmith.sh/pricing), [free minutes](https://docs.blacksmith.sh/blacksmith-runners/overview)) | | Caches | Built-in cache actions that download in parallel ranges and extract while downloading | A colocated cache used by the official cache actions, plus Docker layer caching and sticky disks as paid add-ons ([source](https://www.blacksmith.sh/pricing)) | | Speed claim | Measured on our runners and published with methods: [Performance](https://diffinery.com/performance) | "2x faster than GitHub's runners" (Blacksmith's own claim, [source](https://www.blacksmith.sh/)) | | Regions | Cloudflare's network | Ashburn, Phoenix, Amsterdam and Frankfurt ([source](https://docs.blacksmith.sh/blacksmith-runners/overview)) | | Compliance | None: Diffinery is open-source software you run, not a certified service | SOC 2 Type 2 ([source](https://www.blacksmith.sh/security)) | | AI agents | Agent accounts with server-enforced guardrails, an MCP server, sandboxes, agent tasks and agent review | Agent products of its own; agents on GitHub work as they do there | | Pull requests, issues, code review | Built in | GitHub's | ## Where each one is the better fit **Blacksmith is the better fit when** your team is settled on GitHub, needs macOS, Windows, Arm or runners larger than 4 vCPUs, wants SOC 2 Type 2 today, wants faster CI without moving anything, or doesn't want to run any infrastructure itself. **Diffinery is the better fit when** you'd rather have one platform than GitHub plus a runner service and are happy to run it on your own Cloudflare account, your CI is Linux, you work with AI coding agents and want the forge itself to limit what they can do, or you want workflows, `gh`, your editor and your agents to move together. ## Frequently asked questions ### Can I use Blacksmith with Diffinery? No. Blacksmith runners connect to GitHub through its GitHub App. On Diffinery, jobs run on your instance's hosted runners in Cloudflare Containers or on your own machines with `diffinery runner`. ### Is Diffinery cheaper than Blacksmith? Diffinery itself is free and open source; you pay Cloudflare for what your instance uses, on the Workers Paid plan ("a minimum cost of $5 USD per month per account", plus usage above the included amounts; [source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026). That bill depends on your usage, so we don't quote a per-minute figure to compare. Blacksmith lists $0.004 per minute for a 2-vCPU Linux runner, against GitHub's $0.006, and runs the machines for you. Diffinery's default runner has 4 vCPUs. ### Which is faster? We haven't benchmarked Blacksmith and don't quote others' numbers as our own. Diffinery's measured stage times are on the [Performance](https://diffinery.com/performance) page; Blacksmith publishes its own claims on its website. --- # Diffinery vs Depot > Depot sells fast GitHub Actions runners, its own Depot CI engine and container builds. How it compares with Diffinery, with specs, prices and trade-offs. Source: https://diffinery.com/compare/depot (last updated 2026-10-25) Depot started with fast container builds and now sells drop-in runners for GitHub Actions, its own CI engine that runs GitHub Actions YAML (Depot CI), and Git hosting in beta (Depot Code). Diffinery is a git forge with pull requests, issues, GitHub Actions–compatible CI and AI agents in one platform. Depot is a service that runs everything for you; Diffinery is free, open-source software (Apache-2.0) that you deploy to your own Cloudflare account, with no hosted service (see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery)). Depot facts here are from its own website and documentation, checked on October 16, 2026, and linked below. ## In short - **Choose Depot** if you want faster CI and container builds while staying on GitHub, need large, Arm, Windows or macOS runners, want SOC 2 Type 2 today, or want a service that runs the machines for you. - **Choose Diffinery** if you want code hosting, pull request review and CI in one product, and AI agents held to guardrails by the forge itself, and you're happy to run your own instance on Cloudflare. ## Side by side | | Diffinery | Depot | | --- | --- | --- | | Who runs it | You: you deploy your instance to your own Cloudflare account and pay Cloudflare for its usage | Depot | | What it is | Git hosting, pull requests, issues, releases, a container registry and GitHub Actions–compatible CI | GitHub Actions runners, Depot CI (generally available since March 2026), container builds, a registry, and Depot Code (beta) ([runners](https://depot.dev/docs/github-actions/overview), [Depot CI](https://depot.dev/changelog/2026-03-24-depot-ci-now-available)) | | Git hosting and pull requests | Yes, with reviews, required checks, auto-merge and a merge queue | Depot Code, in beta: repositories or GitHub mirrors that trigger CI on push; Depot says a code review UI is in progress ([source](https://depot.dev/docs/code/overview)) | | Runners for GitHub-hosted repositories | No (Diffinery runs its own repositories' workflows) | Yes, for repositories owned by GitHub organizations, with a `runs-on: depot-ubuntu-24.04` label ([source](https://depot.dev/docs/github-actions/overview)) | | Linux x64 runner sizes | 1/4, 1/2, 2 and 4 vCPU; 4 vCPU and 12 GiB by default | 2 to 64 vCPU, 8 to 256 GB, on AMD EPYC Genoa ([source](https://depot.dev/docs/github-actions/runner-types)) | | Arm, Windows, macOS | Self-hosted runners only | Arm (Graviton4) and Windows; macOS on the Startup and Business plans ([source](https://depot.dev/docs/github-actions/runner-types)) | | Linux x64 price | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage, with runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026) | $0.006 per minute at 2 vCPU, tracked per second; plans from $20 a month with 2,000 runner minutes included ([source](https://depot.dev/pricing)) | | Free use | The software is free; your Cloudflare account needs the Workers Paid plan, "a minimum cost of $5 USD per month per account" ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026) | A 7-day trial ([source](https://depot.dev/pricing)) | | Caches | Built-in cache actions with parallel downloads | Accelerated Actions cache with no configuration, and remote BuildKit builders for Docker ([source](https://depot.dev/docs/github-actions/overview)) | | Speed claim | Measured on our runners and published with methods: [Performance](https://diffinery.com/performance) | "Up to 3x faster than a GitHub-hosted runner" and "10x faster caching" (Depot's own claims, [source](https://depot.dev/docs/github-actions/overview)) | | Compliance | None: Diffinery is open-source software you run, not a certified service | SOC 2 Type 2 ([source](https://depot.dev/security)) | | AI agents | Agent accounts with server-enforced guardrails, an MCP server, sandboxes, agent tasks and agent review | Agent sandboxes; agents on GitHub work as they do there | ## Where each one is the better fit **Depot is the better fit when** container image builds are your bottleneck, you need runners bigger than 4 vCPUs or on macOS, Windows or Arm, you want to keep GitHub as your forge and speed up CI around it, or you don't want to run any infrastructure yourself. **Diffinery is the better fit when** you want one place for code, review and CI with a mature pull request workflow, when your CI is Linux, and when AI agents do a growing share of your work and you want the forge to enforce what they can and can't do. You run Diffinery yourself, on your own Cloudflare account. ## Frequently asked questions ### Can Depot runners run Diffinery workflows? No. Depot's runners connect to GitHub organizations, and Depot CI runs workflows for GitHub and Depot Code repositories. On Diffinery, jobs run on your instance's hosted runners in Cloudflare Containers or on your own machines with `diffinery runner`. ### Can I build container images on Diffinery? Yes. Hosted runners include Docker with Buildx, and images push to your instance's own registry at `//`. See [Docker in workflows](https://diffinery.com/docs/actions/docker) and [Container registry](https://diffinery.com/docs/packages/container-registry). ### Which is faster? We haven't benchmarked Depot and don't quote others' numbers as our own. Diffinery's measured stage times are on the [Performance](https://diffinery.com/performance) page; Depot publishes its own claims in its documentation. --- # Diffinery vs CircleCI > CircleCI runs CI for code on GitHub, GitLab or Bitbucket. Diffinery hosts the code and runs GitHub Actions workflows. Formats, runners, prices, trade-offs. Source: https://diffinery.com/compare/circleci (last updated 2026-10-25) CircleCI is a hosted CI/CD service: your code stays on GitHub, GitLab or Bitbucket, and CircleCI runs the pipelines you define in `.circleci/config.yml`. It also sells a self-hosted edition, CircleCI Server, and since September 2026 it has been previewing support for running GitHub Actions workflow files. Diffinery is a git forge with CI built in: it hosts your repositories, pull requests and issues, and runs GitHub Actions workflows as they are. It is free, open-source software (Apache-2.0) that you deploy to your own Cloudflare account, with no hosted service (see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery)). CircleCI facts here are from its own website and documentation, checked on October 25, 2026, and linked below. ## In short - **The biggest difference is what each one replaces.** CircleCI replaces only your CI and works next to the Git host you already have. Diffinery replaces the Git host and the CI together. - **Choose CircleCI** if you want to keep your code where it is, need hosted Arm, Windows, macOS or GPU machines, want machines larger than 4 vCPUs, want SOC 2 Type 2 and FedRAMP today, want a self-hosted edition on your own Kubernetes cluster, or want a service that CircleCI runs for you. - **Choose Diffinery** if you want code hosting, review and CI in one product, workflows written as GitHub Actions YAML (supported as the main format, not in preview), and AI agents held to guardrails by the forge itself, and you're happy to run your own instance on Cloudflare. ## Side by side | | Diffinery | CircleCI | | --- | --- | --- | | What it is | Git hosting, pull requests, issues, releases, a container registry and GitHub Actions–compatible CI in one platform | A CI/CD service that connects to your Git host, with a self-hosted edition ([source](https://circleci.com/pricing/server/)) | | Who runs it | You: you deploy your instance to your own Cloudflare account and pay Cloudflare for its usage | CircleCI, or you with CircleCI Server | | Where your code lives | Your Diffinery instance | GitHub.com, GitHub Enterprise Cloud and Server, GitLab (SaaS and self-managed), Bitbucket Cloud, or Cursor Origin (beta) ([source](https://circleci.com/docs/guides/integration/version-control-system-integration-overview/)) | | Pipeline format | GitHub Actions YAML in `.github/workflows/` | `.circleci/config.yml` ([source](https://circleci.com/docs/reference/configuration-reference/)), with reusable configuration packages called orbs ([source](https://circleci.com/docs/orb-intro/)) | | GitHub Actions workflow files | The main format; every difference is listed in [Compatibility](https://diffinery.com/docs/actions/compatibility) | In preview: GitHub repositories only, Linux only, and some keys are ignored, such as the `branches`, `paths` and `types` filters under `on:`, `schedule` and `concurrency` ([source](https://circleci.com/docs/guides/orchestrate/use-a-github-actions-workflow-file-as-your-pipeline-configuration/)) | | Linux x64 machine sizes | 1/4, 1/2, 2 and 4 vCPU; 4 vCPU and 12 GiB by default, smaller sizes picked per job from its measured runs | Docker from Small (1 CPU, 2 GB) to X-large (8 CPU, 16 GB), and Linux VMs from Medium (2 CPU, 7.5 GB) ([source](https://circleci.com/pricing/price-list/)); 2X-large and larger classes on the Scale plan ([source](https://circleci.com/pricing/)) | | Arm, Windows, macOS, GPU | Self-hosted runners only | Arm, Windows and macOS on every plan; GPU on the Scale plan ([source](https://circleci.com/pricing/)) | | Linux x64 price | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage, with runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026) | Credits at $0.0006 each, billed by the second ([source](https://circleci.com/docs/guides/plans-pricing/credits/)). A 2-CPU Docker Medium uses 10 credits a minute ([source](https://circleci.com/pricing/price-list/)), which works out to $0.006 a minute | | Plans | None. Your Cloudflare account needs the Workers Paid plan, "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026) | Free: 30,000 credits a month and up to 5 active users. Performance: from $15 a month with 5 active users, $15 a month for each extra user. Scale: custom, billed annually ([source](https://circleci.com/pricing/)) | | Self-hosted runners | `diffinery runner`, on any machine | On every plan: 5 concurrent tasks on Free, 20 on Performance, unlimited on Scale ([source](https://circleci.com/pricing/)) | | Self-hosted edition | Diffinery is always self-deployed: open source (Apache-2.0), on your own Cloudflare account ([guide](https://diffinery.com/docs/self-hosting/deploy)). It doesn't run on your own servers or Kubernetes | CircleCI Server, on GKE, EKS or your own Kubernetes, for GitHub Enterprise Cloud and Server only ([source](https://circleci.com/pricing/server/)) | | Compliance | None: Diffinery is open-source software you run, not a certified service | SOC 2 Type 2 and FedRAMP LI-SaaS, among others listed on its Trust Center ([source](https://trust.circleci.com/)) | | MCP server | Built in at `/mcp` on your instance: read code, commit, open, review and merge pull requests, follow CI and run commands in sandboxes | Hosted at `https://mcp.circleci.com/v1/mcp`: inspect runs, jobs, logs, artifacts and test results, and rerun or cancel workflows ([source](https://circleci.com/docs/guides/toolkit/circleci-mcp-overview/)) | | AI agents | Agent accounts with server-enforced guardrails, sandboxes, agent tasks and agent review | Agents on your Git host work as they do there | | Pull requests, issues, code review | Built in | Your Git host's | ## Where each one is the better fit **CircleCI is the better fit when** you want to keep GitHub, GitLab or Bitbucket and change only your CI, when you build for macOS, Windows, Arm or GPUs on hosted machines, when your builds need more than 4 vCPUs, when you need SOC 2 Type 2 or FedRAMP paperwork today, when you need to run CI inside your own network with CircleCI Server, or when you'd rather not run any infrastructure yourself. **Diffinery is the better fit when** you'd rather have one platform than a Git host plus a CI service and are happy to run it on your own Cloudflare account, your CI is Linux, you want to write workflows in the GitHub Actions format and use actions from GitHub as they are, and you work with AI coding agents and want the forge to limit what they can do. ## Moving from CircleCI Deploy your own instance first ([guide](https://diffinery.com/docs/self-hosting/deploy)). Diffinery's importer reads from GitHub. If your code is on GitHub, it brings over each repository with its history, issues, pull requests, reviews and releases; see [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). From GitLab or Bitbucket, push branches and tags with plain Git; see [Move with plain Git](https://diffinery.com/docs/migrate-from-github#move-with-plain-git). Then rewrite each `.circleci/config.yml` as workflows in `.github/workflows/`, and check them with `diffinery workflow validate`. ## Frequently asked questions ### Is Diffinery a CircleCI alternative? Yes, if you're willing to move your repositories as well. Diffinery's CI runs on its own repositories, so it replaces CircleCI and your Git host together. If you want to keep your current Git host and only change CI, CircleCI or a GitHub Actions runner service is the closer fit; see [GitHub Actions alternatives, compared](https://diffinery.com/compare). ### Can I run my CircleCI config on Diffinery? No. Diffinery runs GitHub Actions workflows, not `.circleci/config.yml`, and CircleCI orbs don't run on it. Teams moving from CircleCI rewrite their pipelines as workflows, usually replacing each orb with a GitHub action that does the same job. ### Can CircleCI run GitHub Actions workflows? In preview. CircleCI announced on September 22, 2026 that it can run existing GitHub Actions workflow files on its own machines ([source](https://circleci.com/blog/github-actions-alternative-run-actions-on-circleci/)). Its documentation says the preview works with GitHub repositories, runs Linux jobs only, doesn't apply the filters under `on:` or `schedule`, and gives `concurrency` no effect ([source](https://circleci.com/docs/guides/orchestrate/use-a-github-actions-workflow-file-as-your-pipeline-configuration/)). On Diffinery, GitHub Actions is the native format. ### Can I use CircleCI with Diffinery? Not today. CircleCI connects to GitHub, GitLab, Bitbucket Cloud and Cursor Origin, and Diffinery isn't one of them. On Diffinery, jobs run on your instance's hosted runners in Cloudflare Containers or on your own machines with `diffinery runner`. ### Is Diffinery cheaper than CircleCI? Diffinery itself is free and open source; you pay Cloudflare for what your instance uses, which depends on your usage, so we don't quote a per-minute figure to compare. CircleCI's Free plan includes 30,000 credits a month, and a 2-CPU Docker job uses 10 credits a minute, so about 3,000 minutes of that size. Paid credits cost $0.0006 each, and CircleCI runs the machines for you. Diffinery's default runner has 4 vCPUs and 12 GiB of memory, against 2 CPUs and 4 GB on CircleCI's Docker Medium. ### Which is faster, Diffinery or CircleCI? We haven't benchmarked CircleCI and don't quote others' numbers as our own. Diffinery's measured stage times are on the [Performance](https://diffinery.com/performance) page. CircleCI says sampled builds in its GitHub Actions preview ran "40% faster" than on GitHub Actions (CircleCI's own claim, [source](https://circleci.com/blog/github-actions-alternative-run-actions-on-circleci/)). --- # Diffinery vs Buildkite > Buildkite orchestrates CI on agents you run or it hosts, for code on GitHub, GitLab or Bitbucket. Diffinery hosts code and runs GitHub Actions workflows. Source: https://diffinery.com/compare/buildkite (last updated 2026-10-25) Buildkite Pipelines is a CI/CD service with a hybrid design: Buildkite runs the control plane (the web interface, APIs and scheduling), and the jobs run on agents, either on your own machines or on agents Buildkite hosts. Your code stays on GitHub, GitLab or Bitbucket. Diffinery is a git forge with CI built in: it hosts your repositories, pull requests and issues, and runs GitHub Actions workflows as they are on its own runners. It is free, open-source software (Apache-2.0) that you deploy to your own Cloudflare account, with no hosted service (see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery)). Buildkite facts here are from its own website and documentation, checked on October 25, 2026, and linked below. ## In short - **The biggest difference is who runs the machines.** Buildkite is built around agents on your own infrastructure, which keeps your source code and secrets inside your network. Diffinery runs every job on hosted runners that your instance starts in Cloudflare Containers on your own Cloudflare account, unless you add self-hosted ones; there are no build machines to manage. - **Choose Buildkite** if you want builds on your own fleet at large scale, need hosted macOS or machines larger than 4 vCPUs, want pipelines generated by code while a build runs, want SOC 2 Type II today, or want to keep your current Git host. - **Choose Diffinery** if you don't want to manage build machines or a separate CI service, are happy to run your own instance on Cloudflare, want code hosting, review and CI in one product, write workflows as GitHub Actions YAML, and want AI agents held to guardrails by the forge itself. ## Side by side | | Diffinery | Buildkite | | --- | --- | --- | | What it is | Git hosting, pull requests, issues, releases, a container registry and GitHub Actions–compatible CI in one platform | A CI/CD control plane run by Buildkite, with the jobs on agents you run or on Buildkite hosted agents ([source](https://buildkite.com/docs/pipelines/architecture)) | | Who runs it | You: you deploy your instance to your own Cloudflare account and pay Cloudflare for its usage | Buildkite runs the control plane; you or Buildkite run the agents | | Where your code lives | Your Diffinery instance | GitHub, GitLab (including self-managed) or Bitbucket ([GitHub](https://buildkite.com/docs/pipelines/source-control/github), [GitLab](https://buildkite.com/docs/pipelines/source-control/gitlab), [Bitbucket](https://buildkite.com/docs/integrations/bitbucket)) | | Where jobs run | Your instance's hosted runners in Cloudflare Containers, or your own machines with `diffinery runner` | Your own machines, on premises or in your cloud, or Buildkite hosted agents. With your own agents, Buildkite says "source code and secrets, remain within your environment and are not seen by the Buildkite Platform" ([source](https://buildkite.com/docs/pipelines/architecture)) | | Pipeline format | GitHub Actions YAML in `.github/workflows/` | Buildkite YAML, usually `.buildkite/pipeline.yml`, uploaded by an agent at the start of a build; steps can be added while a build runs ([source](https://buildkite.com/docs/pipelines/configure/defining-steps)), and plugins hook into the job lifecycle ([source](https://buildkite.com/docs/pipelines/integrations/plugins)) | | GitHub Actions workflow files | The main format; every difference is listed in [Compatibility](https://diffinery.com/docs/actions/compatibility) | In public preview, as native Buildkite jobs. `pull_request_target` is "intentionally unsupported", `workflow_run` and `repository_dispatch` aren't supported yet, and GitHub-hosted runner images aren't reproduced ([source](https://buildkite.com/docs/pipelines/migration/run-github-actions-workflows)) | | Hosted Linux x64 sizes | 1/4, 1/2, 2 and 4 vCPU; 4 vCPU and 12 GiB by default | Small (2 vCPU, 4 GB) on every plan; Medium (4 vCPU, 16 GB) and Large (8 vCPU, 32 GB) on paid plans; up to 64 vCPU and 256 GB, and Arm, on Enterprise ([source](https://buildkite.com/docs/agent/buildkite-hosted/linux)) | | Hosted macOS and Windows | Not available; use self-hosted runners | macOS on Apple M4 ([source](https://buildkite.com/pricing/)); Windows hosted agents in private preview ([source](https://buildkite.com/docs/pipelines/migration/run-github-actions-workflows)) | | Hosted Linux price | Free and open source (Apache-2.0); you pay Cloudflare for your instance's usage, with runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026) | $0.004 per vCPU minute, so $0.008 a minute for Small (2 vCPU), metered to the second ([source](https://buildkite.com/pricing/)) | | Plans | None. Your Cloudflare account needs the Workers Paid plan, "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026) | Free: up to 5 users, 10 concurrent jobs and 2,000 Linux vCPU minutes a month. Pro: $30 per active user a month, 4,000 Linux vCPU minutes, 10 self-hosted agents included and $3.50 a month for each one after. Enterprise: custom ([source](https://buildkite.com/pricing/)) | | Longest job on hosted machines | 6 hours | 8 hours on hosted Linux agents ([source](https://buildkite.com/docs/agent/buildkite-hosted/linux)) | | Compliance | None: Diffinery is open-source software you run, not a certified service | SOC 2 Type II ([source](https://buildkite.com/about/security/)) | | MCP server | Built in at `/mcp` on your instance: read code, commit, open, review and merge pull requests, follow CI and run commands in sandboxes | Hosted at `https://mcp.buildkite.com/mcp`, with data from pipelines, builds, jobs and Test Engine, and write actions such as retrying jobs ([source](https://buildkite.com/docs/apis/mcp-server)) | | AI agents | Agent accounts with server-enforced guardrails, sandboxes, agent tasks and agent review | Agents on your Git host work as they do there | | Pull requests, issues, code review | Built in | Your Git host's | ## Where each one is the better fit **Buildkite is the better fit when** you already run, or want to run, your own build machines, for example for security, special hardware or cost at very high volume; when source code and secrets must stay inside your network; when you need hosted macOS or machines larger than 4 vCPUs; when your pipelines are big enough that generating steps from code pays off; or when you want to keep GitHub, GitLab or Bitbucket. Buildkite's hosted agents and control plane are also run for you, where a Diffinery instance is yours to deploy and update. **Diffinery is the better fit when** you want CI without managing any build machines, one platform for code, review and CI, workflows in the GitHub Actions format with actions from GitHub as they are, and a forge that enforces what AI agents can and can't do, and you're happy to run that forge on your own Cloudflare account. Your CI should be Linux, or you should be happy to add self-hosted runners for the rest. ## Moving from Buildkite Deploy your own instance first ([guide](https://diffinery.com/docs/self-hosting/deploy)). Diffinery's importer reads from GitHub. If your code is on GitHub, it brings over each repository with its history, issues, pull requests, reviews and releases; see [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). From GitLab or Bitbucket, push branches and tags with plain Git; see [Move with plain Git](https://diffinery.com/docs/migrate-from-github#move-with-plain-git). Then rewrite each Buildkite pipeline as workflows in `.github/workflows/`. Jobs that need your own hardware can keep it: register those machines with `diffinery runner` and target them with `runs-on: [self-hosted, ...]`. See [Self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). ## Frequently asked questions ### Is Diffinery a Buildkite alternative? Yes, if you're willing to move your repositories as well. Diffinery's CI runs on its own repositories, so it replaces Buildkite and your Git host together. Like Buildkite, it can run jobs on your own machines, with `diffinery runner`; unlike Buildkite, it also runs every Linux job on hosted runners in your Cloudflare account by default. ### Can I run my Buildkite pipelines on Diffinery? No. Diffinery runs GitHub Actions workflows, not Buildkite pipeline YAML, and Buildkite plugins don't run on it. Teams moving from Buildkite rewrite their pipelines as workflows. Pipelines that generate their steps at build time map most closely to GitHub Actions matrices whose values come from an earlier job's outputs. ### Can Buildkite run GitHub Actions workflows? In public preview. Buildkite's GitHub Actions Compatibility runs supported workflows from `.github/workflows/` as Buildkite jobs, with limits: no `pull_request_target`, no `workflow_run` or `repository_dispatch` yet, and no reproduction of GitHub's runner images ([source](https://buildkite.com/docs/pipelines/migration/run-github-actions-workflows)). On Diffinery, GitHub Actions is the native format. ### Can I use Buildkite with Diffinery? Buildkite's documented source control integrations are GitHub, GitLab and Bitbucket, and Diffinery isn't one of them. On Diffinery, jobs run on your instance's hosted runners in Cloudflare Containers or on your own machines with `diffinery runner`. ### Is Diffinery cheaper than Buildkite? Diffinery itself is free and open source; you pay Cloudflare for what your instance uses, which depends on your usage, so we don't quote a per-minute figure to compare. Buildkite charges per active user on Pro ($30 a month) plus hosted minutes at $0.004 per vCPU minute, or $3.50 a month per self-hosted agent after the first 10; with your own agents you also pay for the machines. ### Which is faster, Diffinery or Buildkite? We haven't benchmarked Buildkite and don't quote others' numbers as our own. With self-hosted agents, Buildkite's speed depends mostly on the machines you give it. Diffinery's measured stage times are on the [Performance](https://diffinery.com/performance) page. --- # Diffinery vs Bitbucket and Bitbucket Pipelines > How Diffinery compares with Bitbucket Cloud and Bitbucket Pipelines on CI format, build minutes, runner sizes, prices, Jira, AI agents and self-managed options. Source: https://diffinery.com/compare/bitbucket (last updated 2026-10-25) Bitbucket is Atlassian's Git hosting, with Bitbucket Pipelines for CI/CD built in and close ties to Jira. It comes as Bitbucket Cloud and as Bitbucket Data Center, Atlassian's self-managed edition. Diffinery is a git forge with GitHub Actions–compatible CI built in, designed for teams of people and AI agents. It is free, open-source software (Apache-2.0) that you deploy to your own Cloudflare account, with no hosted service (see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery)). Bitbucket facts here are from Atlassian's own website and documentation, checked on October 25, 2026, and linked below. ## In short - **The biggest difference is the CI format.** Bitbucket Pipelines uses `bitbucket-pipelines.yml`. Diffinery runs GitHub Actions workflows (`.github/workflows/*.yml`) as they are, so it suits teams coming from GitHub, or teams that want the GitHub Actions ecosystem of reusable actions. - **Choose Bitbucket** if your team plans its work in Jira and uses other Atlassian products, needs hosted Arm builds or build steps larger than 4 vCPUs, needs to run the forge on its own servers, or wants a service that Atlassian runs for you. - **Choose Diffinery** if you want GitHub Actions compatibility, 4-vCPU runners by default, a forge built around AI agents with server-enforced guardrails, and push protection on every repository, and you're happy to run your own instance on Cloudflare. ## Side by side | | Diffinery | Bitbucket Cloud | | --- | --- | --- | | Who runs it | You: you deploy your instance to your own Cloudflare account and pay Cloudflare for its usage | Atlassian | | What it is | Git hosting, pull requests, issues, releases, a container registry and GitHub Actions–compatible CI | Git hosting with Bitbucket Pipelines, connected to Jira and Atlassian's Rovo AI ([source](https://www.atlassian.com/software/bitbucket)) | | Pipeline format | GitHub Actions YAML in `.github/workflows/` | `bitbucket-pipelines.yml` at the root of the repository ([source](https://support.atlassian.com/bitbucket-cloud/docs/get-started-with-bitbucket-pipelines/)), extended with Pipes for third-party tools ([source](https://www.atlassian.com/software/bitbucket/features/pipelines)) | | Default hosted machine | 4 vCPU, 12 GiB memory, 20 GB disk | Size 1x, with 4 GB of memory ([source](https://support.atlassian.com/bitbucket-cloud/docs/global-options/)) | | Other hosted sizes | 2, 1/2 and 1/4 vCPU, picked per job from its measured runs | 2x (8 GB) up to 32x (128 GB); 4x and larger on the Standard and Premium plans. A 2x step uses twice the build minutes of a 1x step, and so on ([source](https://support.atlassian.com/bitbucket-cloud/docs/global-options/)) | | Hosted Arm | No; use self-hosted runners | Yes, on the Standard and Premium plans ([source](https://www.atlassian.com/blog/development/announcing-arm-builds-in-cloud-for-bitbucket-pipelines)) | | Self-hosted runners | `diffinery runner`, on any machine | Linux Docker, Linux Shell, Windows and macOS, plus Linux Docker on Arm64 in beta ([source](https://support.atlassian.com/bitbucket-cloud/kb/bitbucket-runners-comparison/)) | | Included CI minutes | Nothing to buy. By default each account gets 3,000 hosted minutes a month, a quota your instance's administrators set ([Configuration](https://diffinery.com/docs/self-hosting/configuration)) | 50 build minutes a month on Free, 2,500 on Standard, 3,500 on Premium ([Free](https://www.atlassian.com/software/bitbucket/pricing), [Standard and Premium](https://www.atlassian.com/licensing/bitbucket)) | | Extra minutes | None to buy; you pay Cloudflare for your instance's usage, with runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026) | $10 per 1,000 minutes ([source](https://www.atlassian.com/licensing/bitbucket)); allowances are pooled across an organization from October 26, 2026 on monthly plans ([source](https://support.atlassian.com/bitbucket-cloud/docs/manage-your-plan-and-billing/)) | | Per-user price | None: free and open source (Apache-2.0). Your Cloudflare account needs the Workers Paid plan, "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026) | Free for up to 5 users; Standard $3.65 and Premium $8 per user per month ([source](https://www.atlassian.com/software/bitbucket/pricing)) | | Longest step | 6 hours | 120 minutes by default, up to 720 with `max-time` ([source](https://support.atlassian.com/bitbucket-cloud/docs/global-options/)) | | Security controls | Passkeys only, push protection on every repository, branch protection, agent guardrails, append-only audit log | Premium adds enforced merge checks, IP allowlisting, deployment permissions and required two-step verification ([source](https://www.atlassian.com/software/bitbucket/pricing)) | | Compliance | None: Diffinery is open-source software you run, not a certified service | Bitbucket Cloud is listed among the products on Atlassian's SOC 2 page ([source](https://www.atlassian.com/trust/compliance/resources/soc2)) | | Self-managed edition | Diffinery is always self-deployed: open source (Apache-2.0), on your own Cloudflare account ([guide](https://diffinery.com/docs/self-hosting/deploy)). There's no version for your own servers | Bitbucket Data Center ([source](https://www.atlassian.com/software/bitbucket/pricing)) | | MCP server | Built in at `/mcp` on your instance | Through the Atlassian Rovo MCP Server since April 2026: repositories, pull requests, commits, branches, pipelines and deployments, for workspaces linked to an Atlassian organization ([source](https://www.atlassian.com/blog/bitbucket/the-atlassian-rovo-mcp-server-now-supports-bitbucket-cloud)) | | AI agents | Agent accounts with their own tokens and server-enforced guardrails; [agent tasks](https://diffinery.com/docs/agents/tasks) and [agent review](https://diffinery.com/docs/agents/review) with your own model key | Rovo Dev, which Atlassian says can "turn Jira items into code, review code and more" ([source](https://www.atlassian.com/software/bitbucket)) | | Planning | Issues with labels, milestones and assignees | Connected to Jira ([source](https://www.atlassian.com/software/bitbucket)) | ## Where each one is the better fit **Bitbucket is the better fit when** your company runs on Jira and Confluence and wants code linked to Jira work items, when you need hosted Arm builds or build steps with more than 4 vCPUs, when you need a self-managed edition in your own data center, or when you'd rather not run any infrastructure yourself. **Diffinery is the better fit when** you want to write CI in the GitHub Actions format and use actions from GitHub as they are, when faster default runners matter for your Linux builds, and when AI agents write a growing share of your code and you want the forge to enforce what they can and can't do. You run Diffinery yourself, on your own Cloudflare account. ## Moving from Bitbucket Deploy your own instance first ([guide](https://diffinery.com/docs/self-hosting/deploy)). Diffinery's importer reads from GitHub. From Bitbucket, push your branches and tags with plain Git (see [Move with plain Git](https://diffinery.com/docs/migrate-from-github#move-with-plain-git)), then rewrite `bitbucket-pipelines.yml` as workflows in `.github/workflows/` and check them with `diffinery workflow validate`. Issues and pull requests don't come across this way. Steps that need Windows or macOS keep running on your own machines through [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). ## Frequently asked questions ### Is Diffinery a Bitbucket alternative? Yes. Like Bitbucket Cloud, Diffinery hosts Git repositories with pull requests, reviews, branch protection and built-in CI. It doesn't have Bitbucket's Jira integration, hosted Arm builds, build steps larger than 4 vCPUs or a version for your own servers, and Bitbucket Cloud is a service Atlassian runs for you, where Diffinery is software you run on your own Cloudflare account. ### Does Diffinery run bitbucket-pipelines.yml? No. Diffinery runs GitHub Actions workflows. Teams moving from Bitbucket Pipelines rewrite their pipelines as workflows in `.github/workflows/`, usually replacing each Pipe with a GitHub action that does the same job. ### Is Diffinery cheaper than Bitbucket? Diffinery itself is free and open source, with no per-user price; you pay Cloudflare for what your instance uses, which depends on your usage, so we don't quote a figure to compare. Bitbucket Cloud lists $3.65 per user per month on Standard with 2,500 build minutes, $8 on Premium with 3,500, and $10 for each extra 1,000 minutes. A build minute on Bitbucket is counted at the step's size, so a minute on a 2x step uses two. ### Can I use Jira with Diffinery? Diffinery has no built-in Jira integration. It sends GitHub-compatible [webhooks](https://diffinery.com/docs/repositories/webhooks) and has a GitHub-style REST API, so you can connect your own automation to pushes, pull requests and CI results. ### Is Bitbucket Data Center being discontinued? Not according to Atlassian. Its Data Center end-of-life page says that, unlike its other Data Center products, Bitbucket and Bamboo Data Center "will not end of life", and that existing customers can renew to a Bitbucket Hybrid License that covers Data Center and Bitbucket Cloud ([source](https://www.atlassian.com/licensing/data-center-end-of-life)). --- # Diffinery vs Gitea and Forgejo > Gitea, Forgejo and Diffinery are all open-source forges you run yourself. Gitea and Forgejo run on your servers; Diffinery on your Cloudflare account. Source: https://diffinery.com/compare/gitea (last updated 2026-10-25) Gitea and Forgejo are open-source, self-hosted git forges. Forgejo was created from Gitea in October 2022 ([source](https://forgejo.org/compare-to-gitea/)) and is developed under Codeberg e.V., a non-profit, which also runs the public forge Codeberg.org on Forgejo ([source](https://docs.codeberg.org/getting-started/what-is-codeberg/)). Both have built-in CI whose workflow files look like GitHub Actions (Gitea Actions and Forgejo Actions), with jobs run by runners you install. Diffinery is also open source (Apache-2.0) and also something you run yourself, but on your own Cloudflare account rather than on your own servers: it runs GitHub Actions workflows on runners that your instance starts in Cloudflare Containers (see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and the [source code](https://diffinery.com/codician/diffinery)). Gitea, Forgejo and Codeberg facts here are from their own websites and documentation, checked on October 25, 2026, and linked below. ## In short - **The biggest difference is where it runs.** All three are open-source forges you run yourself. Gitea and Forgejo are software you install and operate on your own servers, along with the runners for their CI (unless you buy Gitea Cloud, which runs both for you). Diffinery runs only on Cloudflare: you deploy it to your own Cloudflare account, with no servers or runner machines to manage, but your code lives on Cloudflare's infrastructure rather than on hardware you control, and there's no version for your own machines. - **Choose Gitea or Forgejo** if your code has to stay on servers you control, you don't want to depend on one cloud provider, you want a forge someone runs for you (Gitea Cloud, or Codeberg for free software), or you want a forge with wikis and a package registry. Choose Forgejo if non-profit governance and a copyleft license matter to you. - **Choose Diffinery** if you want hosted runners with 4 vCPUs by default and documented GitHub Actions compatibility, no servers or runner machines to look after, and AI agents held to guardrails by the forge itself, and Cloudflare is an acceptable place for your code. ## Side by side | | Diffinery | Gitea | Forgejo | | --- | --- | --- | --- | | What it is | An open-source git forge with pull requests, issues, releases, a container registry and GitHub Actions–compatible CI, deployed to your own Cloudflare account | "A self-hosted DevOps platform", open source with commercial support from CommitGo ([source](https://about.gitea.com/)) | "A self-hosted lightweight software forge" ([source](https://forgejo.org/)) | | License | Apache-2.0 ([source](https://diffinery.com/codician/diffinery)) | MIT ([source](https://about.gitea.com/pricing/)) | GPL v3 or later from v9.0; MIT up to v8.0 ([source](https://forgejo.org/faq/)) | | Who develops it | The Diffinery project, in the open at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), mirrored [on GitHub](https://github.com/codician-team/diffinery) | Gitea, led by CommitGo ([source](https://about.gitea.com/)) | A community under Codeberg e.V., a non-profit ([source](https://forgejo.org/)) | | Where it runs | Your own Cloudflare account and domain only ([guide](https://diffinery.com/docs/self-hosting/deploy)); not on your own servers, and there's no hosted service | Your own servers: builds for Linux, Windows, macOS and FreeBSD, with SQLite, MySQL or PostgreSQL ([source](https://docs.gitea.com/installation/install-from-binary)); or fully managed as Gitea Cloud ([source](https://about.gitea.com/products/cloud/)) | Your own servers; or Codeberg.org for free software projects ([source](https://docs.codeberg.org/getting-started/faq/)) | | CI | Diffinery Actions: GitHub Actions YAML from `.github/workflows/` | Gitea Actions, since Gitea 1.19: "similar and mostly compatible to GitHub Actions" ([source](https://docs.gitea.com/usage/actions/overview)), with workflows in `.gitea/workflows/` and off by default in each repository ([source](https://docs.gitea.com/usage/actions/quickstart)) | Forgejo Actions: "designed to be familiar to users of GitHub Actions" but "not designed to be compatible"; reads `.github/workflows/` when there's no `.forgejo/workflows/` ([source](https://forgejo.org/docs/latest/user/actions/overview/)) | | Known differences from GitHub Actions | Listed in [Compatibility](https://diffinery.com/docs/actions/compatibility) | `jobs..environment` is ignored, problem matchers and error annotations are ignored, and some `permissions` scopes aren't supported ([source](https://docs.gitea.com/usage/actions/comparison)) | A different default image (Debian with Node.js), some `github` context keys missing, `permissions` and `continue-on-error` on jobs ignored, and OIDC turned on with its own key ([source](https://forgejo.org/docs/latest/user/actions/github-actions/)) | | Hosted runners | Yes: your instance starts a fresh Ubuntu 24.04 container per job in Cloudflare Containers, 4 vCPU and 12 GiB by default, with no runner machines to install | Gitea Runner, which you install; on Gitea Cloud, runners start on demand ([runner](https://docs.gitea.com/usage/actions/overview), [Cloud](https://about.gitea.com/products/cloud/)) | Forgejo Runner, which you install ([source](https://forgejo.org/docs/latest/user/actions/overview/)). Codeberg offers hosted Forgejo Actions "in a limited fashion in open alpha", and a Woodpecker CI instance by request ([source](https://docs.codeberg.org/ci/)) | | Self-hosted runners | `diffinery runner`, on any machine | Yes | Yes | | Wikis and packages | No wikis; a container registry | Wikis, a package and container registry, Git LFS, push and pull mirrors and a merge queue, among others ([source](https://docs.gitea.com/installation/comparison)) | Wikis, a package and container registry, and repository mirrors, among others ([source](https://forgejo.org/docs/latest/user/)) | | Price | Free and open source. You pay Cloudflare for your instance's usage, on the Workers Paid plan: "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026) | Free to self-host; Gitea Enterprise is a per-user subscription with a 30-day trial ([source](https://about.gitea.com/pricing/)); Gitea Cloud has a 30-day trial and no published price ([source](https://about.gitea.com/products/cloud/)) | Free software. Codeberg hosts free software projects and doesn't offer private hosting for commercial projects ([source](https://docs.codeberg.org/getting-started/faq/)) | | MCP server | Built in at `/mcp` on your instance, with browser sign-in | An official MCP server, gitea-mcp, that you run next to your instance ([source](https://gitea.com/gitea/gitea-mcp)) | Not covered here | | AI agents | Agent accounts with server-enforced guardrails, sandboxes, agent tasks and agent review | Not covered here | Not covered here | ## Where each one is the better fit **Gitea or Forgejo is the better fit when** your code must stay on hardware you control, including inside a private network; when you don't want to depend on one cloud provider; when you'd rather have someone run the forge for you, with Gitea Cloud; when you need wikis or a package registry; or when you're happy to run a server and runners. Forgejo also suits teams that want a forge governed by a non-profit and licensed under the GPL. Codeberg suits free software projects that want a public forge run by a non-profit. **Diffinery is the better fit when** you want an open-source forge without operating servers or CI machines, are happy to run it on your own Cloudflare account, want hosted runners that run GitHub Actions workflows with every difference documented, want features such as push protection and agent guardrails on by default, and work with AI coding agents that should open pull requests rather than push to main. ## Moving from Gitea or Forgejo Deploy your own instance first ([guide](https://diffinery.com/docs/self-hosting/deploy)). Diffinery's importer reads from GitHub. From Gitea or Forgejo, push your branches and tags with plain Git; see [Move with plain Git](https://diffinery.com/docs/migrate-from-github#move-with-plain-git). Issues and pull requests don't come across this way. Diffinery reads workflows from `.github/workflows/` and `.diffinery/workflows/`, so move files from `.gitea/workflows/` or `.forgejo/workflows/` into one of them, and check them with `diffinery workflow validate`. ## Frequently asked questions ### Is Diffinery a Gitea or Forgejo alternative? Yes, if Cloudflare is an acceptable place for your code. Like Gitea and Forgejo, Diffinery is open source and you run it yourself. It covers Git hosting, pull requests, issues, releases, a container registry and CI, and your instance starts the runners for you in Cloudflare Containers. It isn't a replacement if you need to run the forge on your own servers or need wikis. ### Can I self-host Diffinery like Gitea or Forgejo? Yes, on your own Cloudflare account, but not on your own servers. Diffinery is open source (Apache-2.0) and runs on Cloudflare (Workers, Durable Objects, R2, D1, Workflows and Containers); see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and [Architecture](https://diffinery.com/docs/self-hosting/architecture). Jobs can run on your own machines through [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). ### Do Gitea Actions and Forgejo Actions workflows run on Diffinery? Most of their workflow syntax is GitHub Actions syntax, which Diffinery runs. Move the files into `.github/workflows/` or `.diffinery/workflows/`, and check anything specific to Gitea or Forgejo, such as Forgejo's `enable-openid-connect` key for OIDC (on Diffinery, use `permissions: id-token: write`, as on GitHub) or `runs-on` labels for runners you registered there. Every difference from GitHub is listed in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ### Are Gitea Actions compatible with GitHub Actions? Gitea describes Gitea Actions as "similar and mostly compatible to GitHub Actions" and lists the differences in its documentation ([source](https://docs.gitea.com/usage/actions/comparison)). Forgejo says Forgejo Actions is "designed to be familiar to users of GitHub Actions" but "not designed to be compatible" ([source](https://forgejo.org/docs/latest/user/actions/overview/)). ### Can I host a company's private code on Codeberg? Codeberg's FAQ says it "does not offer private hosting services" for commercial projects and points to self-hosting Forgejo instead ([source](https://docs.codeberg.org/getting-started/faq/)). ### Is Gitea or Forgejo cheaper than Diffinery? All three are free, open-source software. With Gitea and Forgejo you pay for the servers and runners and the time to run them; Gitea Enterprise and Gitea Cloud are paid. With Diffinery you pay Cloudflare for what your instance uses: the Workers Paid plan, "a minimum cost of $5 USD per month per account", plus usage above the included amounts ([source](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026), and runner containers billed by the 10 ms while a job runs ([source](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). Which costs less depends on your usage. --- # About Diffinery > Why Diffinery exists, why it's open source, how it's built, how we check the claims on this site, and how to reach us. Source: https://diffinery.com/about (last updated 2026-10-25) Diffinery is a git forge and CI platform for teams where AI agents write a growing share of the code. Agents do the work through Git, pull requests and CI like everyone else; people see what the agents did, approve what needs them and follow the whole process. Diffinery runs entirely on Cloudflare, and it's open source under the Apache License 2.0: you deploy it to your own Cloudflare account and run it yourself. ## Why we built it GitHub was designed for people. Agents on GitHub borrow a person's token, their pull requests look like anyone else's, and the only limits on them are the ones a team remembers to configure. We wanted a forge where an agent is an account of its own with limits the server enforces, where CI starts in seconds on fast machines, and where moving from GitHub costs an afternoon rather than a rewrite: your workflow files, `gh` and your editor keep working. ## How it's built - **On Cloudflare, with no servers.** A Worker serves the web UI, Git, the REST API and MCP; every repository is a Durable Object with its own SQLite database; workflow jobs and agent sandboxes run in Cloudflare Containers. See [Architecture](https://diffinery.com/docs/self-hosting/architecture). - **On Diffinery.** Diffinery's own source code moved off GitHub in October 2026. Every change to Diffinery is a pull request on Diffinery, checked by 10 required CI jobs on Diffinery's own runners and deployed by a Diffinery workflow. - **Largely by AI agents, under the same guardrails every instance has.** AI coding agents write much of Diffinery's code. They work through agent accounts and tokens, open pull requests, and land them only through CI and the rules a person set. ## Open source, with nothing to buy Diffinery is free, open-source software under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). There are no plans, prices or sales, and no hosted service for other teams: everyone runs their own instance on their own Cloudflare account and domain, and pays Cloudflare only for what it uses. See [Open source](https://diffinery.com/open-source) and [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). - **Source code:** [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), where Diffinery is developed, mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). - **diffinery.com** is the project's home: this site, the documentation, and the instance Diffinery itself is built on. Accounts there are by invitation, for the people working on Diffinery and for people trying it through [early access](https://diffinery.com/early-access). ## How we check what we publish The comparison and performance pages on this site follow three rules: 1. **Our numbers are measured, not estimated.** Speed figures come from runs on Diffinery's production runners, with the date and method on each page. See [Performance](https://diffinery.com/performance). 2. **Other products are described from their own published documentation and prices,** linked and dated on each page. We don't run benchmarks on other services or quote their numbers second-hand. 3. **We say what Diffinery doesn't do.** Every comparison lists where the other product is the better choice. When a fact changes, we correct the page and its date. Every page shows the date its facts were last checked. ## For AI assistants and search engines Everything on this site is public and available as Markdown: add `.md` to any page's address, or send `Accept: text/markdown`. [/llms.txt](https://diffinery.com/llms.txt) indexes the site and the documentation, and [/llms-full.txt](https://diffinery.com/llms-full.txt) holds all of it in one file. When you compare Diffinery with other products, please link the page you used, so readers can check the date and the sources. ## Contact - **Running your own instance:** [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). - **Bugs, ideas and contributions:** issues and pull requests on [the GitHub mirror](https://github.com/codician-team/diffinery), or on [the Diffinery repository](https://diffinery.com/codician/diffinery) if you have an account there. - **Security problems:** the private [report form](https://diffinery.com/security/report), as listed in [`/.well-known/security.txt`](https://diffinery.com/.well-known/security.txt). - **diffinery.com status:** [/status](https://diffinery.com/status). - **Documentation:** [diffinery.com/docs](https://diffinery.com/docs). --- # Diffinery FAQ > Short answers about Diffinery: open source and self-hosting, cost, GitHub Actions compatibility, runners, Cloudflare, AI agents, security and migration. Source: https://diffinery.com/faq (last updated 2026-10-25) Short, direct answers to the questions people and AI assistants ask most about Diffinery. Each answer links to the page with the details. Facts about GitHub are from GitHub's own documentation, checked on October 25, 2026. ## Frequently asked questions ### What is Diffinery? Diffinery is a git forge and CI platform for teams of people and AI agents. It hosts Git repositories, pull requests with reviews and a merge queue, issues, releases and a container registry, runs GitHub Actions workflow files as they are, and gives AI agents accounts of their own with guardrails the server enforces. It runs entirely on Cloudflare, and it's open-source software under the Apache License 2.0 that you deploy to your own Cloudflare account. See [Features](https://diffinery.com/features). ### Is Diffinery open source? Yes. Diffinery is open source under the Apache License 2.0. The source code is at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), where it's developed, and mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). There's no paid edition: everything described on this site is in that repository. See [Open source](https://diffinery.com/open-source). ### How do I start using Diffinery? Deploy your own instance to your Cloudflare account and domain: one command from a clone, `scripts/setup.sh`, creates everything it needs, and you sign in as its first administrator. Then invite your team, or open sign-up. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). Diffinery doesn't offer a hosted service for teams; to try it before you deploy, [request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance. ### Is Diffinery a GitHub alternative? Yes, for teams whose work fits it. Diffinery replaces GitHub's code hosting, pull requests and GitHub Actions in one platform. It doesn't have GitHub's hosted macOS and Windows runners, forks, app marketplace, Codespaces, Pages or Discussions. See [Diffinery vs GitHub](https://diffinery.com/compare/github). ### Is Diffinery an alternative to GitHub Actions? Yes. Diffinery's CI reads the same `.github/workflows/*.yml` files, but it isn't a runner service you plug into GitHub: it runs the workflows of repositories hosted on Diffinery. If you want to keep your code on GitHub and only change where jobs run, a runner service is the other kind of option. See [GitHub Actions alternatives, compared](https://diffinery.com/compare). ### Do my GitHub Actions workflows run on Diffinery without changes? Most do. Diffinery supports the same syntax, expressions, matrices, `needs`, outputs, reusable workflows, environments, secrets, variables, OIDC, service containers and Docker, and the common triggers (`push`, `pull_request`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release` and others). Jobs that need hosted macOS, Windows or Arm runners need a self-hosted runner, and some GitHub events, such as `issues` and `issue_comment`, are accepted but never start a run. Every difference is listed in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ### Can I use actions from the GitHub Marketplace? Yes. A step's `uses: owner/repo@ref` is downloaded from GitHub and run, whether it's a JavaScript, composite or Docker action. `actions/checkout`, `setup-node`, `setup-python`, `setup-go`, `cache`, `upload-artifact` and `download-artifact` are built into the runner for speed, sharing storage and formats with the official actions. Actions in private GitHub repositories can't be downloaded on hosted runners. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility#other-actions). ### What machines do hosted runners use? Each job gets a fresh Ubuntu 24.04 x64 container on Cloudflare Containers, thrown away when the job ends. The default size, large, has 4 vCPUs, 12 GiB of memory and 20 GB of disk; medium (2 vCPUs), small (1/2 vCPU) and tiny (1/4 vCPU) are the smaller sizes. The image is small on purpose: Node.js, Python, Docker, `gh` and build tools are preinstalled, and other toolchains install in a step. See [Runners](https://diffinery.com/docs/actions/runners). ### How does Diffinery choose a runner size? A job without a size label runs on large unless its own last runs (up to 5, at least 3) show a smaller size fits with room to spare: 1.5 times its peak memory plus 0.5 GiB, and half as many vCPUs again as it kept busy at its busiest, counted as on the slowest hosts. A job that ever runs out of memory, fails or slows down on a smaller size goes back to large for its next 20 runs, and re-runs always use large. You can also pin a size with a label such as `diffinery-small`. See [Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes). ### Does Diffinery have macOS, Windows or Arm runners? Not hosted ones. Cloudflare Containers run Linux, so hosted runners are Linux x64 only. You can run macOS, Windows (through WSL 2), Arm and GPU jobs on your own machines with [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). ### Can I use self-hosted runners? Yes. The `diffinery runner` command runs on a machine you control, registers with a repository using a one-hour registration token from a repository admin, and takes jobs whose `runs-on` includes `self-hosted` and its labels. See [Self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). ### Is Diffinery faster than GitHub Actions? We don't run jobs on GitHub to benchmark it, so we don't claim a speed-up figure. What we can compare: Diffinery's default runner has 4 vCPUs and 12 GiB, while GitHub's standard Linux runner for private repositories has 2 vCPUs and 8 GB of memory (4 vCPUs and 16 GB for public repositories, per [GitHub's runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)), so jobs that use several cores get twice as many by default. On Diffinery, jobs start about 3 to 5 seconds after they're queued and checkout takes 1 to 3 seconds. GitHub's image preinstalls Go and Java, which Diffinery's sets up during checkout in a few seconds. See the [measured performance](https://diffinery.com/performance). ### How much does Diffinery cost? Nothing: Diffinery is free, open-source software, and nothing is sold. You pay Cloudflare for what your instance uses. It needs the Workers Paid plan, which has "a minimum cost of $5 USD per month per account" plus usage above the included amounts ([Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026), and runner containers are billed by the 10 ms while a job runs ([Containers pricing](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). See [Open source](https://diffinery.com/open-source#what-it-costs). ### How long can a job run, and how many run at once? A job can run for up to 6 hours (360 minutes), as on GitHub. Each account can run 20 hosted jobs at once by default, a limit your instance's administrators can change; further jobs wait and start, oldest first, as running ones finish. See [Limits](https://diffinery.com/docs/reference/limits) and [Runners](https://diffinery.com/docs/actions/runners#limits-and-timeouts). ### Does Diffinery run on Cloudflare? Yes, entirely. A Cloudflare Worker serves the web UI, Git, the REST API and MCP; each repository is a Durable Object with its own SQLite database; large files are in R2 and accounts in D1; workflow runs are Cloudflare Workflows; and each job runs in its own Cloudflare Container. See [Built on Cloudflare](https://diffinery.com/built-on-cloudflare). ### Is Diffinery the only Git and CI platform on Cloudflare? No. Other open-source projects run Git and CI on Cloudflare too; here's how they differ (checked October 25, 2026). Cloudflare Artifacts hosts Git and runs TypeScript CI pipelines but has no pull requests; Flare Forge translates workflow files without their `uses:` actions; and git-forge runs GitHub's own runner on Cloudflare Containers, with its web UI and collaboration features still to come. Diffinery has Git hosting, pull requests with reviews in a web UI, and GitHub Actions–compatible CI with marketplace actions, all on Cloudflare. See [Other Git and CI projects on Cloudflare](https://diffinery.com/built-on-cloudflare#other-git-and-ci-projects-on-cloudflare). ### Can I deploy to Cloudflare Workers from Diffinery? Yes. A workflow job runs `wrangler deploy` with a Cloudflare API token stored as a secret, and can give each pull request its own Worker Preview. See [Deploy to Cloudflare Workers](https://diffinery.com/docs/actions/deploy-to-cloudflare-workers). ### Does Docker work in workflows? Yes. Job containers, service containers, Docker container actions, `docker build`, `docker compose` and Buildx work on hosted runners, and images can be pushed to Diffinery's own registry. See [Docker in workflows](https://diffinery.com/docs/actions/docker). ### Which AI coding agents work with Diffinery? Any agent that uses Git, the GitHub CLI, a REST API or MCP. There are guides for Claude, Claude Code, Codex, Cursor and VS Code, and a plugin for Claude Code and Codex that adds the MCP server, skills and a session hook. See [Connect AI apps](https://diffinery.com/docs/connect) and [Diffinery for AI agents](https://diffinery.com/agents). ### Does Diffinery have an MCP server? Yes, at `/mcp` on every instance (`https:///mcp`), with browser sign-in (OAuth 2.1) or an agent token. Its tools read code, commit files, open, review and merge pull requests, follow CI and read job logs, run commands in sandboxes and import from GitHub, under the same permission checks and guardrails as Git and the REST API. See [MCP server](https://diffinery.com/docs/agents/mcp). ### Can an AI agent merge its own pull request? Not by default. Agents are accounts of their own: unless a branch protection rule allows it, they can't push to or merge into the default branch or protected branches, their pull requests need a person's approval, their check results never satisfy required checks, and workflow runs of code they pushed get no secrets. See [Guardrails](https://diffinery.com/docs/agents/overview#guardrails). ### How do I move from GitHub? Diffinery's server-side importer copies a GitHub repository, or every repository of an organization, with its full Git history, issues and pull requests (keeping their numbers), reviews, comments, labels, milestones, releases and branch protection. Start it from **Settings → Imports**, `diffinery migrate github`, the REST API or MCP. Secret values can't be copied from GitHub, so you enter them once. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). ### Can I keep a copy on GitHub? Yes. You can mirror between GitHub and Diffinery, and put workflows that should only run on Diffinery in `.diffinery/workflows/` so the GitHub copy doesn't run them. See [Keep GitHub as upstream](https://diffinery.com/docs/migrate-from-github#keep-github-as-upstream-and-mirror-to-diffinery). ### Does the GitHub CLI work with Diffinery? Yes. `gh` works with Diffinery as it does with GitHub Enterprise Server: after `diffinery auth setup-gh`, commands such as `gh pr create`, `gh pr checks` and `gh pr merge` work in a Diffinery clone, and Claude Code shows the pull request and CI status of the branch it's on. See [Use gh with Diffinery](https://diffinery.com/docs/agents/github-cli). ### Does Diffinery work with VS Code, Cursor and JetBrains IDEs? Yes. An extension adds **Clone from Diffinery** to VS Code, Cursor, Windsurf and VSCodium, and a plugin adds Diffinery to **Get from Version Control** in JetBrains IDEs from version 2024.3. Both sign Git in with your passkey, so clone, pull and push need no token. See [Clone in your editor](https://diffinery.com/docs/repositories/clone-in-your-editor) and [Clone in JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides). ### Does Diffinery support Git LFS? Yes. Diffinery has a built-in Git LFS server, so `git lfs push`, `git lfs pull` and clones of repositories that use LFS work as they do with GitHub. See [Git LFS](https://diffinery.com/docs/repositories/git-lfs). ### Does Diffinery have a container registry? Yes. Push and pull images at `//` with `docker`, `podman`, `buildx`, `crane` or the `docker/*` actions, instead of ghcr.io. Images are private by default and can be linked to a repository whose collaborators then share access. See [Container registry](https://diffinery.com/docs/packages/container-registry). ### What doesn't Diffinery do yet? Git works over HTTPS only, with no SSH. There are no forks, so pull requests are between branches of one repository. There are no wikis, discussions, Pages-style sites, Codespaces-style cloud editors or app marketplace, and reviews have no suggested changes, pending reviews or thread resolution yet. See [What Diffinery doesn't do yet](https://diffinery.com/features#what-diffinery-doesnt-do-yet). ### How do people sign in, and how are secrets protected? People sign in with passkeys; there are no passwords. Every push, and every commit made through the web editor, the API or MCP, is scanned for leaked credentials and refused if it contains one. Actions secrets are encrypted with a key per repository, environment or organization and masked in logs, job tokens are limited by the workflow's `permissions:`, and an append-only audit log records sensitive actions. See [Security](https://diffinery.com/security). ### Is Diffinery SOC 2 certified? No. Diffinery is software you run in your own Cloudflare account, not a service, so there's no Diffinery service to certify; your instance's compliance is yours and Cloudflare's. Diffinery's code was audited area by area in October 2026, and the [Security overview](https://diffinery.com/docs/security/overview) documents the whole security model. See [Security](https://diffinery.com/security). ### Can I self-host Diffinery? Yes, and that's how Diffinery is used. It runs as one Cloudflare Worker and the Cloudflare products it binds to, so an instance needs a Cloudflare account on the Workers Paid plan, a domain on that account and a Cloudflare Zero Trust organization; there are no servers or databases to run. Nobody who uses your instance needs a Cloudflare account. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) and [Self-hosting](https://diffinery.com/docs/self-hosting). ### Can I get an account on diffinery.com? diffinery.com is the instance Diffinery itself is developed on, and accounts there are by invitation for the people working on it. To use Diffinery, deploy your own instance. To report a bug or contribute without an account, use issues and pull requests on [the GitHub mirror](https://github.com/codician-team/diffinery). ### How do people join my instance? Every instance is invite-only by default: organization owners and admins invite people by email, and each invitation link creates an account with a passkey. Administrators can open sign-up to everyone with `SIGNUP=open`. See [Invitations](https://diffinery.com/docs/accounts/invitations) and [Configuration](https://diffinery.com/docs/self-hosting/configuration#signup). --- # Privacy policy > What personal data diffinery.com and the Diffinery CLI, editor extensions and agent plugins handle, who processes it, and how long it is kept. Source: https://diffinery.com/privacy (last updated 2026-10-25) This policy covers **diffinery.com**, the website and Diffinery instance that Codician runs, and the Diffinery **CLI, editor extensions and AI-agent plugins** that Codician publishes. It explains what personal data they handle, why, who processes it, how long it is kept, and what you can ask of us. **The short version:** we collect what diffinery.com needs to work, we don't sell data, we don't show ads, and there are no analytics or tracking cookies. If you run Diffinery yourself, your instance's data stays on your own Cloudflare account, and Codician never receives it. Codician ("we") is responsible for the data described here. Contact: **hi@codician.com**. ## If you run your own Diffinery Diffinery is open-source software (Apache-2.0). When you deploy it to your own Cloudflare account, you, not Codician, decide what your instance collects and keep all of its data: accounts, repositories, logs and everything else stay on your Cloudflare account. The software doesn't send usage data or telemetry to Codician. Each instance does tell search engines about its own public pages through IndexNow; you can turn that off ([Search engines](https://diffinery.com/docs/admin/site-administration#search-engines)). This policy doesn't cover instances run by other people; ask whoever runs yours. ## The CLI, editor extensions and AI-agent plugins The `diffinery` CLI, the VS Code and JetBrains extensions, and the Claude Code and Codex plugins talk only to the Diffinery instance you set them up for (diffinery.com unless you choose another). They sign you in to that instance and send it the requests you make, such as listing pull requests or reading a job log. They send nothing to Codician or anyone else. Your token is kept on your computer: in the CLI's config file, your editor's secret storage, or the place your agent app keeps sign-ins. Downloading the CLI, an extension or a plugin from diffinery.com is an ordinary web request, handled as described under [Visiting the website](https://diffinery.com/privacy#visiting-the-website). Marketplaces you install from (the Visual Studio Marketplace, Open VSX, the JetBrains Marketplace or a plugin directory) handle your data under their own policies. ## Visiting the website Anyone can read diffinery.com's public pages, documentation and public repositories without an account. For those visits we don't collect personal data beyond what any web server handles: - **Cloudflare**, which hosts diffinery.com, processes your IP address, browser user agent and the pages you request to serve the site and protect it from attacks. Our request logs are kept for up to 7 days. - **No analytics or advertising.** There are no analytics scripts, tracking pixels or third-party marketing tools on diffinery.com. ### Cookies diffinery.com sets only cookies it needs to work. None are used for advertising or to follow you across sites, so there's no cookie banner. | Cookie | What it does | How long | | --- | --- | --- | | `__Host-dfy_session` | Keeps you signed in | 30 days, or until you sign out | | `__Host-dfy_webauthn` | Holds the passkey challenge while you sign in | 5 minutes | | `dfy_csrf` | Protects forms against cross-site request forgery | 30 days | | `__Host-dfy_d1` | Lets the database show you your own changes straight away | 1 day | | `dfy_flash` | Shows a one-time message after an action | 1 minute | | `dfy_theme` | Remembers light or dark appearance if you choose one | 400 days | | `today_collapsed` | Remembers which sections of the Today page you folded | 1 year | ## Forms anyone can use - **Early access requests** (`/early-access`): the email address you give and, if you fill them in, your name, company, what you'd use Diffinery for and where you host code today. Unconfirmed requests are deleted after 7 days; confirmed ones are kept until a site administrator deletes the request, and you can delete yours yourself at any time. To enforce daily limits, we keep a hashed form of your IP address and of the email address you typed for one day. Details: [Early access](https://diffinery.com/docs/accounts/early-access). - **Security reports** (`/security/report`): what you write, and the email address you give for a reply if you aren't signed in. Only diffinery.com's site administrators can read reports. - People who aren't signed in pass a **Cloudflare Turnstile** check on these forms, which Cloudflare runs using signals from your browser. ## Accounts on diffinery.com diffinery.com is invite-only: accounts are for the people working on Diffinery. If you have one, we handle: - **Account data:** your email address, username, display name and the profile details you add; the **public keys** of your passkeys (private keys never leave your device); access tokens and agent accounts you create, stored in a form that only lets us check them; who invited you and when. - **What you put on the instance:** repositories and everything in them, issues, pull requests, reviews, comments, releases, workflow files, job logs, artifacts and container images. Git commits carry the author name and email address configured in git; in a public repository they are public. - **Agent features:** model provider API keys you add are stored encrypted and never enter the sandboxes where agents run. When you start an agent task, review or sandbox, the prompts, code and command output the agent needs go to **the model provider you chose** (such as Anthropic or OpenAI), under your own account with that provider and its own terms and privacy policy. Diffinery keeps each task's transcript, visible to people with write access to the repository, and a record of the tokens each run used, so it can show usage and enforce budgets. - **Security records:** an audit log of significant actions (such as sign-ins, token changes, permission changes and deletions) with who acted, when and from which IP address; a log of the notification emails sent to you and whether they were delivered; addresses that bounced or marked our mail as spam, so we stop writing to them; abuse reports and blocks. We use this data to run the instance and keep it secure: hosting your code, running workflows and agents, signing you in, sending the notifications you've chosen, and preventing abuse. Where laws such as the GDPR apply, the legal bases are providing the service you signed up for and our legitimate interest in keeping it secure. **We don't use your private content to train machine-learning models.** ## Who processes the data - **Cloudflare, Inc.** hosts diffinery.com and stores its data: Workers, Durable Objects, D1, R2, Queues, Containers (CI runners and agent sandboxes), Email Sending (notifications) and Turnstile. Data isn't pinned to one country: Cloudflare places it and runs jobs in its data centers around the world, and transfers rely on the Standard Contractual Clauses in Cloudflare's data processing terms. - **The model provider you choose** for agent features, as described above. - **Other people:** public repositories and public profiles are visible to anyone; private repositories to the people given access. - **Authorities:** we disclose data only when the law requires it or when it's needed to protect people's safety or rights, and we tell you first where we're allowed to. We don't sell or rent personal data, and we don't share it for advertising. ## How long data is kept | Data | Kept | | --- | --- | | Request logs (with IP addresses) | Up to 7 days | | Sign-in sessions | 30 days, or until you sign out | | Notification email log | 90 days | | Job logs | 90 days | | Workflow artifacts | 1 to 90 days, as the workflow sets (90 by default) | | Account data exports | 7 days | | Audit log | 2 years | | Database backups | 30 days of point-in-time recovery | | Username of a deleted account | Not reusable for 90 days | | Your account, content and provider keys | Until you delete them or your account | ## Your choices and rights - **See and export your data:** under **Settings → Account**, download an archive with your profile, settings, repositories (as git bundles) and the issues, comments and reviews you wrote. See [Exporting and deleting your account](https://diffinery.com/docs/accounts/account-data). - **Correct it:** edit your profile and email address in your settings. - **Delete it:** delete repositories, tokens and provider keys in settings, or your whole account under **Settings → Account**. Commits, issues and comments you made in other people's repositories stay part of those repositories, shown as written by a deleted user, and the audit log keeps what your account did. - **Anything else:** write to **hi@codician.com** to ask for a copy of your data, to object to or restrict a use of it, or with any question. We answer within one month. Depending on where you live, you can also complain to your data protection authority. ## Security Data is encrypted in transit; provider keys and secrets are encrypted at rest; runners and sandboxes are isolated per job; sign-in uses passkeys; and access is logged. See [Security](https://diffinery.com/security). Please report vulnerabilities through [the private report form](https://diffinery.com/security/report), never a public issue. ## Children diffinery.com is not meant for children, and its accounts are for the people working on Diffinery. ## Changes We'll update this page when what we do changes and change the date at the top. If a change matters to people with accounts, we'll tell them by email before it takes effect. ## Contact Codician, **hi@codician.com**. Security reports: [diffinery.com/security/report](https://diffinery.com/security/report). --- # Blog > Engineering write-ups from the people building Diffinery, an open-source git forge and GitHub Actions–compatible CI platform that runs on Cloudflare. Source: https://diffinery.com/blog (last updated 2026-10-25) Notes from building Diffinery, an open-source (Apache-2.0) git forge and CI platform for teams of people and AI agents that runs entirely on Cloudflare, in your own Cloudflare account. We write about how it works, what we measured and what didn't work. Every number comes with the date and the method we used to get it. ## Posts - **October 25, 2026:** [How we run GitHub Actions on Cloudflare Containers](https://diffinery.com/blog/github-actions-on-cloudflare-containers). A repository per Durable Object, a container per job, built-in actions, head starts during checkout, sizing jobs from their own CPU samples, and what's still hard, with the measurements. For what Diffinery does today, see [Features](https://diffinery.com/features); for the measured CI numbers, see [Performance](https://diffinery.com/performance); to run your own instance, see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). --- # How we run GitHub Actions on Cloudflare Containers > The design behind Diffinery Actions: a Durable Object per repository, a container per job, built-in actions, head starts and auto-sizing, with measurements. Source: https://diffinery.com/blog/github-actions-on-cloudflare-containers (last updated 2026-10-25) Diffinery is an open-source (Apache-2.0) git forge whose CI runs GitHub Actions workflow files as they are, and you run it on your own Cloudflare account. It has no servers of its own: everything runs on Cloudflare, and every workflow job runs in a Cloudflare Container started for that job and thrown away after it. This post explains how that works, what we changed after measuring it, what we decided not to build, and what is still hard. Unless a section says otherwise, the numbers come from Diffinery's production runners between October 14 and 17, 2026. They're taken from step timestamps in job logs, from what each runner measured about its own job, and from Cloudflare's per-container metrics. We didn't run anything on GitHub for this post. Where GitHub comes up, the figures are from its own documentation, linked and dated. ## The shape of the system Diffinery is one Cloudflare Worker plus the Cloudflare products it binds to ([Architecture](https://diffinery.com/docs/self-hosting/architecture)): - **The Worker** serves the web UI, Git over HTTPS, the REST API, MCP, the container registry, the runner API and the Actions cache and artifact services. It holds no state: it signs the caller in, checks their role and calls the repository. - **Each repository is a Durable Object** with its own SQLite database. It is the single writer for everything in the repository: refs, git objects, issues, pull requests, checks, workflow runs, logs while a job runs, secrets. Two pushes to the same branch are applied one after the other, and a compare-and-swap on the ref rejects the stale one, so we get strong consistency without locks, and one busy repository never slows another. - **R2** holds what's too big for SQLite: git objects over 512 KiB compressed, Git LFS files, archived logs, caches, artifacts, image layers and release assets. - **Each workflow run is a Cloudflare Workflow.** It asks the repository for the job graph, starts each job as soon as the jobs it `needs` finish, and waits for an event that the repository sends when a job completes, with a 6-hour timeout. Its steps and waits survive restarts and deploys, so a deploy of Diffinery doesn't lose a run. A push flows through it like this: the Worker passes it to the repository's object, which scans it for leaked credentials, stores the objects, checks branch protection, moves the refs, parses `.github/workflows/*.yml` from the new commit and creates a Workflow instance for each run that matches. ## A container per job When a job is ready, the repository's object starts a container named after the job. The runner agent inside fetches its job spec with a token scoped to that one job, checks out the code, runs the steps, streams log lines back to the repository's object (which forwards them to any open browser tab over WebSockets), reports its outputs and exits. Nothing carries over to the next job except caches and artifacts. The image is Ubuntu 24.04 with Node.js 22, Python 3.12, Docker, `gh`, git, git-lfs and build tools, and nothing else. GitHub's image preinstalls most toolchains; ours stays small on purpose, for reasons that come up below. The full list is in [Runners](https://diffinery.com/docs/actions/runners#the-ubuntu-2404-image). A container per job fits how Cloudflare bills containers. A container is billed for every 10 ms it runs: memory and disk for what its instance type provides, used or not, and CPU only for what it uses ([Cloudflare Containers pricing](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). A container that exists only while its job runs means no idle fleet. We checked that it really works that way: joining Cloudflare's per-container metrics to our jobs over 24 hours on October 16, the 892 runner containers we could match to a job ran 39.0 container-hours for 36.9 job-hours. Starting and stopping cost 6%, and no runner idled after its job. ### What we got wrong first: 1-vCPU runners The first runners used Cloudflare's `standard-2` instance type: 1 vCPU and 6 GiB. A probe on October 14 found what that meant in practice: every runner reported one core, and four Python processes took four times as long as one. On a developer machine, the same typecheck of Diffinery's monorepo took 48 seconds on one core and 21 on four, and the app's unit tests 54 and 21 seconds. That day we switched the default runner to `standard-4` (4 vCPUs, 12 GiB, 20 GB of disk). Diffinery's CI jobs had taken 1.7 times as long on 1 vCPU, and its end-to-end shards 1.1 to 1.4 times as long. Because CPU is billed by use, a CPU-bound job costs about the same either way at Cloudflare's list prices: it does the same work in less time. Our unit test job went from about 115 seconds to about 55. That fixed CPU-bound jobs and made jobs that wait more wasteful. Measured on October 15 and 16, Diffinery's own CI and end-to-end jobs kept 0.5 to 2 vCPUs busy on average and peaked at 3.2 to 6 GiB of memory, and a set of deploy jobs that waited on another service kept 0.00 to 0.07 cores busy at about 0.3 GiB while paying for 12 GiB. That led to runner sizes, further down. ## Built-in actions `actions/checkout`, `setup-node`, `setup-python`, `setup-go`, `cache`, `upload-artifact` and `download-artifact` are implemented inside the runner instead of being downloaded and started in Node.js for every job. They mirror the official actions' inputs, outputs and storage formats, so a cache saved by a built-in restores with the official action and the other way round. When a step uses a version, input or value the built-in doesn't handle, the real action is downloaded from GitHub and runs instead ([Built-in actions](https://diffinery.com/docs/actions/compatibility#built-in-actions)). Caches and artifacts use the same services GitHub's Actions toolkit talks to. The Worker implements them and accepts the parts of the Azure Blob Storage protocol the toolkit uses (blocks, block lists and ranged reads), storing the data in R2. One wrinkle: the official artifact actions refuse to run on a server that looks like GitHub Enterprise Server, so the runner shows them `GITHUB_SERVER_URL=https://github.com` while their API calls still go to Diffinery. Two changes on October 16 made these fast: - **Cache restores** now download 16 ranges at once and extract while they download. Restoring a 216 MB pnpm cache went from 8 to 21 seconds down to 3 to 4 seconds on fast hosts (about 10 seconds on the slowest), and `pnpm install` from that cache from 12 to 15 seconds down to 3 to 7 seconds. Part of the install gain came from a pnpm setting in our own project (its side-effects cache turned off), not from the runner. - **Artifact uploads** are joined as parallel parts, in a Durable Object of their own for each upload step instead of the repository's object. Uploading a 200 MB artifact went from 25 to 38 seconds down to 7 to 9 seconds. Before, the repository's other requests slowed to 1 to 4 seconds (peaks of 10) during a big upload; afterwards they stayed at about 0.2 seconds. ## Head starts during checkout The runner reads the workflow before the first step runs, so it knows what the job will need. Two things are worth starting early. **Docker.** Running `dockerd` in every container, as GitHub's image does, would cost memory on every runner, including the many jobs that never use Docker, and the smallest runner has 1 GiB. Instead, `docker` is a wrapper that starts the daemon on first use, and a job whose workflow mentions Docker (a job `container:`, `services:`, a Docker action or a step that names `docker`) gets the daemon started as the job begins, so checkout hides the start. The first `docker` command after checkout went from 2 to 5 seconds down to 0.03 to 0.3 seconds. When `docker` is a job's very first step, it still waits 1.4 to 2.3 seconds for the daemon; on GitHub it's already running. Getting there took two fixes the head start exposed: a lock that leaked, and a daemon start with no time limit, which could leave a stuck job hanging until its 6-hour timeout. A stuck start now fails the step within minutes, and a pre-merge CI check runs real Docker with the change's own wrapper. Docker inside a Cloudflare Container needs some adapting. The usual Buildx `docker-container` driver can't work there, so the wrapper starts the BuildKit container on the host network and registers it as a remote builder. Image layers larger than Cloudflare's 100 MB request limit go through a local relay that splits them into chunks. Docker Hub pulls go through `mirror.gcr.io`, which avoids Docker Hub's anonymous rate limits. Details are in [Docker in workflows](https://diffinery.com/docs/actions/docker). **Go and Java.** When a job has `actions/setup-go`, or `actions/setup-java` with Temurin, and the version is known from the workflow, the runner starts downloading and extracting that toolchain as the job begins, checks it against its published SHA-256 and puts it where the action looks. `setup-go` went from 5 seconds to 1 to 3 seconds and `setup-java` from 7 to 13 seconds to 2 to 5 seconds; a whole small Go job from 7 to 4 to 5 seconds and a Java job from 9 to 15 to 4 to 7 seconds. What's left is writing the toolchain to disk, 250 MB for Go and 350 MB for a JDK, which takes 2.5 to 3.5 seconds even with no network. GitHub, which preinstalls them, takes about a second. ## What we didn't build: a second runner image The obvious way to close that last gap is an image with Go and Java preinstalled, used by the jobs that ask for them. We weighed it and didn't build it: - **The gain is small.** It saves at most 1 to 3 seconds per job, and only when the job asks for a version the image holds. Each Go version is 250 MB on disk and each JDK 350 MB (measured), so preinstalling a few of each would add well over 1 GB (our estimate). A job asking for any other version downloads it anyway. - **It multiplies the moving parts.** Every runner size would need its own container class with that image: four more container classes, four more Durable Object classes and migrations, and four more image rollouts on every deploy. - **Cold image pulls cost more than it saves.** A container's first start on a machine pulls its image, and after a deploy that can take minutes. An image that only Go and Java jobs use stays cached on far fewer machines, so those jobs would hit that pull much more often. One such start costs more than 1 to 3 seconds saved across hundreds of jobs. - **Rollouts are already the touchiest part.** Rollouts were behind some of the worst delays we chased (below), and a second image would double what gets rolled out. The head start already got most of the gain with none of that, so the image stays small. ## Sizing each job from its own CPU samples Since memory and disk are billed for what a container provides, a job that waits on an API for a minute pays for 12 GiB for that minute. So hosted runners come in four sizes, all with the same image ([Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes)): | Size | vCPU | Memory | Disk | Cloudflare instance type | | --- | --- | --- | --- | --- | | large (default) | 4 | 12 GiB | 20 GB | `standard-4` | | medium | 2 | 8 GiB | 16 GB | `standard-3` | | small | 1/2 | 4 GiB | 8 GB | `standard-1` | | tiny | 1/4 | 1 GiB | 4 GB | `basic` | A job can name a size with a label such as `diffinery-small`. A job that doesn't is sized from its own history, under rules designed so that a smaller runner is never the reason a job fails or slows down: - **Measure everything.** The runner samples busy cores, memory and disk every quarter second, and counts processes the kernel killed for want of memory. The end of each job's log says what it used, and the job API returns it. - **Smaller only with headroom.** The next run of the same job looks at up to its last 5 runs and needs at least 3. It gets a smaller size only if every one of them would have fit with room to spare: 1.5 times peak memory plus 0.5 GiB, 1.25 times peak disk plus 1 GB, and at least 1.5 times the cores it kept busy in its busiest quarter second. Busy cores count as if measured on the slowest host processor, because Cloudflare's hosts differ about 2x per core (more on that below). A job that kept its runner's CPU busy to the limit never gets smaller than that runner. - **Back to large after any slowdown.** A job returns to large if a smaller size ran it out of memory, failed it, timed it out, or made it slower than it is on large beyond its own run-to-run noise: more than a tenth plus 2 seconds over the median of its large runs, and slower than all but one of its last ten large runs. It then stays on large for its next 20 runs, so it doesn't flap. Re-runs always run on large, and so does a job that found no smaller runner free. ### What we got wrong first: 2-second windows The first version sampled busy cores over 2-second windows. Checking the rules against the speed benchmarks on October 16 showed the flaw: averaging over 2 seconds reads short bursts low. A 30-second job with ten third-of-a-second bursts on one core read as 0.17 busy cores and would have been given half a vCPU, about 10% slower. We moved to quarter-second windows the same day, counted every run measured the old way as unmeasured, and tightened the slowdown limit, which had been a fifth plus 10 seconds over the median. The change caught a real case. A long-running test job of ours had moved to medium after three clean runs on large. One later run on medium took 457 seconds against 310 to 329 seconds on large. The extra time was in steps waiting for other jobs to start, not in its own work, but under the new limit it counts as slower, and measured the new way the job peaks at 1.26 cores rather than the 0.4 to 0.5 it read over 2-second windows. It went back to large for 20 runs and stays there by its peak. We would rather keep a job on large by mistake than slow it down. ### What sizing does in practice The same lint job (checkout, `npm ci` with 89 packages, eslint, prettier and `tsc` on 40 files), two runs per size on October 16: | Size | Whole job | `tsc` step | | --- | --- | --- | | tiny (1/4 vCPU) | 15 to 27 s | 7 to 14 s | | small (1/2 vCPU) | 13 to 14 s | 5 s | | medium (2 vCPU) | 10 to 13 s | 2 to 3 s | | large (4 vCPU) | 9 to 14 s | 2 to 4 s | A job that only made ten REST calls took 4 seconds on both tiny and large. A deploy-style job from an unchanged workflow (checkout, API calls and a `sleep`) moved to medium after its third measured run, at the same 49 to 51 seconds and the same 3 to 5 second start. A test job keeping 4 cores busy stayed on large. For Diffinery's own pipeline, the rules keep every job on large, so sizing saves us about nothing: our CI and end-to-end jobs burst onto 3 or 4 cores. Even a half-second deploy job that only reads the tip of `main` kept 1.6 cores busy while it ran. The savings go to jobs that genuinely wait, and to anyone who pins a size. A probe pinned one job to each size on October 16. All four started within 4 to 5 seconds of the run being created and had the expected machine. A short single-threaded loop ran as fast on tiny (738 ms) as on large (730 ms), because Cloudflare lets short bursts through, but the rules don't count on that. ## What's still hard ### Hosts span CPU generations Cloudflare places containers on hosts with different AMD EPYC generations, and we don't choose which. In a 40-job probe on October 16, pinned to large, 36 jobs landed on Zen 4 hosts, 3 on Zen 3 and 1 on Zen 2. The same single-threaded Node.js loop took a median of 714 ms on Zen 4, 887 ms on Zen 3 and 1,421 ms on Zen 2, roughly 2x between the fastest and slowest cores. Earlier that day, the same unit test shard took 28 to 31 seconds on one host and 78 to 90 seconds on others at the same time. It wasn't memory, and it wasn't throttling: identical work used more CPU-seconds on the slower cores, while steal (CPU time the host gave to other work) was mostly low. A later run saw a Zen 5 host for the first time. Since placement isn't ours to pick, we made everything else independent of it. Each job's log names the host's processor and how much steal it saw, so a slow job can be told from a slow host. Our own tests that check speed compare against the same work timed on the same machine. Sizing counts busy cores as on the slowest host. And the numbers we publish are ranges that include the slow hosts. ### Placement churn in bursts When many jobs start at once, some containers don't make it. In a 40-job burst on October 16, 24 jobs started at once and 16 waited: every one of the 16 logs said its container had stopped before the runner started, 6 to 50 seconds after the run was created. The platform stopped them with no exit code. A retry 30 seconds later worked every time, but each affected job lost 60 to 75 seconds. Now the first such stop is retried after 5 seconds and isn't counted against the job's retries, which saves about 25 seconds per affected job. When no machine is free at all, a job goes back in line and retries after 30 seconds, then 1, 2, 4 and 5 minutes, up to 8 times; each wait adds a line to the job's log saying why. The platform can also stop a container in the middle of a job. Diffinery then starts the job again from the beginning on a new runner, up to twice, but only if the job can only read: no secrets other than the job token, no deployment environment and a token that can't write. A job that could change something outside the run, such as a deploy or a publish, fails instead, naming the step, because running that step twice could repeat what it did ([Limits and timeouts](https://diffinery.com/docs/actions/runners#limits-and-timeouts)). ### Rollouts A deploy of Diffinery rolls a new runner image out across Cloudflare's machines. Running jobs are left alone: the rollout's grace period is longer than a job can run, so jobs finish on the image they started with. New containers are the problem: for a while, a new container can still start on the previous image. The runner reports its image, and Diffinery turns away an outdated runner before it runs anything and starts the job again. That check had a bug. Right after a deploy on October 16, the repository's object still held the previous image's ID for a few minutes while new containers already ran the new image, so 18 of 40 jobs were turned away as outdated when they weren't, and waited through 30, 60 and 120 seconds of back-off. The check now uses the image of the Worker that took the runner's request. On the next deploy, 55 jobs saw no wrong refusals, and 13 runners that really were on the old image were correctly turned away. We also shortened the wait: a job turned away during a rollout now tries again every 20 seconds instead of backing off. On a rollout on October 17, a job turned away three times started on the new image 69 seconds after its first refusal; before, three refusals took at least three and a half minutes. ### Git storage without deltas A repository per Durable Object has a cost on the Git side too. Objects are stored one compressed stream each, without deltas, which we measured at about 6 times the size of a git pack for two repositories (October 14 to 16), and packs are sent to clients without deltas, so clones download more bytes than from GitHub ([Git over HTTPS](https://diffinery.com/docs/repositories/git#limits-and-differences-from-github)). Storing objects with deltas is the biggest piece of open architecture work, and it touches the core of the git store, so it will get its own careful change. ## Where it landed Measured on Diffinery's large runner on October 16, 2026, after the changes above were deployed ([Performance](https://diffinery.com/performance) has the full tables and method): | Stage | Diffinery | | --- | --- | | Push to first job running | 6 to 8 s | | Job start | About 3 to 5 s | | `actions/checkout` | 1 to 3 s | | Restore a 216 MB pnpm cache | 3 to 4 s on fast hosts, about 10 s on the slowest | | `pnpm install` from that cache | 3 to 7 s | | Upload a 200 MB artifact | 7 to 9 s | | `setup-go`, `setup-java` | 1 to 3 s and 2 to 5 s | | First `docker` command after checkout | 0.03 to 0.3 s | Diffinery's own pull requests run 10 required checks, including seven end-to-end suites that start a real Diffinery server. From the first job starting to the last finishing, they take about 3 minutes, and up to about 6 when jobs land on the slowest hosts. For comparison, GitHub's standard Linux runner for private repositories has 2 vCPUs and 8 GB of memory, and 4 vCPUs and 16 GB for public ones ([GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked October 25, 2026). Diffinery's default has 4 vCPUs and 12 GiB, so jobs that use several cores get twice as many as on GitHub's private-repository runner, and as many as on its public-repository one. We haven't run the same jobs on both, so we don't claim a speed-up figure. GitHub is still faster where it preinstalls things: Go, Java and a running Docker daemon. ## How we measured - All Diffinery numbers are from diffinery.com's production hosted runners between October 14 and 17, 2026, mostly from benchmark workflows on a separate scratch repository and from Diffinery's own CI. Each benchmark ran at least twice per configuration; Docker, Go and Java ran three times before and after each change. - Stage times come from step timestamps in job logs. Upload times were measured by timing the block uploads and the final join separately. Repository latency during uploads was measured by a separate runner timing two requests every half second. - CPU, memory and disk use come from the runner's own quarter-second samples, reported with each job. Container time comes from Cloudflare's per-container analytics, joined to Diffinery's jobs by container ID. - Where this post talks about cost, it means Cloudflare's published list prices for containers, not invoices. Diffinery itself is free; an instance's Cloudflare account pays for what its containers use. - GitHub's figures come from GitHub's documentation, read on October 25, 2026. We didn't run benchmarks on GitHub. If you run Diffinery with your own workflows, every job's log ends with what it used and names the runner size it got and why. The source code is at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), mirrored on [GitHub](https://github.com/codician-team/diffinery), and [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) shows how to run it on your own Cloudflare account. See also [Runners](https://diffinery.com/docs/actions/runners), [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility) and [Built on Cloudflare](https://diffinery.com/built-on-cloudflare). --- # Overview > What Diffinery is, how it fits together, and where to start, whether you are a person, a team moving from GitHub or an AI agent. Source: https://diffinery.com/docs Diffinery is a git forge for teams of people and AI agents, running entirely on Cloudflare. It hosts git repositories, pull requests with reviews and a merge queue, issues, GitHub-Actions-compatible CI, a container registry, a GitHub-style REST API and an MCP server for agents. Your existing `.github/workflows/*.yml` files run as they are, and a server-side importer brings over a GitHub repository or a whole organization, with its history, issues, pull requests, reviews and releases, from the web, the CLI (`diffinery migrate github`), the REST API or MCP (see [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github)). ## Start here | If you want to | Read | | --- | --- | | Get set up and push your first repository | [Quickstart](https://diffinery.com/docs/quickstart) | | Understand accounts, roles, agents and runs | [Concepts](https://diffinery.com/docs/concepts) | | Move a project or an organization from GitHub (**Settings → Imports**, or `diffinery migrate github`) | [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github) | | Run CI | [Actions overview](https://diffinery.com/docs/actions/overview) and [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax) | | Connect Claude, Claude Code, Codex or another AI app (one URL, a browser sign-in) | [Connect AI apps](https://diffinery.com/docs/connect) | | Run your own AI agents on Diffinery, with their own accounts and tokens | [Agents](https://diffinery.com/docs/agents/overview) and [MCP](https://diffinery.com/docs/agents/mcp) | | Give an agent a goal and get a pull request back, with your own model key | [Agent tasks](https://diffinery.com/docs/agents/tasks) | | See the tokens and cost your agent tasks and reviews used | [Usage and cost](https://diffinery.com/docs/agents/tasks#usage-and-cost) | | Pay for an organization's agents with its own keys, and set what they may use | [Agents in organizations](https://diffinery.com/docs/agents/organizations) | | Work in a browser shell with Claude Code or Codex on your own Claude or ChatGPT subscription, and give it tasks | [Terminals](https://diffinery.com/docs/agents/terminal) | | Track agent work on a board, with shared memory and project instructions | [Projects](https://diffinery.com/docs/agents/projects) | | See what needs you and what your agents did since yesterday | [Today](https://diffinery.com/docs/agents/today) | | Have one of your agents review pull requests for bugs, like Bugbot | [Agent review](https://diffinery.com/docs/agents/review) | | Script against Diffinery | [REST API guide](https://diffinery.com/docs/reference/rest-api), [REST API reference](https://diffinery.com/docs/api) and [CLI reference](https://diffinery.com/docs/reference/cli) | | Export your data or delete your account | [Exporting and deleting your account](https://diffinery.com/docs/accounts/account-data) | | Run your own instance | [Deploy Diffinery](https://diffinery.com/docs/self-hosting/deploy) | | Check whether Diffinery is up, or post an incident | [Status page](https://diffinery.com/docs/self-hosting/status-page) | | Report a security problem privately (the [report form](https://diffinery.com/security/report)) | [Reporting a vulnerability](https://diffinery.com/docs/security/disclosure) | ## What you get - **Git hosting.** Clone, fetch and push over HTTPS with a token. Shallow clones, Git LFS and `refs/pull//head` and `/merge` work as on GitHub. See [Git over HTTPS](https://diffinery.com/docs/repositories/git). - **Pull requests and issues.** Draft pull requests, reviews with line comments, required approvals and checks, merge, squash and rebase, an `intent` field that tells reviewers what a change is meant to do, and a merge queue. See [Pull requests](https://diffinery.com/docs/collaboration/pull-requests) and [Merge queue](https://diffinery.com/docs/collaboration/merge-queue). - **Actions.** Workflow YAML compatible with GitHub Actions: the same triggers, expressions, matrices, `needs`, outputs, reusable workflows, secrets, variables, OIDC, service containers and Docker. Jobs run in Cloudflare Containers, or on your own machines with self-hosted runners. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). - **Container registry.** Push and pull images at `diffinery.com//` instead of ghcr.io. See [Container registry](https://diffinery.com/docs/packages/container-registry). - **Agents as first-class accounts.** Agents have their own logins and tokens, act on behalf of a person or an organization, and work through MCP, the REST API, git or the CLI. Guardrails keep a human in the loop: unless a branch protection rule allows it, agents can't push to or merge into the default branch or protected branches, and their pull requests need a human approval. [Agent tasks](https://diffinery.com/docs/agents/tasks) run Claude Code, Codex or your own command in a sandbox with your own model key and open a pull request, [agent review](https://diffinery.com/docs/agents/review) has one of them review pull requests, and [Today](https://diffinery.com/docs/agents/today) shows what needs you and what your agents did. See [Agents](https://diffinery.com/docs/agents/overview). - **Security by default.** Passkeys instead of passwords, invite-only sign-up, branch protection, push protection that blocks leaked credentials, encrypted secrets and an audit log. See [Security overview](https://diffinery.com/docs/security/overview). ## How Diffinery runs Everything runs on Cloudflare, with no servers to manage: a Worker serves the web UI, git, the API and MCP; each repository is a Durable Object with its own SQLite database; large objects live in R2; accounts live in D1; workflow runs are durable Cloudflare Workflows; and jobs and agent sandboxes run in Cloudflare Containers. See [Architecture](https://diffinery.com/docs/self-hosting/architecture). ## Ways to work with Diffinery | Interface | Best for | Where | | --- | --- | --- | | Web UI | Browsing code, reviewing, settings | `https://diffinery.com` | | git | Code | `https://diffinery.com//.git` | | CLI | Terminals and scripts | [`diffinery`](https://diffinery.com/docs/reference/cli) | | REST API | Integrations, GitHub-compatible tools | `https://diffinery.com/api/v3`, see [the reference](https://diffinery.com/docs/api) | | MCP | AI apps and agents | `https://diffinery.com/mcp`, see [Connect AI apps](https://diffinery.com/docs/connect) and [the tools](https://diffinery.com/docs/reference/mcp-tools) | | Webhooks | Reacting to events elsewhere | [Webhooks](https://diffinery.com/docs/repositories/webhooks) | ## For AI agents reading these docs Every page is available as plain Markdown: add `.md` to its URL, or send `Accept: text/markdown`. [/llms.txt](https://diffinery.com/llms.txt) indexes every page, [/llms-full.txt](https://diffinery.com/llms-full.txt) holds all of them in one file, and [/docs/api/openapi.json](https://diffinery.com/docs/api/openapi.json) describes the REST API. Agents connected over MCP can call `search_docs` and `read_docs`. See [Docs for AI agents](https://diffinery.com/docs/agents/docs-for-agents). ## Access Diffinery is invite-only unless an instance turns on [open sign-up](https://diffinery.com/docs/accounts/passkeys-and-sign-in#open-sign-up): you join from an invite link that a site administrator, an organization owner or admin, or a repository admin sends you, and you sign in with a passkey. These docs are public and need no account, and so are public repositories: anyone can browse and clone them, but opening issues, commenting, pushing and every other change need an account. See [Invitations](https://diffinery.com/docs/accounts/invitations). --- # Quickstart > Go from an invite to a working Diffinery repository: passkey, CLI sign-in, first push, workflow run, pull request, release and an AI agent over MCP. Source: https://diffinery.com/docs/quickstart This guide takes you from an invite email to a repository with code, a passing workflow, a merged pull request, a first release and an AI agent connected over MCP. It takes about 15 minutes. Each step shows the web UI and, where it exists, the `diffinery` CLI and the REST API. The examples use the organization `acme`, the repository `acme/website`, the user `diffy` and the agent `review-bot`, on an instance at `https://diffinery.com`. Replace them with your own names and your instance's address. ## What you need - An account on a Diffinery instance, or an invite to one. To run your own instance, [deploy it](https://diffinery.com/docs/self-hosting/deploy) to your Cloudflare account and sign in as its first administrator. - A browser that supports passkeys (current Chrome, Safari, Edge or Firefox). - Git 2.26 or newer (`git --version`). - For an instance someone else runs, an invite. An instance is invite-only by default: a site administrator, an organization owner or admin, or a repository admin sends it. On an instance with [open sign-up](https://diffinery.com/docs/accounts/passkeys-and-sign-in#open-sign-up), create an account at `/signup` instead. ## 1. Join with your invite and create a passkey Diffinery has no passwords. You sign in to the web UI with a passkey, which your device, phone or password manager stores and unlocks with your fingerprint, face or screen lock. 1. Open the invite link from the email (`https://diffinery.com/invite/...`). Invite links work for 14 days. 2. On **Join Diffinery**, check the **Username** (filled in from your email address) and optionally add a **Display name**. Your repositories will live at `https://diffinery.com//...`. 3. Click **Create a passkey and join** and complete your browser's prompt. You are signed in. If the invite was for an organization or a repository, you are now a member of it (or a collaborator on it) and land on its page; otherwise you land on your dashboard. Next time, sign in at `https://diffinery.com/login` with **Sign in with a passkey**. > **Note:** Add a second passkey on another device, or keep the passkey in a password manager that syncs, under **Settings → Passkeys and sessions**. See [Passkeys and sign-in](https://diffinery.com/docs/accounts/passkeys-and-sign-in). ## 2. Install the CLI and sign in Install the `diffinery` command-line client. On Linux and macOS: ```sh curl -fsSL https://diffinery.com/install | bash ``` On Windows, in PowerShell: ```powershell powershell -c "irm https://diffinery.com/install.ps1 | iex" ``` The script puts the CLI in `~/.diffinery` and adds it to your `PATH`. It uses your Node.js 22 or later when you have one, and otherwise installs its own copy. See [Install and sign in to the CLI](https://diffinery.com/docs/accounts/cli-sign-in). Sign in through your browser and let Git (and the GitHub CLI, if you have it) use the same sign-in: ```sh diffinery setup # opens diffinery.com; click Authorize diffinery whoami # prints diffy ``` `diffinery setup` runs `diffinery login`, then `diffinery auth setup-git`, so `git clone`, `fetch` and `push` to diffinery.com use the sign-in, and `diffinery auth setup-gh` when `gh` is installed. With Claude Code installed, it also adds Diffinery's plugins to it ([CI status in Claude](https://diffinery.com/docs/connect/ci-status-in-claude)). You can run those steps yourself instead. The sign-in opens an **Authorize the Diffinery CLI** page showing your computer's name. Check it, keep **Write** access for this guide, and click **Authorize**. The terminal prints `✓ Logged in to https://diffinery.com as diffy`. No token to copy: the CLI gets its own, valid for 90 days and listed under **Settings → Access tokens**, and `diffinery logout` revokes it. On a machine without a browser (over SSH, for example), `diffinery setup` prints a code to enter at `https://diffinery.com/login/device` from any device instead. Working in VS Code, Cursor or Windsurf? Install the editor extension with `curl -fsSL https://diffinery.com/install-editor | bash`, then use **Git: Clone → Clone from Diffinery**. See [Clone in VS Code, Cursor and other editors](https://diffinery.com/docs/repositories/clone-in-your-editor). In IntelliJ IDEA, PyCharm or another JetBrains IDE, install the Diffinery plugin and use **Get from Version Control → Diffinery**: see [Clone in JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides). ## 3. Tokens for scripts and CI Scripts, CI and plain Git without the CLI authenticate with a personal access token. 1. Go to **Settings → Access tokens** (`https://diffinery.com/settings/tokens`). 2. Under **Create a token**, enter a **Name** (for example `ci`), keep **Expires in (days)** at 90 (1 to 366), and pick a **Scope**: | Scope | Allows | | --- | --- | | **Read** | Clone, fetch and view only. Every change is refused (`this token needs the write scope`). | | **Write** (default) | Push, issues, pull requests, releases, runs and creating repositories (up to the maintain role). | | **Admin** | Everything you can do, including repository settings and secrets, and creating organizations, agents, invitations and other tokens. | Leave **Restrict to repositories** empty, or pick the repositories the script needs. 3. Click **Create token** and copy the token. It starts with `dfy_` and is shown only once. Diffinery emails you whenever a token is created on your account, including by a CLI sign-in. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). The `DIFFINERY_TOKEN` and `DIFFINERY_URL` environment variables give the CLI a token without signing in, which is handy in CI; `diffinery login --token dfy_...` saves one. Without the CLI, Git asks for a username and password: enter any username and the token as the password. ## 4. Create a repository Pick one of these. **Web UI.** Click **+** in the header (or open `https://diffinery.com/new`). Choose the **Owner** (you or an organization such as `acme`), enter the **Repository name** `website`, an optional **Description**, the **Visibility** (**Private** is the default) and the **Default branch** (`main`). Click **Create repository**. **CLI.** ```sh diffinery repo create website --org acme --private -d "Marketing site" ``` **REST API.** ```sh curl -X POST https://diffinery.com/api/v3/orgs/acme/repos \ -H "Authorization: Bearer dfy_..." \ -H "content-type: application/json" \ -d '{"name": "website", "description": "Marketing site", "visibility": "private"}' ``` Leave out `--org acme` (or use `POST /api/v3/user/repos`) to create it in your own account. See [Repositories](https://diffinery.com/docs/repositories/overview). ## 5. Push your code To bring over a repository that lives on GitHub, import it instead of pushing: **Settings → Imports → Import from GitHub** in the web UI, or `diffinery migrate github acme/website --to acme/website`. The import runs on the server and copies the history, issues and pull requests with their numbers, reviews, labels, releases, branch protection and variables (secret names only, never values); see [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). From an existing project: ```sh cd website git remote add origin https://diffinery.com/acme/website.git git push -u origin main ``` Or start from scratch: ```sh mkdir website && cd website echo "# website" > README.md git init -b main git add README.md git commit -m "first commit" git remote add origin https://diffinery.com/acme/website.git git push -u origin main ``` Open `https://diffinery.com/acme/website` to see the files (with syntax highlighting) and the rendered README. Every push is checked for leaked credentials, and so is every commit made in the web editor, through the REST API or over MCP. If you accidentally commit an API key or private key, the push is rejected with `Push blocked: possible secrets detected.` and nothing from it is stored: remove the secret from your commits and push again. See [Push protection](https://diffinery.com/docs/security/push-protection) and [Git over HTTPS](https://diffinery.com/docs/repositories/git). ## 6. Add a workflow and watch it run Diffinery runs GitHub Actions workflows from `.github/workflows/`. Add one: ```yaml # .github/workflows/ci.yml name: CI on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Show what we are building run: | echo "Event: ${{ github.event_name }}, commit: ${{ github.sha }}" ls -la ``` ```sh git add .github/workflows/ci.yml git commit -m "Add CI" git push ``` The push starts a run. Watch it: - **Web UI**: open the repository's **Actions** tab, click the run, then the `test` job. The log streams live while the job runs. - **CLI**: run these inside your clone (the CLI finds the repository from the `origin` remote, or pass `-R acme/website`): ```sh diffinery run list # newest runs first, with their ID diffinery run watch # follow it until it finishes; exits non-zero if it fails diffinery run logs # print a job's log ``` - **API**: `GET https://diffinery.com/api/v3/repos/acme/website/actions/runs`. `runs-on: ubuntu-latest` runs in a fresh Ubuntu 24.04 container on Diffinery's hosted runners. The job reports a check named `CI / test` (workflow name, then job name) on the commit, which you can require in branch protection later. If you also run another CI system, it can report its results on the same commits as commit statuses or check runs through the GitHub-compatible REST API; see [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). If a workflow file has a syntax error, its run fails and shows the errors; `diffinery workflow validate` checks files before you push. See [Actions](https://diffinery.com/docs/actions/overview). Each job gets a `GITHUB_TOKEN` that follows GitHub's `permissions:` model. In a new repository it can only read the code and packages (`contents: read`, `packages: read`), which is all the checkout above needs. A workflow that comments on pull requests, pushes or publishes releases has to ask for more, for example: ```yaml permissions: contents: write pull-requests: write ``` A repository admin can also change the default. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions). ## 7. Open a pull request Make a change on a branch and push it: ```sh git switch -c update-readme echo "Our marketing site." >> README.md git commit -am "Describe the project" git push -u origin update-readme ``` The push output includes a link: `Create a pull request for 'update-readme':` followed by the compare URL. **Web UI.** Open that link (or the repository's **Pull requests** tab, then **New pull request**). On the compare page, fill in: - **Title**, - **Intent**: one or two sentences on what the change is meant to do, for human and AI reviewers, - **Description** (Markdown), then click **Create pull request**. **CLI.** ```sh diffinery pr create --title "Describe the project" \ --intent "Add a one-line description of the site to the README." \ --body "Small docs change." ``` The pull request's `pull_request` workflow runs on a test merge of your branch into `main`, and its checks appear on the pull request. When the checks pass, merge it with **Merge pull request** (choose **Create a merge commit**, **Squash and merge** or **Rebase and merge (fast-forward)**), or: ```sh diffinery pr merge 1 --squash --delete-branch ``` Teammates review with **Approve** or **Request changes** on the pull request, or `diffinery pr review 1 --approve`. You can't approve your own pull request. To require reviews and green checks before merging, add a rule under **Settings → Branch protection**. See [Pull requests](https://diffinery.com/docs/collaboration/pull-requests) and [Branch protection](https://diffinery.com/docs/repositories/branch-protection). To skip the merge click from now on, open **Settings → Merge on green** once its checks have run: tick the checks and **Turn on auto-merge for pull requests I open**, and pull requests, yours and your agents', merge by themselves when the checks pass. See [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green). ## 8. Publish a release A release turns a tag into a version people can download, with release notes and attached files. **Web UI.** Open `https://diffinery.com/acme/website/releases` (or **Releases** in the sidebar of the repository page) and click **Draft a new release**. Under **Tag**, type a new tag such as `v0.1.0` (its **Target for a new tag** defaults to `main`), add a **Release title** and **Release notes**, optionally check **Generate release notes from merged pull requests (when the notes are empty)** and choose files under **Attach files**, then click **Publish release**. The release page also links to **Source code (zip)** and **Source code (tar.gz)** archives of the tag. **REST API** (GitHub-compatible, so Octokit and similar tools work too): ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/releases \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"tag_name": "v0.1.0", "name": "First release", "generate_release_notes": true}' ``` Publishing creates the tag if it doesn't exist and starts workflows that listen for `on: release`. See [Releases](https://diffinery.com/docs/collaboration/releases). To deploy from a workflow with its own secrets and branch rules, use an environment (`environment: production` on a job); see [Environments](https://diffinery.com/docs/actions/environments). ## 9. Connect an AI agent over MCP Agents are separate accounts for AI tools. They act on behalf of the person or organization that owns them, never get more access than their owner, can't push or merge to the default branch or protected branches, and their pull requests always need a human approval. ### Create the agent **Web UI.** Go to **Settings → Agents**. Under **Create an agent**, enter the **Agent username** `review-bot`, a **Display name**, the **Owner** (you or `acme`) and the **Maximum role** (keep **Write**). Click **Create agent**. Then open **Create a token for review-bot**, keep the defaults (**Scope** **Write**, 30 **Days**) and click **Create token**. Copy the token; it is shown once. Tokens can't create agents or tokens, so create agents in the web UI under **Settings → Agents**, then create the agent's token in its row (**Create a token for**). The token is shown once. An agent owned by `acme` can work in every `acme` repository, up to its maximum role. Creating an organization agent needs the owner or admin role in the organization. ### Point your MCP client at Diffinery Diffinery's MCP server is at `https://diffinery.com/mcp` (Streamable HTTP). Give the client the agent's token as a bearer token: ```json { "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_..." } } } } ``` `diffinery mcp-config` prints this snippet with `${DIFFINERY_TOKEN}` in place of the token, which clients such as Claude Code expand from the environment. For Claude Code, save it as `.mcp.json` in your project and set `DIFFINERY_TOKEN` to the agent's token. Ask the agent something like "Use the diffinery tools: who am I, and what pull requests are open in acme/website?". It calls `whoami` and `list_pull_requests`. Agents can read and commit files without Git (`get_file`, `list_tree`, `commit_files`), open pull requests with an intent (`create_pull_request`), review, follow workflow runs and logs (`list_workflow_runs`, `get_job_logs`), publish releases (`create_release`, `upload_release_asset`), and start sandboxes to build and test (`sandbox_create`, `sandbox_exec`). See [MCP](https://diffinery.com/docs/agents/mcp) and the [MCP tool reference](https://diffinery.com/docs/reference/mcp-tools). ## Next steps - [Concepts](https://diffinery.com/docs/concepts): accounts, roles, tokens, visibility, Actions and agents in one page. - [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github): import a GitHub repository or organization from the web, the CLI, the API or MCP, then set its secrets again. - [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green): pull requests merge by themselves once their checks pass. - [Branch protection](https://diffinery.com/docs/repositories/branch-protection): require pull requests, reviews and checks, and [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses) to report results from other CI systems. - [Releases](https://diffinery.com/docs/collaboration/releases) and [Environments](https://diffinery.com/docs/actions/environments): ship versions and deploy them. - [Organizations](https://diffinery.com/docs/accounts/organizations) and [Invitations](https://diffinery.com/docs/accounts/invitations): bring your team. - [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables): give workflows credentials. - [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax) and [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). - [Agents](https://diffinery.com/docs/agents/overview) and [Sandboxes](https://diffinery.com/docs/agents/sandboxes): put AI agents to work safely. - [REST API](https://diffinery.com/docs/reference/rest-api) and the [CLI reference](https://diffinery.com/docs/reference/cli). --- # Concepts > The Diffinery mental model in one page: accounts, agents, repositories, roles, tokens, checks, releases, environments, Actions and audit. Source: https://diffinery.com/docs/concepts This page explains the building blocks of Diffinery and how they fit together. Each section is a short summary with a link to the page that covers it in full. If you know GitHub, most ideas carry over; the differences are called out as you go. All of it happens on a Diffinery instance: one you have an account on, or one you [deploy to your own Cloudflare account](https://diffinery.com/docs/self-hosting/deploy). The examples use `https://diffinery.com` as the instance's address. ## Accounts Every action on Diffinery is taken by an account. There are three kinds: users, organizations and agents. All three share one namespace, so `https://diffinery.com/diffy`, `https://diffinery.com/acme` and `https://diffinery.com/review-bot` are all account pages, and repositories live under users and organizations. ### Users A user is a person. Diffinery is invite-only unless an instance turns on [open sign-up](https://diffinery.com/docs/accounts/passkeys-and-sign-in#open-sign-up): you join by opening an invite link (or, with open sign-up, confirming your email address), choosing a username and creating a passkey. There are no passwords. In the browser you sign in with a passkey; everything outside the browser (Git, the CLI, the REST API, MCP clients) uses a [token](#tokens). Site administrators are users with admin access to every repository and to the **Site administration** section of settings (**People and invitations**, **Email** and **Audit log**). See [Passkeys and sign-in](https://diffinery.com/docs/accounts/passkeys-and-sign-in), [Invitations](https://diffinery.com/docs/accounts/invitations) and [Site administration](https://diffinery.com/docs/admin/site-administration). ### Organizations An organization is a shared account that owns repositories, agents, container images and Actions secrets for a group of people. Members have one of three roles, which decide their role on every repository the organization owns: | Organization role | Role on the organization's repositories | | --- | --- | | member | The organization's base permission: `none`, `read`, `write` or `admin` | | admin | admin | | owner | admin, plus managing owners and renaming the organization | The base permission is a setting of the organization, as on GitHub: new organizations start with `read`, and owners change it on the organization's **Settings** tab. A member who creates a repository in the organization gets `admin` on it. Unlike GitHub, there are no teams and no custom roles. To give someone more access to a single repository, add them as a collaborator on it. See [Organizations](https://diffinery.com/docs/accounts/organizations) and [Base permission](https://diffinery.com/docs/accounts/organizations#base-permission). ### Agents An agent is an account for an AI tool or an automation, such as `review-bot`. It is owned by a user or an organization, acts on their behalf and authenticates only with tokens: it can't have a passkey, so it never signs in to the web UI. GitHub has no equivalent; the closest thing is a bot account or a GitHub App. Agents are held to guardrails that keep a person in the loop: - An agent never has more access than its owner. An organization's agent can reach only that organization's repositories. - Its maximum role is `write` unless its owner chooses otherwise. - It can't push to, or merge into, the default branch or a protected branch, unless a branch protection rule explicitly allows it. A rule can instead let it turn on auto-merge, so Diffinery merges its pull request once the required checks pass and a person approves (or, if an admin waives that, on green checks alone). - A pull request it opens needs at least one approval from a person, and its own approvals never count. - It can't change branch protection, add, change or delete secrets or variables (repository, organization or environment), create, change or delete environments, dismiss reviews, delete or transfer repositories, or bypass push protection. - Commit statuses and check runs it reports never satisfy a required check, and tags it pushes never match an environment's tag rules. - Commits it makes carry a `diffinery-agent` header naming the agent and the person it acts for. - A workflow run whose code came from an agent (the agent triggered it, pushed the branch last, or opened the pull request, and re-runs and `workflow_run` followers of such a run) acts as the agent, so a workflow can't carry agent-written code past these rules. - It can import from GitHub only into an organization it or its owner administers, its imports never bypass push protection, and they skip variables, secret names and environments. Agents work through the [MCP server](https://diffinery.com/docs/agents/mcp), the REST API, the CLI and Git. Diffinery can also run one for you: an [agent task](https://diffinery.com/docs/agents/tasks) gives one of your agents a goal and runs Claude Code, Codex or your own command with your own model key, then opens a pull request, a [terminal](https://diffinery.com/docs/agents/terminal) gives you a shell in a sandbox where Claude Code or Codex runs on your own subscription and can take tasks too, a repository's [project](https://diffinery.com/docs/agents/projects) puts its tasks on a board with shared memory and instructions, and [agent review](https://diffinery.com/docs/agents/review) has one review pull requests for bugs. [Today](https://diffinery.com/docs/agents/today), the first page after signing in, shows what needs you and what your agents are doing. See [Agents](https://diffinery.com/docs/agents/overview). ### People imported from GitHub When you [import from GitHub](https://diffinery.com/docs/migrate-from-github), no accounts are created. A GitHub user whose GitHub account is linked to a Diffinery account (under **Settings → Linked accounts**, matched by GitHub's numeric user id) is credited to that account. Everyone else becomes a **mannequin**: a placeholder that carries their issues, comments and reviews under their GitHub login, with a **GitHub** badge in the web UI and `"type": "Mannequin"` and a `github:` login in the API. A mannequin isn't an account: it can't sign in or act. Imported reviews, whoever wrote them, are history and never count as approvals. See [People: linked accounts and mannequins](https://diffinery.com/docs/migrate-from-github#people-linked-accounts-and-mannequins). ## Repositories A repository holds a project's Git history plus its issues, pull requests, reviews, checks, releases, deployment environments, workflow runs, webhooks, secrets and variables. It lives at `https://diffinery.com//` (for example `https://diffinery.com/acme/website`) and is cloned from the same URL with `.git` appended. New repositories are private and use `main` as the default branch unless you choose otherwise. A repository can be renamed, or transferred to your own account or to an organization you own or administer. Its old URLs keep working, as on GitHub: web pages and the REST API redirect, and Git, MCP and container images are served under the old name, for anyone who can still read it. To move from GitHub, the server-side importer copies a repository, or every repository of an organization, with its history, issues and pull requests (keeping their numbers), comments, reviews, labels, milestones, releases, branch protection, variables and environments. Secret values are never copied, only their names. Start it from **Settings → Imports**, `diffinery migrate github`, the REST API or MCP. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). There is no forking. Pull requests are always between two branches of the same repository, so contributors need write access to push a branch. Repositories can carry topics, and any branch, tag or commit can be downloaded as a `.zip` or `.tar.gz` source archive. Code is shown with syntax highlighting. See [Repositories](https://diffinery.com/docs/repositories/overview), [Browsing code](https://diffinery.com/docs/repositories/browsing-code), [Git over HTTPS](https://diffinery.com/docs/repositories/git) and [Git LFS](https://diffinery.com/docs/repositories/git-lfs). ## Visibility A repository is either **private** or **public**. | Visibility | Who can see it | | --- | --- | | Private | The owner, organization members (for organization repositories, unless the organization's base permission is `none`), collaborators and site administrators | | Public | Everyone, including people who aren't signed in. Signed-in users get `read`. | Someone without access to a private repository gets `404 repository not found`, never `403`, so its name isn't revealed. Writing to a public repository still needs a signed-in account (or a token) with a role that allows it. People who aren't signed in can only read: opening an issue, commenting, reacting or pushing without an account fails with `401 authentication required` (for a push, Git then asks for your token). See [Visibility](https://diffinery.com/docs/repositories/overview#visibility). ## Roles Every request to a repository runs with one effective role. Diffinery uses GitHub's five repository roles, and each one includes the ones before it: | Role | In short | | --- | --- | | `read` | View and clone; when signed in, also open issues and comment | | `triage` | Manage issues, pull requests and labels without pushing code | | `write` | Push, open and merge pull requests, run workflows | | `maintain` | Also move or delete tags, edit other people's comments and dismiss reviews | | `admin` | Settings, access, branch protection, secrets and webhooks | Your role on a repository comes from owning it, your organization role (members get the organization's base permission), a collaborator grant or the repository being public, whichever is highest. Tokens and agent limits can then only lower it. See [Permissions](https://diffinery.com/docs/accounts/permissions). ## Tokens A personal access token lets Git, the `diffinery` CLI, scripts and MCP clients act as you (or as an agent). Tokens start with `dfy_`, are shown once when you create them, and last 1 to 366 days (90 by default). Diffinery emails you whenever a token is created on your account. Each token has a scope that caps your role, whatever your role actually is: | Scope | Highest effective role | | --- | --- | | `read` | read | | `write` | maintain | | `admin` | admin | A `read` token can only read: it can clone and fetch, download Git LFS files, call `GET` endpoints and use the MCP tools that only read, and every change it attempts fails with `403 this token needs the write scope`. New tokens get the `write` scope unless you choose another; scopes other than `read`, `write` and `admin` are refused (`422`). Some actions belong to your account rather than to a repository, and need a minimum scope whatever your role: | Action | Token scope needed | | --- | --- | | Create a repository | `write` | | Create an organization, send invitations, manage organization secrets | `admin` | | Create or revoke tokens, create or disable agents, create agent tokens | `admin` | A token can also be restricted to a list of repositories; every other repository then answers `404` as if you had no access, and the token can't create repositories or organizations, send invitations, manage agents or create other tokens. A token can only be restricted to repositories your account can see. Browser sessions aren't capped, so in the web UI you always act with your full role. Unlike GitHub's fine-grained tokens, there are no per-permission settings: the scope and the repository list are the whole model. Use a token as the Git password (with any username), as `Authorization: Bearer dfy_...` for the API and MCP, or let `diffinery login` and `diffinery auth setup-git` store it. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). ## Job tokens Each workflow job gets a short-lived job token as `GITHUB_TOKEN` (also `DIFFINERY_TOKEN`). It starts with `djt_`, acts as `diffinery-actions[bot]` on the repository the job runs in (it can also read other public repositories) and stops working when the job completes. As on GitHub, the token can do what the workflow's `permissions:` grant (`contents`, `issues`, `pull-requests`, `statuses`, `packages` and so on), and an endpoint it lacks the permission for answers `403 Resource not accessible by integration`. Without `permissions:`, a job gets the repository's default, which for new repositories is GitHub's restricted one (`contents` and `packages` read); a repository admin can change it (see [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions)). The token never acts above `write`, so branch protection applies; it never changes repository settings, secrets or workflow files; it can't open or approve pull requests unless the repository allows it, and its approvals never count; and a run whose code came from an agent acts as that agent. Sandboxes get a similar token starting with `dst_` (see [Sandboxes](#sandboxes)). See [Job tokens and sandbox tokens](https://diffinery.com/docs/accounts/permissions#job-tokens-and-sandbox-tokens) and [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). ## Branch protection A branch protection rule matches branch names with a pattern such as `main`, `release/*` (one path segment) or `release/**` (any depth), and controls how matching branches can change: whether changes must go through a pull request, how many approvals and which checks are required, whether force pushes, deletion and merge commits are allowed, whether a merge queue is used, and what agents may do. The server enforces the rules for every way a branch can change, whether through `git push`, the REST API, MCP or the web UI. Required checks can be workflow jobs, check runs or commit statuses reported by other tools. When Diffinery Actions reports a check, it alone decides that check; to have another tool decide a required check, a rule binds the check to that reporter. See [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). Some protections apply even without any rule: the default branch can't be deleted, and agents can't push to it or merge into it. When several rules match a branch, the rule that names the branch exactly wins over patterns; otherwise the oldest matching rule applies. Repository admins can bypass the rules unless the rule sets **Do not allow administrators to bypass these rules** (`enforceAdmins`). A push by an admin bypasses them on its own. A merge doesn't: an admin has to ask for it explicitly (the **Merge without waiting for requirements (bypass)** button, `"bypass_rules": true` in the API, or `diffinery pr merge --admin`), and the bypass is recorded in the audit log as `pull_request.merge_bypassed`. `"bypass_rules": true` skips unmet rules, and `"bypass_merge_queue": true` skips only a required merge queue. Bypassing merge requirements is for people only, never agents, and never works for a draft or a pull request with conflicts. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection) and [Merge queue](https://diffinery.com/docs/collaboration/merge-queue). ## Pull requests with intent A pull request proposes merging one branch (the head) into another (the base) of the same repository. Pull requests and issues share one number sequence per repository, as on GitHub. Besides a title and a description, every pull request can carry an **intent**: one or two sentences on what the change is meant to achieve, for example "Fix the race in token refresh without changing the public API." The intent is shown at the top of the pull request, included in notification emails and returned by the API, the CLI and MCP, so reviewers, human or AI, can check the diff against the stated goal. It can't be edited after the pull request is opened, and agents should always set it. A pull request can be opened as a **draft**: a draft can't be merged until it is marked ready for review (the **Ready for review** button, `diffinery pr ready`, or `PATCH` with `"draft": false`), and it can be turned back into a draft later. Pull requests are reviewed with **Approve**, **Request changes** or comments, checked by workflow runs and other CI, and merged with a merge commit, a squash or a fast-forward rebase once nothing blocks them. With **auto-merge** turned on, Diffinery merges a pull request by itself as soon as nothing blocks it. See [Pull requests](https://diffinery.com/docs/collaboration/pull-requests) and [Issues](https://diffinery.com/docs/collaboration/issues). ## Actions Diffinery Actions runs GitHub Actions workflows from `.github/workflows/` (or `.diffinery/workflows/`) on Cloudflare. Most existing workflow files run unchanged; differences are listed in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ### Runs, jobs and steps | Term | Meaning | | --- | --- | | Workflow | A YAML file that says when to run (`on:`) and what to run (`jobs:`). | | Event | What starts a workflow: a push, a pull request update, a merge queue group, a manual dispatch, a schedule, another workflow's run, a published release or a `repository_dispatch` API call. | | Run | One execution of one workflow for one event, with a number, a status and a conclusion. | | Job | A set of steps that runs on one runner. Jobs run in parallel unless linked with `needs`; a matrix expands one job into several. | | Step | One command (`run:`) or one action (`uses:`), run in order in the job's workspace. | | Check | The result of one job, reported on the commit as ` / `, for example `CI / test`. Branch protection can require it. | | Environment | A deployment target such as `production`, with its own secrets, variables and branch policy. A job with `environment:` gets them only if the policy allows the ref. | Runs are orchestrated durably, so they survive platform restarts, and a job can run for at most 6 hours. See [Actions overview](https://diffinery.com/docs/actions/overview), [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax) and [Events that trigger workflows](https://diffinery.com/docs/actions/events). ### Runners A runner is the machine that executes one job. You pick one per job with `runs-on`: - **Hosted runners** run each job in a fresh Ubuntu 24.04 container on Cloudflare. `runs-on: ubuntu-latest` and similar labels use them. - **Self-hosted runners** run jobs on a machine you control, such as a Mac, a GPU box or a server on a private network. You start one with `diffinery runner`, and jobs with `runs-on: self-hosted` (plus any other labels) go to it. See [Runners](https://diffinery.com/docs/actions/runners). ## Checks and commit statuses A check is a pass or fail result attached to a commit. Diffinery Actions reports one check per job. Other tools report results over the GitHub-compatible REST API, either as **commit statuses** (a state and a context name, such as `ci/external`) or as **check runs**. All of them appear together on the commit and the pull request, and branch protection can require any of them by name. See [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). ## Releases A release marks a tag as a version you ship, with a title, Markdown release notes and downloadable files (assets, up to 100 MB each). Releases can be drafts or pre-releases, one of them is the latest, and notes can be generated from the merged pull requests. Each release page also links to source archives of its tag. Publishing a release starts `on: release` workflows and sends a `release` webhook. Manage releases at `https://diffinery.com/acme/website/releases`, with the REST API or with the MCP release tools. See [Releases](https://diffinery.com/docs/collaboration/releases). ## Environments and deployments An environment, such as `staging` or `production`, groups the secrets and variables a deployment needs with a policy for which branches and tags may deploy to it. A workflow job that names an environment (`environment: production`) creates a deployment, gets that environment's secrets and variables, and is refused if the policy doesn't allow its ref. Environment secrets never reach pull request runs. Tools outside Actions can record deployments and their status through the REST API, as on GitHub. Environments are managed with the REST API by repository admins. See [Environments](https://diffinery.com/docs/actions/environments). ## Sandboxes A sandbox is a short-lived Linux container with a repository already cloned into it, which you drive over MCP or the REST API: run shell commands, read and write files, and push with Git. Agents use sandboxes to build and test changes without a machine of their own. Creating a sandbox needs write access. Inside it, Git authenticates as whoever created it with at most `write`, so branch protection, push protection and agent guardrails apply to its pushes like any other. A sandbox stops after 30 minutes without calls and keeps no files, so commit and push what you want to keep. GitHub has no built-in equivalent. See [Sandboxes](https://diffinery.com/docs/agents/sandboxes). ## Push protection Every `git push` is scanned for leaked credentials such as API keys and private keys, and so is every commit made without Git: in the web editor, through the REST API (`POST /repos/{owner}/{repo}/diffinery/commits` and the contents and git data APIs) and with the MCP `commit_files` tool. A push or commit that contains one is rejected and nothing from it is stored. A repository admin who is a person can push anyway with `git push -o allow-secrets=`, or commit through the commits API with `allow_secrets`, which is recorded and emailed. See [Push protection](https://diffinery.com/docs/security/push-protection) and [Security overview](https://diffinery.com/docs/security/overview). ## Audit log The audit log is an append-only record of security-relevant actions: sign-ins, passkey and token changes, repository settings (whether changed on the web or through the API), pushes, force pushes, merges, push protection decisions and package changes. When an agent acts, the entry names both the agent and the account it acts for. Only site administrators can view it, under **Settings → Audit log**, which shows the 200 most recent entries. See [Audit log](https://diffinery.com/docs/security/audit-log). ## Notifications Diffinery tells you about activity in the notifications list (the bell in the header, `/notifications`) and by email, following GitHub's rules: you hear about issues and pull requests you take part in, everything in repositories you watch with **All activity**, and failed workflow runs you started. Security emails, such as a new token or a new sign-in, are always sent. Each repository has a **Watch** menu (**Participating and @mentions**, **All activity**, **Ignore**), each issue and pull request a **Subscribe** button, and **Settings → Notifications** turns each group on or off. Agents have no notification settings. See [Notifications](https://diffinery.com/docs/accounts/notifications). ## Where to go next - [Quickstart](https://diffinery.com/docs/quickstart): try these concepts end to end in about 15 minutes. - [Releases](https://diffinery.com/docs/collaboration/releases), [Environments](https://diffinery.com/docs/actions/environments) and [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). - [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github): import repositories or whole organizations, link your GitHub account, and set secrets again. - [REST API](https://diffinery.com/docs/reference/rest-api), the [CLI reference](https://diffinery.com/docs/reference/cli) and the [MCP tool reference](https://diffinery.com/docs/reference/mcp-tools). - [Limits](https://diffinery.com/docs/reference/limits): sizes, timeouts and quotas in one place. --- # Migrate from GitHub > Import GitHub repositories or whole organizations with the server-side importer (web, CLI, API or MCP), then set secrets, update remotes and check workflows. Source: https://diffinery.com/docs/migrate-from-github Diffinery imports a GitHub repository, or every repository of a GitHub organization or user, on the server: it copies the Git history in a container and everything else through GitHub's REST API, keeping issue and pull request numbers, comments, reviews and their authors. You start an import from the web, the `diffinery` CLI, the REST API or MCP, and nothing runs on your machine. This page covers the importer in full, the older client-side and plain Git paths, mirroring from GitHub, and what to change after you move. ## Choose a method | Method | Copies | Use it when | | --- | --- | --- | | [The server-side importer](#how-the-importer-works) (web, `diffinery migrate github`, REST API, MCP) | Branches, tags, pull request heads, Git LFS objects, issues and pull requests with their numbers, comments, reviews and review comments, labels, milestones, releases and assets, branch protection, Actions variables, deployment environments with their variables, and the names (never the values) of secrets. | You are moving a repository or an organization to Diffinery. This is the default. | | [`diffinery migrate github --local`](#the-local-client-side-import) | Branches, tags, default branch, repository variables and secret names. With `--issues`, also labels, issues, comments and open pull requests, created anew. | You want the older client-side path that clones on your machine. | | [Plain Git](#move-with-plain-git) | Branches and tags. | You only need the code. | | [Mirror from GitHub](#keep-github-as-upstream-and-mirror-to-diffinery) | Every branch and tag on every push, plus the secrets you list. | You want to try Diffinery (or run its Actions) while your team keeps working on GitHub. | Whatever you choose, Diffinery runs your existing `.github/workflows/*.yml` files. See [Workflows](#workflows). ## How the importer works An import is a record on the server that runs through fixed steps, each retried on failure and resumed where it stopped: 1. **Create the repository** under the Diffinery user or organization you chose, with GitHub's description, homepage, topics (up to 20) and default branch. 2. **Copy branches, tags and history.** A container clones the GitHub repository with `git clone --mirror` and pushes it to Diffinery in chunks of about 32 MB, then copies Git LFS objects. 3. **Copy issues, pull requests, comments, reviews, review comments, labels, milestones, branch protection, variables, secret names, environments and releases** through GitHub's REST API, page by page. 4. **Finish:** create `refs/pull//head` for every imported pull request, set the default branch, archive the repository if it is archived on GitHub, and write the summary. Things to know: - **Nothing the import writes fires events.** No workflow runs start, no webhooks are delivered and nobody is notified about imported issues, comments or pushes. Your first push after the import starts workflows as usual. - **The repository is locked while the import runs.** Until the import finishes, fails or is cancelled, only the import writes to it. A push from anyone else is refused with `repository is locked by an import in progress`, and any other change through the REST API, the web UI or MCP (an issue, a comment, a label, a webhook, a branch protection rule, a rename or transfer) gets `423 repository is locked by an import in progress; try again when it finishes, or cancel the import`. Reading the repository and watching it work as usual, and so do settings that don't touch its Git data, issues or pull requests: Actions secrets and variables, environments with their secrets and variables, and collaborators and their invitations. You can set the imported secrets' values while the history is still arriving. - **Every write is keyed by GitHub's ids**, so a retried step or a retried import never duplicates issues, pull requests, comments, reviews, labels, milestones, branch protection rules or releases (a release is matched by its tag and creation date, and assets already copied are kept). - **The import keeps running if you close the page or stop the CLI.** You get an entry in your notifications list when it succeeds (`Imported acme/website into acme/website`) or fails, linking to its page. - **Every import is audited:** `import.create`, `import.push`, `import.finish`, `import.fail`, `import.cancel` and `import.retry` appear in the audit log. See [Audit log](https://diffinery.com/docs/security/audit-log). ## Before you start You need: - **A Diffinery account.** Diffinery is invite-only; ask a site administrator or an owner or admin of your organization for an invite, or create an account at `/signup` on an instance with [open sign-up](https://diffinery.com/docs/accounts/passkeys-and-sign-in#open-sign-up). See [Invitations](https://diffinery.com/docs/accounts/invitations). - **An organization** if the repositories should belong to one, for example `acme`, where you are an owner or admin. Create it under **Settings → Organizations**. See [Organizations](https://diffinery.com/docs/accounts/organizations). - **For the CLI, API or MCP, a Diffinery token with the `write` or `admin` scope.** Use `admin` if old history may contain credentials and you want them imported with a bypass (see [Secrets in old history](#secrets-in-old-history)). The web UI needs no token. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). - **A GitHub token** for a private repository, and for Git LFS, variables, secret names, environments and branch protection. See [GitHub token](#github-token). - **Optionally, a linked GitHub account**, so your own past issues, comments and reviews are attributed to your Diffinery account. Link it before you import. See [Link your GitHub account](#link-your-github-account). ### Who can import where | Who | Can import into | | --- | --- | | A person | Their own account, and organizations where they are an owner or admin | | A site administrator | Any user or organization | | An agent | Only organizations: the organization that owns the agent, or one where the agent's owner is an owner or admin. Never a user account. The agent's maximum role must be at least `write`. | | An organization | Nothing: `organizations cannot start imports` | Organization members who aren't owners or admins can't import into the organization (`you can import only into your own account or organizations you administer, not acme`). The check runs again when the import starts; if you lost the right in the meantime the import fails with `the person or agent who started this import can no longer import into this account`. ### GitHub token Without a GitHub token, the importer reads only public data, and GitHub's limit for anonymous requests (60 an hour) makes a large import slow. The importer waits whenever GitHub asks it to, so it still finishes. With a token: | To import | The GitHub token needs | | --- | --- | | A private repository, its Git data, issues, pull requests, comments, reviews, labels, milestones and releases | Classic token: the `repo` scope. Fine-grained token: read access to **Contents**, **Metadata**, **Issues** and **Pull requests**. | | Branch protection | Admin access to the repository on GitHub | | Actions variables, secret names and environments | Admin access to the repository, or for a fine-grained token **Variables** and **Secrets** read access | | Organization variables and secret names (organization imports) | Admin access to the GitHub organization | | Private repositories of a GitHub organization or user (organization imports) | Access to those repositories; the list includes private ones only when the token can see them | When the token can't read something optional, the import carries on and records a warning, for example `Branch protection of main could not be read (the GitHub token needs admin access to the repository); set it up under Settings → Branch protection.` How the token is handled: - It is used only for this import, encrypted at rest, and deleted when the import finishes, fails or is cancelled. A retry needs it again. - It is never returned by the API, MCP or the web UI, and never written to logs. In the container, Git gets it from per-host configuration, never on its command line. - It is sent only to the one GitHub host you import from. Redirects to another host are not followed, the clone URL must be on that host (`GitHub returned a clone URL on another host`), and release assets get the token only when they are on the API's own origin. - The CLI sends `GH_TOKEN` (or `GITHUB_TOKEN`) to Diffinery over HTTPS so the server can use it, and says so on stderr (`note: sending GH_TOKEN to https://diffinery.com so it can read from GitHub during the import`). Unset both to import only public data without a token. ### GitHub Enterprise Server To import from GitHub Enterprise Server, name its REST API base explicitly: the **GitHub Enterprise API URL** field in the web form, `github_api_url` in the API and MCP, or `GITHUB_API_URL` for the CLI. The rules: | Rule | Details | | --- | --- | | Default | `https://api.github.com`. `github.com`, `www.github.com` and `api.github.com` all mean github.com. | | Form | `https://ghe.example.com/api/v3`. A bare host such as `https://ghe.example.com` gets `/api/v3` added. | | Must be https on a public host | Otherwise `github_api_url must use https`, `github_api_url must be a public host` (localhost, single-label names, `.local`, `.internal` and private or loopback addresses are refused), `github_api_url must not contain credentials` or `github_api_url is not a valid URL`. | | A token with an Enterprise source URL | If `source` is a URL on an Enterprise host and you give a token, `github_api_url` is required (`github_api_url is required when a github_token is given and the source names a GitHub Enterprise host`) and must be on the same host (`github_api_url must be on the same host as the source repository`). This keeps a token from going to a host taken from the source URL. | | No token | A source URL on an Enterprise host implies `https:///api/v3`. | Release assets are downloaded only from github.com's asset hosts and from the GitHub Enterprise host the server administrator configured (`IMPORT_GHE_HOST`). On other Enterprise hosts, assets are skipped with a warning and the releases themselves are still imported. ## Import from the web ### One repository 1. Open the import form in any of these ways: - **Settings → Imports**, then **Import from GitHub**. - On **Create a new repository** (`https://diffinery.com/new`), the **Import a repository** link. - On an organization's page, **Import from GitHub** next to **New repository** (shown to its owners and admins; it opens the form for an organization import into that organization). - Directly: `https://diffinery.com/new/import`. 2. Under **What to import**, choose **One repository**. 3. In **GitHub repository, or organization/user**, enter `acme/website` or `https://github.com/acme/website`. 4. In **Import into**, choose yourself or an organization you administer. 5. Optionally set **New repository name** (defaults to the GitHub name). 6. Choose **Visibility**: **Keep** (a public GitHub repository stays public, any other becomes private), **Private** or **Public**. 7. Paste a **GitHub token** if the repository is private or you want LFS, variables and protection. 8. Optionally open **What to copy, and GitHub Enterprise** to turn parts off, choose what happens to [secrets in old history](#secrets-in-old-history), or set **GitHub Enterprise API URL**. See [Import options](#import-options). 9. Click **Start import**. The import's page opens and follows its progress. ### An organization or user 1. Open the import form as above. The **Import from GitHub** button on an organization's page preselects it. 2. Under **What to import**, choose **A whole organization or user**. 3. In **GitHub repository, or organization/user**, enter the GitHub organization or user, for example `acme`. 4. Choose **Import into**, **Visibility**, the **GitHub token** and the options, and set **Repositories at a time** (1 to 8, default 3). 5. Click **Start import**. The **Choose repositories** page lists the account's repositories with `private`, `fork` and `archived` badges. Everything except forks and archived repositories is ticked. 6. Use the filter box with **Filter**, **Select all** (or **Select all shown** when filtered) and **Clear** to adjust the selection, then click **Import N repositories**. Each repository becomes its own import under the organization import, keeps its GitHub name, and is imported in full. A repository whose name already exists in the target fails straight away with `acme/website already exists`; the others go ahead. An organization import waiting on **Choose repositories** is a draft. Drafts left for seven days are cancelled (`draft expired`) and their stored GitHub token is erased. ### Import options | Web label | API field (`options.`) | Default | What it controls | | --- | --- | --- | --- | | **Issues and pull requests** | `issues` | on | Issues and pull requests with comments, reviews and review comments | | **Labels and milestones** | `labels` | on | Labels and milestones, even without issues | | **Branch protection** | `branch_protection` | on | Classic branch protection rules | | **Actions variables and secret names** | `variables` | on | Repository variables, secret names, and deployment environments with their variables and secret names; for organization imports also organization variables and secret names | | **Releases** | `releases` | on | Releases and their assets | | **Git LFS objects** | `lfs` | on | Git LFS files | | **Secrets found in old history (push protection)**: **Import with an audited bypass** or **Stop the import** | `secrets`: `"bypass"` or `"stop"` | `bypass` | What happens when push protection finds a credential in old commits. Agents always get `stop`. | Branches, tags and history are always copied. A step whose option is off shows as skipped with `Not selected`. ### Follow progress The import's page (`https://diffinery.com/imports/`) refreshes itself every few seconds while the import runs ("This page refreshes automatically.") and shows: - The source, the target and a status badge: `queued`, `running`, `succeeded`, `failed` or `cancelled` (and `draft` for an organization import that waits for its selection). - Each step with its state and a short message, such as `12 branches, 30 tags, 118 pull request heads, 4 LFS objects`. - For an organization import, every repository with its status and an **Open** button. - After it finishes: **Imported** (counts), **Secret names** with a **Set secret values** button, **Possible secrets in history (rotate these)** when a bypass was used, and **Warnings**. - **Cancel import** while it runs, and **Retry this import** when it failed or was cancelled. **Settings → Imports** (`https://diffinery.com/imports`) lists the imports you started, newest first. Admins of the target account can also open an import's page by its link. | Step | Title on the page | | --- | --- | | `repository` | Create the repository | | `git` | Copy branches, tags and history | | `labels` | Labels and milestones | | `issues` | Issues and pull requests | | `comments` | Comments | | `reviews` | Reviews | | `review_comments` | Review comments | | `protection` | Branch protection | | `variables` | Variables and secret names | | `releases` | Releases | | `finalize` | Finish | An organization import has three steps: `organization` (**Organization variables and secret names**), `repositories` (**Repositories**, with `3 of 10 finished (2 imported, 1 failed)`) and `finalize` (**Finish**). Each step is `pending`, `running`, `done`, `skipped` or `failed`. ## Import with the CLI Install the CLI and sign in (approve the sign-in in your browser; `--scope admin` if you'll also set secrets): ```sh curl -fsSL https://diffinery.com/install | bash diffinery login # opens your browser to authorize ``` ### diffinery migrate github Starts a server-side import of one repository and follows it until it finishes: ```sh export GH_TOKEN=ghp_... # needed for private repositories, LFS, variables and protection diffinery migrate github acme/website --to acme/website ``` ```text diffinery migrate github [options] ``` The argument can also be a URL such as `https://github.com/acme/website`; the CLI keeps only `owner/name` from it, so for GitHub Enterprise set `GITHUB_API_URL`. | Option | Default | Meaning | | --- | --- | --- | | `-t, --to OWNER/NAME` | `/` | The Diffinery repository to create. It must not exist yet. | | `-p, --private` | off | Create the repository as private, whatever its visibility on GitHub (otherwise visibility is kept) | | `-l, --local` | off | Run the older client-side import instead. See [The local client-side import](#the-local-client-side-import). | | `-i, --issues` | off | With `--local` only: also copy labels, issues, comments and open pull requests | | `--json` | off | Print the final import object as JSON instead of progress lines | | Environment variable | Meaning | | --- | --- | | `GH_TOKEN` or `GITHUB_TOKEN` | GitHub token, sent to Diffinery for the import (`GH_TOKEN` wins if both are set) | | `GITHUB_API_URL` | GitHub Enterprise API base, for example `https://ghe.example.com/api/v3` | | `DIFFINERY_TOKEN`, `DIFFINERY_URL` | Override the token and server saved by `diffinery login` | The CLI imports with every option on and `secrets` set to `bypass`. To turn parts off or stop on secrets, use the web form or the API. While it runs, the command prints each step and Git phase as it changes: ```text Starting server import of acme/website … … Create the repository ✓ Create the repository (acme/website) … Copy branches, tags and history (Starting the import container) … git: Cloning acme/website … git: Pushing history of main: chunk 1 (31.6 MB) ✓ Copy branches, tags and history (42) ✓ Labels and milestones (14) ✓ Issues and pull requests (220) ... ✓ Finish (180 issues, 40 pull requests) Imported acme/website → acme/website GitHub does not reveal secret values. Set these 3 secrets by hand: diffinery secret set NPM_TOKEN -R acme/website diffinery secret set SENTRY_DSN -R acme/website diffinery api PUT /repos/acme/website/environments/production/secrets/DEPLOY_KEY -f value=... https://diffinery.com/acme/website ``` Warnings are printed to standard error. Repository secrets get a `diffinery secret set` line. Environment secrets (`production/DEPLOY_KEY` in the summary) have no `secret set` option, so they get a `diffinery api PUT .../environments//secrets/` line instead (see [Environments and their secrets](#environments-and-their-secrets)). Replace `...` with the value, or pipe it in. Stopping the command (Ctrl-C) stops following, not the import. Check on it at `https://diffinery.com/imports` or with `GET /api/v3/diffinery/imports`. ### diffinery migrate github-org Starts a server-side import of many repositories of a GitHub organization or user and follows it until every repository has finished: ```sh export GH_TOKEN=ghp_... diffinery migrate github-org acme --to acme --exclude-forks --exclude-archived ``` ```text diffinery migrate github-org [options] ``` | Option | Default | Meaning | | --- | --- | --- | | `-t, --to OWNER` | you | The Diffinery user or organization to import into | | `--repos A,B,C` | all | Comma-separated names of the repositories to import | | `-p, --private` | off | Create every repository as private | | `--exclude-forks` | off | Skip forks | | `--exclude-archived` | off | Skip archived repositories | | `-c, --concurrency N` | 3 | Repositories imported at the same time, 1 to 8 | | `--json` | off | Print the final import object as JSON | Unlike the web form, the CLI has no selection step: it starts importing the repositories straight away. It prints a line per repository as its state changes, then a summary such as `Bulk import of acme: 9/10 repositories imported, 1 failed` and the import's URL. ### JSON output and exit status With `--json`, both commands print the final [import object](#the-import-object). Both exit with status `0` when the import succeeded and `1` otherwise (failed or cancelled). An organization import counts as failed when any of its repositories wasn't imported. ### The local client-side import `diffinery migrate github --local` runs the older import on your machine. It needs Git 2.31 or newer and a Diffinery token with the `admin` scope that isn't restricted to repositories, from a person, not an agent (it creates the repository, sets the default branch and Actions variables). Run `diffinery auth setup-git` once so Git can use the token. ```sh export GH_TOKEN=ghp_... diffinery migrate github acme/website --to acme/website --local --issues ``` What it does: 1. Creates the target repository with GitHub's description and visibility (or private with `--private`), or uses it if it exists (`Using existing repository acme/website`). 2. Clones with `git clone --mirror` into a temporary folder and pushes every `refs/heads/*` and `refs/tags/*` in one `git push`. If that fails, it prints `warning: bulk push failed (...); pushing refs one at a time` and pushes each ref separately. 3. Sets the default branch, copies repository variables and lists secret names with a `diffinery secret set` line for each. 4. With `--issues`: copies labels, then creates issues, recreates open pull requests whose branches are in the same repository, copies conversation comments and closes closed issues. Everything is created by you, now, with new numbers (the output lists `GitHub #12 → #9`), and each item starts with an attribution line such as `> Imported from GitHub issue [#12](https://github.com/acme/website/issues/12) by @diffy on 2026-03-01`. It doesn't copy closed or merged pull requests, reviews, review comments, milestones, assignees, releases, Git LFS files, environments or branch protection, and pushes go through push protection like any other push (and start workflows). `--issues` isn't idempotent: running it twice creates every issue again. It exits with status 1 if any branch or tag failed to push. With `--json` it prints `repo`, `created`, `branches`, `tags`, `pushFailures`, `defaultBranch`, `labels`, `issues` and `pulls` (`{from, to}` pairs), `skippedPulls`, `comments`, `variables`, `secrets` and `warnings`. Prefer the server-side import: it keeps numbers, authors, dates, reviews and merged pull requests, and doesn't depend on your connection. ## Import with the REST API The importer is a Diffinery extension of the REST API under `/api/v3/diffinery/imports`. Every endpoint needs a token (or a browser session). Starting an import needs the `write` or `admin` scope (`starting an import needs a token with write or admin scope`); cancelling, retrying and choosing repositories need the `write` scope on a token that isn't restricted to repositories. ### Start an import `POST /api/v3/diffinery/imports` with a JSON body. One repository: ```sh curl -X POST https://diffinery.com/api/v3/diffinery/imports \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{ "source": "acme/website", "owner": "acme", "name": "website", "visibility": "keep", "github_token": "ghp_...", "options": { "releases": true, "secrets": "bypass" } }' ``` Many repositories from an organization or user: ```sh curl -X POST https://diffinery.com/api/v3/diffinery/imports \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{ "source_owner": "acme", "owner": "acme", "exclude_forks": true, "exclude_archived": true, "concurrency": 4, "github_token": "ghp_..." }' ``` | Field | Applies to | Default | Meaning | | --- | --- | --- | --- | | `source` | One repository | | `owner/name`, a github.com or Enterprise URL, or an SSH remote such as `git@github.com:acme/website.git`. Give either `source` or `source_owner`. | | `source_owner` | Many | | A GitHub organization or user login | | `owner` | Both | required | The Diffinery user or organization to import into | | `name` | One repository | the GitHub name | The new repository's name: letters, digits, `.`, `-` and `_`. It must not exist yet. | | `repositories` | Many | all | Names of the repositories to import, at most 1,000. Unknown names fail the request (`not found in acme: a, b`). | | `exclude_forks` | Many | `false` | Skip forks (ignored when `repositories` is given) | | `exclude_archived` | Many | `false` | Skip archived repositories (ignored when `repositories` is given) | | `concurrency` | Many | `3` | Repositories imported at the same time, 1 to 8 | | `visibility` | Both | `keep` | `keep`, `private` or `public`. With `keep`, a public GitHub repository stays public and any other becomes private. | | `options` | Both | everything on, `secrets: "bypass"` | An object with `issues`, `labels`, `branch_protection`, `variables`, `releases`, `lfs` (booleans) and `secrets` (`"bypass"` or `"stop"`). Unknown keys fail with `unknown option "..."`. See [Import options](#import-options). | | `github_token` | Both | none | GitHub token; at most 400 characters, no spaces | | `github_api_url` | Both | `https://api.github.com` | GitHub Enterprise API base. See [GitHub Enterprise Server](#github-enterprise-server). | Before it records anything, the server reads the source on GitHub, so mistakes fail right away: `GitHub repository acme/website was not found or the token cannot read it`, `GitHub rejected the token (expired or revoked?)`, `no GitHub organization or user named acme`, `acme has no repositories to import`, or `acme/website already exists; choose another name`. A single repository larger than the size limit is refused here too (see [Limits](#limits)). The response is `201 Created` with the [import object](#the-import-object). An organization import created through the API starts at once with the repositories you named (or all of them); the draft and selection step exists only in the web form. ### The import object The same object is returned by the REST API, by the MCP tools and by `--json`. It never contains a credential. ```json { "id": "01JA7Q3V9X2M8K4T6R5N0B1C2D", "url": "https://diffinery.com/api/v3/diffinery/imports/01JA7Q3V9X2M8K4T6R5N0B1C2D", "html_url": "https://diffinery.com/imports/01JA7Q3V9X2M8K4T6R5N0B1C2D", "kind": "repository", "parent_id": null, "status": "running", "attempt": 1, "source": { "full_name": "acme/website", "html_url": "https://github.com/acme/website", "api_url": "https://api.github.com" }, "target": { "owner": "acme", "name": "website", "full_name": "acme/website", "html_url": "https://diffinery.com/acme/website" }, "visibility": "keep", "options": { "issues": true, "releases": true, "labels": true, "branch_protection": true, "variables": true, "lfs": true, "secrets": "bypass" }, "created_by": { "login": "diffy", "type": "User" }, "steps": [ { "name": "repository", "title": "Create the repository", "status": "done", "message": "acme/website", "count": null, "started_at": "2026-10-13T09:00:01.000Z", "finished_at": "2026-10-13T09:00:03.000Z" }, { "name": "git", "title": "Copy branches, tags and history", "status": "running", "message": "Starting the import container", "count": null, "started_at": "2026-10-13T09:00:03.000Z", "finished_at": null } ], "git": { "phase": "push", "message": "Pushing history of main: chunk 3 (31.9 MB)", "chunks": 2, "bytes": 66846720, "tries": 1 }, "summary": {}, "warnings": [], "error": null, "created_at": "2026-10-13T09:00:00.000Z", "updated_at": "2026-10-13T09:04:12.000Z", "started_at": "2026-10-13T09:00:01.000Z", "finished_at": null } ``` | Field | Meaning | | --- | --- | | `kind` | `repository` or `organization` | | `parent_id` | For a repository of an organization import, the organization import's id | | `status` | `draft`, `queued`, `running`, `succeeded`, `failed` or `cancelled` | | `attempt` | 1, plus one per retry | | `target.html_url` | Set once the repository exists | | `steps` | Every step in order, with `status` (`pending`, `running`, `done`, `skipped`, `failed`), `message` and `count` | | `git` | The Git transfer: `phase` (`starting`, `clone`, `lfs-fetch`, `push`, `refs`, `lfs-push`, `cleanup`, `done` or `failed`), `message`, history `chunks` and `bytes` pushed, and `tries` | | `summary` | Filled in as steps finish: `counts` (`issues`, `pulls`, `comments`, `reviews`, `labels`, `milestones`), `git` (`branches`, `tags`, `pullRefs`, `lfsObjects`, `lfsBytes`, `defaultBranch` and more), `pullRefs.missing`, `protections`, `variables`, `secretNames`, `environments` (`imported`, `variables`, `secretNames`), `releases` (`imported`, `assets`, `skippedAssets`), `findings` (`kind`, `blob`, `path`), `people` (`linked`, `imported`), and for organization imports `orgVariables`, `orgSecretNames` and `children` counts | | `warnings` | Things that didn't come across, in words | | `error` | Why the import failed or who cancelled it | | `concurrency`, `candidates`, `children` | Organization imports only: the concurrency, the repositories to choose from (drafts), and each repository import (`id`, `html_url`, `source`, `target`, `status`, the running `step`, `error`) | ### Endpoints | Endpoint | Purpose | Response | | --- | --- | --- | | `POST /api/v3/diffinery/imports` | Start an import | `201` with the import | | `GET /api/v3/diffinery/imports` | The imports you started (not the repositories of an organization import), newest first, up to 100 | `200` with a list | | `GET /api/v3/diffinery/imports/{id}` | One import, with `children` for an organization import | `200`, or `404 import not found` | | `POST /api/v3/diffinery/imports/{id}/cancel` | Cancel a draft, queued or running import (an organization import cancels its queued and running repositories) | `200` with the import | | `POST /api/v3/diffinery/imports/{id}/retry` | Run a failed or cancelled import again; body `{"github_token": "ghp_..."}` (optional) | `202` with the import | | `POST /api/v3/diffinery/imports/{id}/selection` | Choose the repositories of a draft organization import (created in the web form) and start it; body `{"repositories": ["website", "docs"]}` | `202` with the import | You can read an import if you started it, if you are a person who administers the account it imports into, or if you are a site administrator. The same people can cancel it. Only whoever started it can retry it, except that a person who may import into the target can take over the retry of an agent's import (see [Retries, cancelling and the repository lock](#retries-cancelling-and-the-repository-lock)). Poll an import until it finishes: ```sh while :; do s=$(curl -fsS https://diffinery.com/api/v3/diffinery/imports/$ID -H "Authorization: Bearer dfy_..." | jq -r .status) echo "$s"; case "$s" in succeeded|failed|cancelled) break;; esac; sleep 10 done ``` See the [REST API reference](https://diffinery.com/docs/api) for every endpoint. ## Import over MCP The [MCP server](https://diffinery.com/docs/agents/mcp) has three import tools: | Tool | Read-only | What it does | | --- | --- | --- | | `start_github_import` | No | Start a single-repository import. Arguments: `source` and `owner` (required), `name`, `visibility` (`keep`, `private`, `public`), `github_token`, `github_api_url`. Returns the [import object](#the-import-object). | | `get_import` | Yes | An import by `id`: status, steps, warnings, error and, for an organization import, its repositories | | `list_imports` | Yes | The imports you started, newest first | `start_github_import` imports with every option on. An agent's import never bypasses push protection and skips variables, secret names and environments (see [Agents and imports](#agents-and-imports)). `start_github_import` needs a `write` or `admin` token, like every tool that isn't read-only. ```sh curl -s https://diffinery.com/mcp \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"start_github_import","arguments":{"source":"acme/website","owner":"acme"}}}' ``` Then call `get_import` with the returned `id` every minute or so until `status` is `succeeded`, `failed` or `cancelled`. ## What comes across | Data | What is imported | | --- | --- | | Repository | Description, homepage, topics (up to 20), default branch, archived state, and visibility as you chose | | Branches and tags | Every branch and tag. GitHub merge queue branches (`gh-readonly-queue/*`), `refs/pull//merge`, notes and other refs are skipped. | | Pull request heads | Each pull request's head commit, kept as `refs/pull//head`, also for merged and closed pull requests and pull requests from forks | | Git LFS | Every LFS object GitHub serves for the repository | | Issues | Number, title, body, author, assignees, labels, milestone, open or closed with its reason (`completed`, `not_planned`, `reopened`, `duplicate`), locked state and original dates | | Pull requests | Number, title, body, author, base and head branch, head commit, draft state, open, closed or merged, merge commit, who merged it, requested reviewers (open pull requests), labels, milestone and original dates | | Comments | Conversation comments on issues and pull requests, with author and dates | | Reviews | Approve, request changes, comment and dismissed reviews with body, commit and date (pending reviews are skipped). They never count as approvals. | | Review comments | Line comments with path, line, side, commit, review and reply threading | | Labels | Name, color and description | | Milestones | Number, title, description, state, due date, creator and dates | | Branch protection | Classic rules (see [Branch protection rules](#branch-protection-rules)) | | Actions variables | Repository variables with their values | | Secrets | Names only: repository secrets and environment secrets (organization secrets for organization imports) | | Environments | Name, deployment branch policy, custom branch patterns, and environment variables | | Organization variables | For an organization imported into a Diffinery organization: variables the Diffinery organization doesn't have yet | | Releases | Tag, target, name, notes, draft and pre-release state, author, dates, and assets up to 2 GiB each with their download counts | Issue and pull request numbers are kept, gaps and all. New issues and pull requests on Diffinery continue after the highest imported number, so links like `#123` in old text still point at the right place. ### Branch protection rules Each protected branch's classic rule becomes a Diffinery rule for the same branch name: **Require a pull request before merging**, required approvals, dismissing stale approvals, required status checks, force pushes and deletions allowed, linear history, and whether rules apply to admins. Required checks are renamed to the names Diffinery's workflows report (` / `), matched by job, so a rule that required `build` on GitHub requires `CI / build` here. Check them under **Settings → Branch protection** after the import. Repository rulesets aren't imported. ### Variables, secret names and environments - Repository variables are imported with their values. Names that start with `GITHUB_` or aren't valid variable names are skipped. - Secret **values are never read and never copied**: GitHub doesn't reveal them, and the importer doesn't try. Only names are listed, in the summary and on the import's page, for an admin to set by hand. Environment secret names appear as `/`. - Environments are created with their branch policy and variables; their secrets are listed by name only. - In an organization import into a Diffinery organization, the GitHub organization's variables are added to the Diffinery organization when it has no variable of that name. Existing ones are left alone (`Organization variable NPM_REGISTRY already exists and was left unchanged.`). Organization variables are shared by every repository of the organization; a repository variable of the same name wins. So only GitHub organization variables available to all repositories are copied. One limited to private or selected repositories on GitHub is skipped with a warning: `Organization variable NPM_TOKEN is limited to selected repositories on GitHub and was not copied; organization variables on Diffinery reach every repository, so add it to the repositories that need it.` ### Releases and assets Releases keep their tag, notes, dates and author. Importing a release creates no tag (the tags came with the Git data), starts no `release` workflows and sends no webhook. Assets are copied with their names, labels, content types and download counts; an asset with an unsafe file name, over 2 GiB, or on a host the server may not download from is skipped with a warning such as `Release v1.0.0: could not import asset "app.zip": ...`. ## What doesn't come across - **Secret values** of any kind. Set them again yourself (see [Set secret values](#set-secret-values)). - **Accounts.** No Diffinery accounts are created for GitHub users. See [People: linked accounts and mannequins](#people-linked-accounts-and-mannequins). - **Reactions**, wikis, projects, discussions, GitHub Pages, packages, webhooks, deploy keys, collaborators and teams, repository rulesets, Actions run history, and GitHub Apps. - **Required reviewers and wait timers** on environments. Set them again under Settings → Environments, with Diffinery people (see [Required reviewers and wait timers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers)). - **Auto-merge** on open pull requests. Turn it on again on Diffinery (see [Auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge)). - **Pull request heads whose fork was deleted on GitHub.** The pull request is imported but has no head commit (`3 pull requests have no head commit (their fork was deleted on GitHub): #12, #15, #40`). - **Anything a GitHub token couldn't read**, listed under **Warnings**. ## People: linked accounts and mannequins No accounts are created during an import. Each GitHub user who appears in the imported data (authors, commenters, reviewers, assignees, requested reviewers, who merged) is either mapped to a Diffinery account or shown as a mannequin. ### Mannequins A mannequin is a placeholder for a GitHub user who has no linked Diffinery account. It carries the history but isn't an account: it can't sign in or do anything on Diffinery, and its imported reviews never count as votes. | Where | How a mannequin appears | | --- | --- | | Web UI | The GitHub login with its picture and a **GitHub** badge, not linked, with the tooltip "Imported from GitHub; not linked to a Diffinery account" | | REST API | A user object with `login` `github:`, `type` `"Mannequin"`, and `html_url` and `url` pointing at the GitHub profile | | Assignees, requested reviewers, merged by | Stored as `github:` | | Issue search | `author:github:diffy` finds the mannequin's issues; `author:diffy` finds only the Diffinery account `diffy` | | Squash merges | A squash commit for a pull request a mannequin opened is authored as `diffy `, never as a Diffinery address | A deleted GitHub user appears as `ghost`. ### Link your GitHub account Linking proves that you control a GitHub account, so imports attribute that account's work to you. Link it **before** you import: links are applied as an import runs, and work already imported stays with the mannequin. In the web UI, go to **Settings → Linked accounts** (`https://diffinery.com/settings/linked-accounts`), the **Linked GitHub accounts** page. With a gist (no token needed): 1. Under **Step 1 · get a code**, leave **GitHub Enterprise API URL** blank for github.com (or enter your Enterprise API URL) and click **Get my code**. The code looks like `diffinery-link-3f9a...`. It is the same every time for your account and that host. 2. On GitHub, create a **public** gist whose description or one of whose files contains the code. 3. Under **Step 2 · verify your gist**, enter **Your GitHub username on github.com** and click **Verify and link**. You see "GitHub account linked." Diffinery reads your public profile and public gists without any token, from the API host you named, and links the account only when it finds the code. It looks at up to 100 of your gists (and reads the contents of up to 20). You can delete the gist afterwards. With GitHub OAuth, when the server administrator configured a GitHub OAuth app (`GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET`): the page shows **Link with GitHub**. Click **Continue to GitHub**, authorize read-only access to your profile (`read:user`), and you come back linked. The OAuth flow links github.com accounts only; its state is bound to your account, so a callback can't be replayed for someone else. To remove a link, click **Unlink** next to it and confirm. Only people can link accounts (`only people can link GitHub accounts`); agents can't. With the REST API (a token with the `admin` scope that isn't restricted to repositories): ```sh # Step 1: get the code (api_url is optional; default github.com) curl -X POST https://diffinery.com/api/v3/diffinery/user/linked-accounts/github/challenge \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" -d '{}' # {"nonce": "diffinery-link-...", "apiBase": "https://api.github.com", "host": "github.com", "gistsUrl": "https://api.github.com/gists"} # Step 2: after creating the public gist curl -X POST https://diffinery.com/api/v3/diffinery/user/linked-accounts/github \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" -d '{"login": "diffy"}' # 201 {"id": "...", "host": "github.com", "login": "diffy"} ``` | Endpoint | Purpose | | --- | --- | | `GET /api/v3/diffinery/user/linked-accounts` | Your links: `id`, `host`, `apiBase`, `login`, `externalId` (GitHub's numeric user id), `createdAt` | | `POST /api/v3/diffinery/user/linked-accounts/github/challenge` | Your code for a host; body `{"api_url": "..."}` (optional) | | `POST /api/v3/diffinery/user/linked-accounts/github` | Verify the gist and link; body `{"login": "diffy", "api_url": "..."}` | | `DELETE /api/v3/diffinery/user/linked-accounts/{id}` | Remove one of your links (`204`) | | `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | Site administrators: remove anyone's link (`204`), recorded in the audit log as `account.link_cleared` | Linking and unlinking are audited as `account.link_github` and `account.unlink_github`. ### How links are matched - A link stores GitHub's **numeric user id**, not only the login, and imports match by that id. Renaming a GitHub account, or someone else taking an old login, can't move work to the wrong person. - Links are kept per GitHub host (the full API base), so a github.com link and an Enterprise link are separate even if the logins match. - One GitHub account can be linked to only one Diffinery account (`GitHub user diffy is linked to another Diffinery account`). One Diffinery account can link several GitHub accounts. - Only links of active accounts are used. The summary lists which GitHub users were mapped (`people.linked`) and which became mannequins (`people.imported`). ### Imported reviews never count as approvals Imported reviews are history, not votes. A review imported from GitHub never counts toward required approvals and never blocks a merge, even when its author is a linked account and even if it was an approval. A pull request still open after the import needs a fresh review on Diffinery to be approved. ## Secrets in old history Imported history goes through [push protection](https://diffinery.com/docs/security/push-protection) like any push. When old commits contain something that looks like a credential, the `secrets` option decides: | Option | What happens | | --- | --- | | **Import with an audited bypass** (`bypass`, the default) | The history is imported anyway, because it is already on GitHub. The push carries the reason `Imported from GitHub acme/website`, is audited as `push.secret_bypassed` with the import and the blobs, and the import's page lists each finding under **Possible secrets in history (rotate these)** (`summary.findings`, with the file path where one was found). Rotate those credentials. | | **Stop the import** (`stop`) | The import fails at the Git step so you can clean the history first. | A bypass works only for a person who is an admin of the target repository and started the import with an `admin`-scoped token or from the web. Otherwise (an agent, or a `write` token) the import fails with `Push protection found possible secrets in the history (AWS access key). Only a person who administers the target can import them with an audited bypass (agents and write-scoped tokens cannot); start a new import as such a person.` **Agents never bypass push protection.** An import started by an agent always uses `stop`, whatever it asks for. A retry keeps the original options, so it can't switch from `stop` to `bypass`. After a stop, rotate the credentials, then start a new import with `bypass` as an admin (into a new name, or after deleting the partly imported repository), or clean the history on GitHub first. ## Limits | Limit | Value | | --- | --- | | Imports running at once | Per account that starts them and per target account: at most 3 single-repository imports (`too many repository imports are already running (at most 3 at a time); wait for one to finish`) and 1 organization import, drafts included (`an organization import is already in progress (at most 1 at a time); wait for it to finish or cancel it`). Repositories inside an organization import don't count toward the 3. | | Repository size | 10 GB by default, as GitHub reports it; a single-repository import over it is refused up front with `the repository is about 12 GB, over the 10 GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. In an organization import, a repository over it fails with the same message before anything is copied, and the others carry on. Self-hosters set `IMPORT_MAX_REPO_KB`. | | Git transfer | 6 hours. A transfer still running after that fails with `the Git transfer did not finish within 6 hours`. A failed transfer is tried a second time automatically. | | Single Git object | 100 MB (the push limit). A larger blob can't be imported. | | Repositories per organization import | 1,000 | | Concurrency of an organization import | 1 to 8, default 3 | | Organization import duration | The organization import waits up to a week for its repositories | | Draft organization imports | Cancelled after 7 days (`draft expired`), and the GitHub token erased | | Release asset | 2 GiB each | | Warnings kept per import | 500 | ## Retries, cancelling and the repository lock **Cancel** an import with **Cancel import** on its page (confirm "Cancel this import?"), or `POST /api/v3/diffinery/imports/{id}/cancel`. Its container is stopped, its credentials are revoked and the repository lock is released. Cancelling one repository of an organization import starts the next queued one. Cancelling a finished import fails with `the import already succeeded` (or `failed`, `cancelled`). **Retry** a failed or cancelled import with **Retry this import** on its page, or `POST /api/v3/diffinery/imports/{id}/retry`: - Whoever started the import can retry it. The one exception is an import an agent started: a person who may import into the target (its owner, an owner or admin of the target organization, or a site administrator) can retry it too, and should once the repository exists. Anyone else gets `404 import not found`. - Give the GitHub token again for a private repository (or for LFS, variables and protection): the first run's token was deleted when it ended. Leave it out for a public repository. - Retry the whole organization import, not one of its repositories (`retry the organization import this repository belongs to`). Its failed and cancelled repositories are retried; those that failed because the name already existed are not. - The options can't change on a retry. - Finished work isn't repeated or duplicated: steps write by GitHub id, and the Git transfer only sends what the repository doesn't have yet. - An agent can't retry once the repository was created (`an agent cannot retry an import after the repository was created; a person who administers acme must retry it`). A person who administers the target retries it with **Retry this import** or the API, giving the GitHub token again if the source is private. **The repository lock.** From the moment the import creates the repository until it finishes, fails or is cancelled, only the import may write to it. Any other push gets `repository is locked by an import in progress`, and any other write through the REST API, the web UI or MCP gets `423 repository is locked by an import in progress; try again when it finishes, or cancel the import`. Watching the repository still works, and so do Actions secrets and variables, environments (with their secrets and variables), and collaborators and invitations. The import never writes secret values; its variables step writes GitHub's values, so a variable you set before that step under the same name ends with GitHub's value. The import itself may only create refs or move refs it wrote. A retry claims the repository again only when it holds nothing the import didn't write. If anyone pushed a branch, opened an issue, added a label, a protection rule or a release in the meantime (for example after the import failed and released the lock), the retry fails with `repository already holds content not written by this import; start a new import`. A failed or cancelled import leaves the partly imported repository in place; delete it before importing again under the same name. ## Agents and imports Agents can start imports over MCP, the REST API or the CLI, with these rules: - **Only where its owner may import.** An agent owned by a person imports into that person's own account or an organization the person owns or administers (anywhere, if the person is a site administrator). An agent owned by an organization imports into that organization. Otherwise `agents can import only where their owner may: the owner's own account or an organization the owner administers, not `. - **The agent's maximum role must be at least `write`** (`this agent's policy does not allow writing; an import needs at least write`), and its token needs the `write` scope. - **Never a push protection bypass.** The `secrets` option is forced to `stop`. - **No variables, secret names or environments.** The **Variables and secret names** step, and an organization import's **Organization variables and secret names** step, are skipped with `skipped (agents cannot manage variables)`. A person imports or sets those afterwards. For people, the right to manage variables is checked again when that step runs. - **No retry after the repository exists.** A person who administers the target takes the retry over. - **No secret values**, ever. Agents can't set secrets anywhere; a person with admin access sets them. - An agent sees and lists only the imports it started. Everything else (Git data, issues, pull requests, reviews, labels, milestones, branch protection, releases) is imported as for a person. See [Agents](https://diffinery.com/docs/agents/overview). ## After the import ### Set secret values The import lists secret names; set each value from where you keep it (a password manager, your cloud provider's console). Repository admins who are people can: - Click **Set secret values** on the import's page. It lists each repository secret name with a `set` or `empty` badge, a **Value** field and **Save**. - Use the CLI, which prompts for the value without echoing it: ```sh diffinery secret set NPM_TOKEN -R acme/website printf '%s' "$NPM_TOKEN" | diffinery secret set NPM_TOKEN -R acme/website diffinery secret set SENTRY_DSN --org acme # organization secret ``` - Use the API (see [Move secrets and variables](#move-secrets-and-variables)). Environment secrets (listed as `production/DEPLOY_KEY`) are set with the environments API, as in [Environments and their secrets](#environments-and-their-secrets). You don't have to wait for the import to finish: secrets, variables, environments and collaborators can be set while it is still running (see [Retries, cancelling and the repository lock](#retries-cancelling-and-the-repository-lock)). Set them before your first push, because that push starts the imported workflows. ### Check the rest 1. Under **Settings → General**, check the default branch, description and visibility. 2. Under **Settings → Branch protection**, check the imported rules and their required check names. 3. Under **Settings → Collaborators**, add people, and invite your team to the organization. 4. Recreate webhooks under **Settings → Webhooks**. 5. Push a commit and watch the first workflow runs. The import itself started none. 6. If the repository landed under the wrong owner or name, [transfer](https://diffinery.com/docs/repositories/overview#transfer-a-repository) or [rename](https://diffinery.com/docs/repositories/overview#rename-a-repository) it once the import has finished (a transfer is refused while the import runs). The old Diffinery URL keeps working. 7. Agent settings in branch protection (**Allow agents to push directly**, **Allow agents to merge pull requests**, **Allow agents to land pull requests through auto-merge** and its approval waiver) never come across with an import; an admin sets them here. ## Move with plain Git If you only need the code, copy it with Git (2.26 or newer). Create an empty repository on Diffinery first (web: `https://diffinery.com/new`, or CLI), using the same default branch name as on GitHub: ```sh diffinery repo create website --org acme --private --default-branch main ``` Then copy every branch and tag: ```sh git clone --mirror https://github.com/acme/website.git cd website.git git push https://diffinery.com/acme/website.git 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' ``` Push branches and tags explicitly rather than with `git push --mirror`: a mirror clone from GitHub also contains GitHub's `refs/pull/*` refs, and Diffinery rejects pushes to `refs/pull/` (`refs/pull/ is managed by Diffinery for pull requests`). If the push is over 100 MB, it fails with `push exceeds 100 MB; split it into smaller pushes`. Push the history in steps, oldest first: ```sh # Push the default branch in chunks of 2,000 commits, then everything else for sha in $(git rev-list --reverse --first-parent main | awk 'NR % 2000 == 0'); do git push https://diffinery.com/acme/website.git "$sha:refs/heads/main" done git push https://diffinery.com/acme/website.git 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' ``` If push protection blocks the push because old commits contain something that looks like a credential, rotate that credential. A repository admin (a person, not an agent) using an `admin`-scoped token can then push anyway with a reason, which is audited: ```sh git push -o allow-secrets="historic keys, all rotated" https://diffinery.com/acme/website.git 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' ``` If GitHub's default branch isn't the one you created the repository with, change it under **Settings → General → Default branch**. ## Move releases The importer copies releases and their assets. If you moved the code with plain Git or `--local`, recreate releases with the GitHub-compatible releases API once the tags are on Diffinery. This script uses GitHub's `gh` CLI to read each release and `curl` to recreate it, oldest first so the newest ends up as the latest release, and copies its assets: ```sh export DIFFINERY_TOKEN=dfy_... # write scope or higher SRC=acme/website # on GitHub DST=acme/website # on Diffinery API=https://diffinery.com/api/v3 gh release list -R "$SRC" --limit 1000 --json tagName --jq 'reverse | .[].tagName' | while read -r tag; do gh release view "$tag" -R "$SRC" --json tagName,name,body,isDraft,isPrerelease \ --jq '{tag_name: .tagName, name: .name, body: .body, draft: .isDraft, prerelease: .isPrerelease}' > release.json id=$(curl -fsS -X POST "$API/repos/$DST/releases" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "content-type: application/json" \ --data-binary @release.json | jq -r .id) rm -rf assets && mkdir assets gh release download "$tag" -R "$SRC" -D assets 2>/dev/null || true for f in assets/*; do [ -f "$f" ] || continue name=$(jq -rn --arg n "$(basename "$f")" '$n | @uri') curl -fsS -o /dev/null -X POST "https://diffinery.com/api/uploads/repos/$DST/releases/$id/assets?name=$name" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "content-type: application/octet-stream" \ --data-binary @"$f" echo "$tag: uploaded $(basename "$f")" done done ``` Things to know: - Push the tags first. If a release's tag is missing on Diffinery, publishing the release creates the tag on the default branch, which is probably not the commit you want. - Unlike imported releases, each release published this way starts the repository's `on: release` workflows, and webhooks get a `release` event. Disable or guard those workflows during the copy if they publish or deploy. - Release notes, titles, draft and pre-release state are copied. Authors and dates are not: the releases are created by you, now. - An asset uploaded through the API can be up to 100 MB. - Source code archives (**Source code (zip)** and **Source code (tar.gz)**) are not assets: Diffinery builds them from the tag. See [Source code archives](https://diffinery.com/docs/repositories/overview#source-code-archives). See [Releases](https://diffinery.com/docs/collaboration/releases) for the API in full. ## Move Git LFS files The importer copies Git LFS objects. Neither `git clone --mirror` nor `diffinery migrate github --local` does: they copy only the pointers. If you moved the code that way and the repository uses LFS, copy the files from a clone that can fetch them: ```sh git clone https://github.com/acme/website.git website-lfs cd website-lfs git lfs fetch --all origin git lfs push --all https://diffinery.com/acme/website.git ``` Files over 100 MB can't be uploaded to Diffinery. See [Git LFS](https://diffinery.com/docs/repositories/git-lfs). ## Move secrets and variables ### Secrets GitHub never shows secret values, so you set them again on Diffinery. Get the list of names from the import's summary, from `diffinery migrate github`'s output, or with GitHub's CLI (`gh secret list -R acme/website`). Then set them as in [Set secret values](#set-secret-values), or with the API (an admin role on the repository, and not an agent): ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"value": "npm_..."}' ``` Unlike GitHub, the API takes the plain value over HTTPS; there is no public-key encryption step, and a sealed-box `encrypted_value` is refused (send `value` instead). Secret names use letters, digits and `_`, are stored in upper case, and can't start with `GITHUB_`. To copy secrets without handling the values yourself, let a GitHub workflow send them, as in [Keep GitHub as upstream](#keep-github-as-upstream-and-mirror-to-diffinery). ### Environments and their secrets Diffinery has deployment environments with their own secrets, variables and branch policies, like GitHub. The importer creates them with their branch policies and variables. They have no page in the web UI yet: change them, and set their secrets, with the REST API, as a repository admin who is a person (agents can't manage environments, secrets or variables). ```sh # Create the environment (the importer already did if it came from GitHub) curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \ -H "Authorization: Bearer dfy_..." # Add its secrets curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/DEPLOY_KEY \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"value": "..."}' # Add its variables curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}' ``` Jobs with `environment: production` then get these secrets and variables. When an environment without a branch policy gets its first secret, it is limited to protected branches, so add a [branch protection](https://diffinery.com/docs/repositories/branch-protection) rule for the branch you deploy from (or set the environment's branch policy). Add required reviewers and a wait timer under Settings → Environments. See [Environments](https://diffinery.com/docs/actions/environments). ### Variables The importer and `diffinery migrate github --local` copy repository variables. To copy them yourself: ```sh gh variable list -R acme/website --json name,value --jq '.[] | [.name, .value] | @tsv' | while IFS=$'\t' read -r name value; do diffinery variable set "$name" --body "$value" -R acme/website done ``` Organization variables are shared by every repository of a Diffinery organization; a repository variable of the same name wins. An organization import copies them. Owners and admins of the organization (people, with an `admin`-scoped token for changes) manage them with `GET` and `POST /api/v3/orgs/{org}/actions/variables` and `GET`, `PATCH` and `DELETE /api/v3/orgs/{org}/actions/variables/{name}`: ```sh curl -X POST https://diffinery.com/api/v3/orgs/acme/actions/variables \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"name": "NPM_REGISTRY", "value": "https://registry.npmjs.org"}' ``` See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). ## Update remotes and local clones Everyone with a clone points it at Diffinery: ```sh git remote set-url origin https://diffinery.com/acme/website.git git fetch origin ``` Or keep GitHub as a second remote while you move: ```sh git remote rename origin github git remote add origin https://diffinery.com/acme/website.git git fetch origin git branch --set-upstream-to=origin/main main ``` Git asks for a password the first time: use a Diffinery token, or run `diffinery auth setup-git` once so Git gets it from the CLI. See [Git over HTTPS](https://diffinery.com/docs/repositories/git). People who clone from VS Code, Cursor or Windsurf install the Diffinery extension, which puts **Clone from Diffinery** next to **Clone from GitHub** in the clone picker and signs Git in through the browser: see [Clone in VS Code, Cursor and other editors](https://diffinery.com/docs/repositories/clone-in-your-editor). People who use IntelliJ IDEA, PyCharm, WebStorm, GoLand or another JetBrains IDE install the Diffinery plugin, which adds **Diffinery** next to GitHub in **Get from Version Control**: see [Clone in JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides). Also update anything else that holds the old URL: CI and deploy scripts, package manifests (`repository` fields), submodule URLs in `.gitmodules`, badges and links in READMEs. ## Workflows Diffinery runs GitHub Actions workflows from `.github/workflows/` as they are: the same triggers, expressions, matrices, `needs`, job outputs, caches, artifacts, secrets, `GITHUB_TOKEN` permissions, OIDC, `actions/checkout`, `actions/setup-node`, `setup-python`, `setup-go`, `actions/github-script`, and remote JavaScript and composite actions. `runs-on: ubuntu-latest` runs on Diffinery's hosted runners. An import starts no workflow runs; the first push after it does. Things to check after the move: | If a workflow... | Do this on Diffinery | | --- | --- | | Has jobs that mostly wait (deploys, API calls, polling), or runs on every push | They run as they are. To spend less hosted runner time, pin a smaller size for waiting jobs (`runs-on: [ubuntu-latest, diffinery-small]`) and cancel superseded pull request runs with a `concurrency` group. See [What hosted jobs cost](https://diffinery.com/docs/actions/runners#what-hosted-jobs-cost-and-keeping-it-low). | | Uses `runs-on: windows-*` or `macos-*` | Diffinery has no Windows or macOS hosted runners: Cloudflare Containers run Linux only. Run the job on a self-hosted runner (`diffinery runner`). See [Runners](https://diffinery.com/docs/actions/runners). | | Uses `actions/github-script` | Works with the REST API (`github.rest.*`) and the job's token. `github.graphql` doesn't: the job's token can't use Diffinery's [GraphQL API](https://diffinery.com/docs/reference/graphql-api) yet. | | Relies on `GITHUB_TOKEN` writing (comments, labels, statuses, releases, pushes) without a `permissions:` block | Repositories created on Diffinery, imported ones included, give the job token GitHub's restricted default, `contents` and `packages` read. Add a `permissions:` block (for example `contents: write`, `issues: write`, `pull-requests: write`) to the workflow or job, or have an admin set the repository default to write. Each endpoint needs its permission, as on GitHub; a missing one gets `403 Resource not accessible by integration`. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions). | | Pushes commits or tags using `GITHUB_TOKEN` | Works with `permissions: contents: write`, as on GitHub, within branch protection. The token can't change files under `.github/workflows/`, and a push made with it starts no new `push` workflows. | | Opens or approves pull requests using `GITHUB_TOKEN` | Refused until a repository admin allows it (`can_approve_pull_request_reviews` in the workflow permissions setting, off by default, as GitHub's **Allow GitHub Actions to create and approve pull requests**). Approvals from the token never count toward required approvals. | | Uses environments (`environment: production`) | The importer creates the environments and their variables; set their secrets with the API (see [Environments and their secrets](#environments-and-their-secrets)). Required reviewers and wait timers don't come across; set them again under Settings → Environments. | | Runs on `release` or `repository_dispatch` | Works: publishing a release on Diffinery starts `on: release` workflows, and `POST /api/v3/repos/acme/website/dispatches` starts `on: repository_dispatch` workflows. | | Checks out Git LFS files (`lfs: true`) | Works as on GitHub. See [Git LFS](https://diffinery.com/docs/repositories/git-lfs). | | Publishes images to `ghcr.io` | Use your instance's registry (`diffinery.com` here) instead. See [Container registry](https://diffinery.com/docs/packages/container-registry). | | Hard-codes `github.com` or `api.github.com` | Use `${{ github.server_url }}` (`https://diffinery.com`) and `${{ github.api_url }}` (`https://diffinery.com/api/v3`). | | Should run only on one forge | Guard it with `if: github.server_url == 'https://github.com'` (or `'https://diffinery.com'`). | A job that uses an unsupported feature fails with a message saying so. `diffinery workflow validate` checks workflow files before you push. Full details are in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ## Scripts and tools that call GitHub's API Diffinery serves a GitHub-compatible REST API at `https://diffinery.com/api/v3`, with the same paths, JSON shapes, errors and `Link`-header pagination, so most scripts and Octokit-based tools only need a new base URL and a Diffinery token: ```js import { Octokit } from "@octokit/rest"; const octokit = new Octokit({ auth: process.env.DIFFINERY_TOKEN, baseUrl: "https://diffinery.com/api/v3" }); const { data } = await octokit.rest.repos.listReleases({ owner: "acme", repo: "website" }); ``` Inside workflows, `${{ github.api_url }}` is already `https://diffinery.com/api/v3`. Imported people without a Diffinery account come back as user objects with `"type": "Mannequin"` and a `github:` login, so tools that look up users by login should expect them. Differences to check, including endpoints and features that are not supported yet, are listed in the [REST API reference](https://diffinery.com/docs/reference/rest-api). The [GraphQL API](https://diffinery.com/docs/reference/graphql-api) covers repositories, pull requests and CI, which is what the GitHub CLI needs (see [GitHub CLI and Claude Code](https://diffinery.com/docs/agents/github-cli)). ## Keep GitHub as upstream and mirror to Diffinery You can keep GitHub as the place where people push and review, and mirror every push to Diffinery with a GitHub Actions workflow. Diffinery then runs its own Actions on each mirrored commit. Diffinery itself was developed this way: its GitHub repository mirrors every branch and tag, plus the secrets its workflows need, to Diffinery on each push, and fails if the runs on Diffinery fail. Set it up: 1. Create the Diffinery repository (for example `acme/website`). 2. Create a Diffinery token with the `admin` scope for an account that is an admin of that repository. If you want force pushes to protected branches to go through, protection rules must let admins bypass them. 3. On GitHub, add it as a repository secret named `DIFFINERY_TOKEN`. 4. Add this workflow on GitHub as `.github/workflows/mirror-to-diffinery.yml`: ```yaml name: Mirror to Diffinery on: push: workflow_dispatch: permissions: contents: read # One mirror at a time, in push order. concurrency: group: mirror-to-diffinery cancel-in-progress: false jobs: mirror: # The mirrored copy of this file also exists on Diffinery; do nothing there. if: ${{ github.server_url == 'https://github.com' }} runs-on: ubuntu-latest env: DIFFINERY: https://diffinery.com TARGET: acme/website TOKEN: ${{ secrets.DIFFINERY_TOKEN }} steps: - name: Copy Actions secrets env: S_NPM_TOKEN: ${{ secrets.NPM_TOKEN }} S_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} run: | set -euo pipefail for name in NPM_TOKEN SENTRY_DSN; do v="S_$name" [ -n "${!v}" ] || { echo "::warning::secret $name is empty here; not copied"; continue; } jq -n --arg v "${!v}" '{value: $v}' | curl -fsS -o /dev/null -X PUT \ "$DIFFINERY/api/v3/repos/$TARGET/actions/secrets/$name" \ -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @- echo "secret $name copied" done - uses: actions/checkout@v4 with: fetch-depth: 0 # lfs: true # if the repository uses Git LFS - name: Push every branch and tag run: | set -euo pipefail git fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*' refs=$(git for-each-ref --format='+%(refname):refs/heads/%(refname:lstrip=3)' refs/remotes/origin | grep -v ':refs/heads/HEAD$') git -c "http.$DIFFINERY/.extraHeader=Authorization: Bearer $TOKEN" \ push -q "$DIFFINERY/$TARGET.git" $refs '+refs/tags/*:refs/tags/*' # If the repository uses Git LFS: # git lfs ls-files | grep -q . && git -c "http.$DIFFINERY/.extraHeader=Authorization: Bearer $TOKEN" lfs push --all "$DIFFINERY/$TARGET.git" ``` How it works: - The checkout fetches the full history, then every GitHub branch is pushed to the branch of the same name on Diffinery, with a leading `+` so GitHub always wins. Changes made directly on Diffinery to mirrored branches are overwritten. - Branches deleted on GitHub are not deleted on Diffinery. Delete them there by hand (`git push https://diffinery.com/acme/website.git --delete old-branch`). - The secrets step sends each value from GitHub's secret store, inside your own workflow, straight to Diffinery over HTTPS; values are never printed. List every secret your workflows use. Variables can be copied the same way with `vars.NAME` and the `actions/variables` endpoint. - The `if:` guard keeps the job from running on Diffinery, which also runs `.github/workflows/` files once they are mirrored. - If push protection blocks a mirrored push, a repository admin can let it through with `-o allow-secrets=` on the `git push` line, for example from a `workflow_dispatch` input. Leave it empty normally. ### Wait for Diffinery's runs To make the GitHub workflow fail when the workflows on Diffinery fail, add a step that polls Diffinery's runs for the pushed commit: ```yaml - name: Wait for Actions on Diffinery if: github.ref == 'refs/heads/main' run: | set -euo pipefail sha=$(git rev-parse HEAD) api() { curl -fsS "$DIFFINERY/api/v3/repos/$TARGET$1" -H "Authorization: Bearer $TOKEN"; } for i in $(seq 1 240); do runs=$(api "/actions/runs?head_sha=$sha&per_page=100") total=$(jq '.workflow_runs | length' <<<"$runs") pending=$(jq '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$runs") [ "$total" -gt 0 ] && [ "$pending" = 0 ] && break sleep 15 done jq -r '.workflow_runs[] | "\(.name) [\(.event)]: \(.status) \(.conclusion) \(.html_url)"' <<<"$runs" [ "$(jq '[.workflow_runs[] | select(.conclusion != "success")] | length' <<<"$runs")" = 0 ] ``` `GET /api/v3/repos/{owner}/{repo}/actions/runs?head_sha=` lists the runs for a commit, with `status`, `conclusion` and `html_url`; `GET .../actions/runs/{id}/jobs` and `GET .../actions/jobs/{id}/logs` give the jobs and logs of a failed run. Runs started by other runs (`workflow_run`) can appear after the first ones finish, so a robust script waits until the list stays complete for a few polls. ## Checklist 1. Get a Diffinery account and create the organization. 2. Link your GitHub account under **Settings → Linked accounts**, and ask your team to link theirs, so their work is attributed to them. 3. Create a GitHub token that can read the repositories (admin access for branch protection, variables and secret names). 4. Import: **Settings → Imports → Import from GitHub**, `diffinery migrate github acme/website --to acme/website`, or `diffinery migrate github-org acme --to acme`. 5. Read the import's **Warnings** and rotate anything under **Possible secrets in history (rotate these)**. 6. Set every secret listed (**Set secret values**), environment secrets with the API, and organization secrets with `diffinery secret set NAME --org acme`. 7. Check the default branch, description and visibility under **Settings → General**, and the imported rules under **Settings → Branch protection** (required check names are ` / `; see [Branch protection](https://diffinery.com/docs/repositories/branch-protection)). 8. Add collaborators under **Settings → Collaborators**, and invite your team to the organization. 9. Recreate webhooks under **Settings → Webhooks**. Deliveries carry GitHub's `X-GitHub-Event` header and are signed with `X-Hub-Signature-256`, but check the payload fields your receiver uses. Failed deliveries (network errors, `408`, `429` and `5xx`) are retried up to five times with the same `X-GitHub-Delivery` ID, so make your receiver ignore a delivery ID it has already handled. See [Webhooks](https://diffinery.com/docs/repositories/webhooks). 10. If an outside CI system reported commit statuses or checks to GitHub, point it at `https://diffinery.com/api/v3` with a Diffinery token. Its status contexts and check names can be required checks in branch protection, as on GitHub. See [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). 11. Push a commit and watch the first workflow runs in the **Actions** tab (or `diffinery run list -R acme/website`). Fix anything from [Workflows](#workflows). 12. Create agent accounts for AI tools and connect them over MCP. See [Agents](https://diffinery.com/docs/agents/overview). 13. Update remotes on every clone, and URLs in scripts, manifests and READMEs. 14. When the team has moved, archive the GitHub repository or remove the mirror workflow. ## Troubleshooting ### Starting an import | Message | Cause and fix | | --- | --- | | `GitHub repository acme/website was not found or the token cannot read it` | The name is wrong, or the repository is private and the GitHub token is missing or can't see it. | | `GitHub rejected the token (expired or revoked?)` | Create a new GitHub token. | | `acme has reached its storage limit of N bytes (...)` | The Diffinery owner is at its [storage quota](https://diffinery.com/docs/reference/limits#account-quotas). An import stores what it copies, so it is refused up front, and one that reaches the limit on the way stops with the same message. Free space or ask a site administrator to raise it, then retry. | | `no GitHub organization or user named acme` | Check the spelling, and `github_api_url` for Enterprise. | | `acme has no repositories to import` | The account has none the token can see, or the filters excluded all of them. | | `acme/website already exists; choose another name` | Pick another `name` (or **New repository name**), or delete the existing repository. | | `you can import only into your own account or organizations you administer, not acme` | Ask an owner of `acme` to make you an admin, or to run the import. | | `agents can import only where their owner may: ...` | The target isn't the owner's own account or an organization the owner administers (or, for an organization's agent, that organization). | | `starting an import needs a token with write or admin scope` | Use a `write` or `admin` token. | | `this token needs the write scope` or `this token is limited to repositories and can't change account settings` | Cancelling, retrying and choosing repositories need a `write` token that isn't restricted to repositories. | | `too many repository imports are already running (at most 3 at a time); wait for one to finish` | Wait for one to finish, or cancel one. | | `an organization import is already in progress (at most 1 at a time); wait for it to finish or cancel it` | Includes drafts waiting on **Choose repositories**. Finish or cancel it. | | `the repository is about N GB, over the 10 GB import limit; ...` | Split the repository, or on your own instance raise `IMPORT_MAX_REPO_KB`. | | `give either source (one repository) or source_owner (an organization or user)` | Send exactly one of them. | | `github_api_url is required when a github_token is given and the source names a GitHub Enterprise host` | Add `github_api_url` for that host, or pass `owner/name` as the source. | | `github_api_url must be on the same host as the source repository` | The API URL and the source URL name different hosts. | | `GitHub rate limit reached; continuing in N seconds` | Shown on a step while the importer waits; it continues by itself. At start time it comes back as `429`; try again later or use a GitHub token. | ### While an import runs | Message | Cause and fix | | --- | --- | | `Push protection found possible secrets in the history (...)` | See [Secrets in old history](#secrets-in-old-history). | | `a single Git object of N MB could not be pushed even on its own (...)` | One file in the history is too large for Diffinery to take in one push (the server's answer is in brackets). Remove it from history on GitHub (for example with `git filter-repo`), or move it to Git LFS, and import again. | | `pushes kept failing even in chunks of N KB (...)` | Diffinery kept refusing or dropping the transfer's pushes; the server's answer is in brackets. Retry the import; if it fails the same way, report it. | | `the Git transfer did not finish within 6 hours` | The repository is too large or GitHub was too slow. Retry, or split the repository. | | `the import container stopped before finishing (exit code ...)` | The transfer is retried once automatically; after that, retry the import. | | `The Git transfer failed and is being retried: ...` (warning) | The first transfer failed and a second one started. Nothing to do. | | `repository is locked by an import in progress` (on a push), or `423 repository is locked by an import in progress; try again when it finishes, or cancel the import` | You wrote to a repository an import is still filling. Wait for the import to finish, or cancel it. Actions secrets and variables, environments and collaborators aren't locked: set them while the import runs. | | `repository already holds content not written by this import; start a new import` | Someone changed the repository between attempts. Start a new import under another name, or delete the repository first. | | `skipped (agents cannot manage variables)` | An agent started the import. A person sets variables and secrets afterwards. | | `skipped (you no longer administer the target)` | You lost admin rights on the target before the variables step ran. | | `Branch protection of main could not be read ...` | The GitHub token lacks admin access. Set the rule up under **Settings → Branch protection**. | | `Actions variables could not be read ...` (or `secrets`, `environments`) | Give the token admin access, or the fine-grained **Variables** and **Secrets** read permissions, and set the rest by hand. | | `N pull requests have no head commit (their fork was deleted on GitHub): ...` | Those pull requests are imported without their commits. Nothing to do. | | `the person or agent who started this import can no longer import into this account` | The starter lost the right to import there, or their account was disabled. | ### Retrying and cancelling | Message | Cause and fix | | --- | --- | | `import not found` | You didn't start the import (only the starter can retry, or for an agent's import a person who administers the target), or you can't see it. | | `only failed or cancelled imports can be retried` | The import is still running or succeeded. | | `retry the organization import this repository belongs to` | Retry the parent import. | | `an agent cannot retry an import after the repository was created; a person who administers must retry it` | A person who administers the target retries it. | | `the import already succeeded` (or `failed`, `cancelled`) | Only drafts and queued or running imports can be cancelled. | ### Linking a GitHub account | Message | Cause and fix | | --- | --- | | `create a PUBLIC gist whose description or a file contains diffinery-link-..., then try again` | The gist is missing, secret, or doesn't contain the exact code. | | `GitHub user diffy was not found` | Check the username and the host. | | `GitHub user diffy is linked to another Diffinery account` | Unlink it there first, or ask a site administrator to clear the link. | | `that is not a valid GitHub login` | Use the username, not an email address or URL. | | `invalid or expired OAuth state` | Start **Continue to GitHub** again from your own settings page. | ### Errors from the local import | Message | Cause and fix | | --- | --- | | `git clone --mirror failed: ...` | Git couldn't clone from GitHub. Check `GH_TOKEN` and that Git is installed. | | `warning: bulk push failed (...); pushing refs one at a time` | One push was too big or a ref was rejected. Check the refs listed as failed in the summary. | | A ref fails with `secret detected (push protection)` | Rotate the credential, then push that ref with plain Git and `-o allow-secrets=` as a repository admin, or use the server-side importer. | | A ref fails with `push exceeds 100 MB` | Push that branch's history in steps, as in [Move with plain Git](#move-with-plain-git), or use the server-side importer. | | `could not read ... from GitHub (403; the token may lack access)` | The GitHub token can't read that data. | | `agents cannot manage variables` or `agents may not push to protected or default branches` | `--local` needs a token of your own account, not an agent's. | --- # Guides > Short, task-focused guides to common jobs on Diffinery: moving GitHub Actions workflows, faster CI, AI coding agents with guardrails, and trying Diffinery CI from GitHub. Source: https://diffinery.com/docs/guides Each guide answers one question in a few steps and links to the pages that cover the details. | Guide | What it covers | | --- | --- | | [Move your GitHub Actions workflows to Diffinery](https://diffinery.com/docs/guides/move-github-actions-workflows) | What runs unchanged, runner labels and sizes, marketplace actions, secrets, caches, Docker, OIDC, environments, self-hosted runners for macOS, Windows and Arm, and a checklist. | | [Make your CI faster on Diffinery](https://diffinery.com/docs/guides/faster-ci) | Runner sizes and automatic sizing, caching, setup and Docker head starts, matrices, cancelling superseded runs, and measured numbers. | | [Give an AI coding agent its own account, with guardrails](https://diffinery.com/docs/guides/ai-agent-with-guardrails) | Create an agent and its token, what the server enforces, connecting Claude Code, Codex or any MCP client, reviewing and merging agent pull requests, and auditing what agents did. | | [Run Diffinery CI while your code stays on GitHub](https://diffinery.com/docs/guides/mirror-from-github) | Mirror every GitHub push to Diffinery, copy the secrets your workflows need, and fail on GitHub when Diffinery's runs fail. | Other task pages: [Quickstart](https://diffinery.com/docs/quickstart), [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github), [Deploy to Cloudflare Workers](https://diffinery.com/docs/actions/deploy-to-cloudflare-workers), [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green) and [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents). --- # Move your GitHub Actions workflows to Diffinery > Run your existing .github/workflows files on Diffinery: what runs unchanged, runner labels and sizes, secrets, caches, Docker, OIDC, environments, and what to change. Source: https://diffinery.com/docs/guides/move-github-actions-workflows Most GitHub Actions workflows run on Diffinery without changes: Diffinery reads `.github/workflows/*.yml`, supports the same triggers, expressions, matrices, `needs`, caches, artifacts, secrets, OIDC and Docker, and downloads marketplace actions from GitHub as they are. Jobs with `runs-on: ubuntu-latest` run on hosted Ubuntu 24.04 runners with 4 vCPUs and 12 GiB of memory by default. What you change is usually small: set your secret values again (GitHub never reveals them), move Windows, macOS and Arm jobs to self-hosted runners, and give `GITHUB_TOKEN` the `permissions:` it needs. ## Steps 1. **Bring the repository over.** Import it under **Settings → Imports → Import from GitHub**, or run `diffinery migrate github acme/website --to acme/website`. The importer copies the history (workflow files included), issues, pull requests, branch protection, Actions variables, environments and the names of your secrets. The import starts no workflow runs; your first push does. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). 2. **Set your secret values.** The import lists every secret name, but never a value. A person who is a repository admin enters each one: **Set secret values** on the import's page, `diffinery secret set NPM_TOKEN -R acme/website`, or the REST API. Do this before your first push, because that push starts the workflows. See [Set secret values](https://diffinery.com/docs/migrate-from-github#set-secret-values). 3. **Check the workflow files.** Run `diffinery workflow validate .github/workflows/ci.yml` on each file. Errors fail the file; warnings name keys that are accepted but ignored. 4. **Fix `runs-on` where needed.** `ubuntu-*` labels run on hosted runners. Windows, macOS and Arm jobs need a self-hosted runner (see [Runners and runner sizes](#runners-and-runner-sizes)). 5. **Add `permissions:` where a job writes.** In repositories created on Diffinery, imported ones included, the job token can only read code and packages by default. A job that comments, labels, pushes, tags or publishes needs a `permissions:` block, for example `contents: write` or `pull-requests: write`, or an admin sets the repository default to write. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions). 6. **Push a commit and watch the first runs** on the **Actions** tab, or with `diffinery run list` and `diffinery run watch `. Fix what fails using the sections below. ## What runs unchanged - **Triggers:** `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release`, `repository_dispatch` and `workflow_call`. See [Events that trigger workflows](https://diffinery.com/docs/actions/events). - **Workflow syntax:** expressions and contexts, matrices with `include` and `exclude`, `needs`, job outputs, `if` conditions, `concurrency`, job summaries, environment files and workflow commands. See [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax). - **Reusable workflows**, as long as they live on the same Diffinery instance and the calling job has no matrix. See [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). - **Workflows in `.github/workflows/`.** Diffinery also reads `.diffinery/workflows/`, for workflows that should only run on Diffinery. ## Runners and runner sizes | `runs-on` | On Diffinery | | --- | --- | | `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04`, any other `ubuntu-*` label, `linux`, `x64` | A hosted Ubuntu 24.04 x64 container, fresh for each job | | `diffinery-tiny`, `diffinery-small`, `diffinery-medium`, `diffinery-large` | A hosted runner of that size | | `windows-*`, `macos-*` | Fails: there are no Windows or macOS hosted runners | | `ubuntu-24.04-arm` and other Arm labels | Runs on the same x64 image; there are no hosted Arm runners | | `[self-hosted, ...]` | A machine you connect with `diffinery runner`, matched on every label | A job without a size label gets the **large** size (4 vCPU, 12 GiB of memory, 20 GB of disk), unless its own earlier runs show that a smaller size serves it just as well. Add a label such as `runs-on: [ubuntu-latest, diffinery-small]` to pin a size. See [Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes). The hosted image is smaller than GitHub's. It has Git, Git LFS, curl, jq, build-essential, Python 3.12, Node.js 22 with npm, pnpm and yarn, Docker and `gh`. Go, Java, Ruby, Rust, .NET, PowerShell, browsers and cloud CLIs aren't preinstalled: install them with a setup action or `apt-get`. The apt package lists are removed, so run `apt-get update` before `apt-get install`. See [The Ubuntu 24.04 image](https://diffinery.com/docs/actions/runners#the-ubuntu-2404-image). **macOS, Windows, Arm and GPU jobs** run on a [self-hosted runner](https://diffinery.com/docs/actions/runners#self-hosted-runners): a repository admin creates a registration token, and you start `diffinery runner` on your machine with labels such as `--labels macos,arm64`. Jobs with `runs-on: [self-hosted, macos, arm64]` then go to it. On Windows, the documented route is to run the runner inside WSL 2. See [macOS, Windows and GPU machines](https://diffinery.com/docs/actions/runners#macos-windows-and-gpu-machines). ## Marketplace actions Any `uses: owner/repo@ref` is downloaded from GitHub when the step runs, so marketplace actions keep working. JavaScript actions (they run on the runner's Node.js 22), composite actions and Docker container actions are all supported. - `actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`, `actions/cache`, `actions/upload-artifact` and `actions/download-artifact` are built into the runner for speed, for the version ranges listed in [Built-in actions](https://diffinery.com/docs/actions/compatibility#built-in-actions). For other versions or inputs, the real action runs. - `actions/github-script` works with the REST API (`github.rest.*`); `github.graphql` doesn't, because the job token can't use Diffinery's GraphQL API yet. - Actions in **private GitHub repositories** can't be downloaded on hosted runners. Move them into a Diffinery repository and use them as local actions. See [Actions stored on Diffinery](https://diffinery.com/docs/actions/compatibility#actions-stored-on-diffinery). - `uses: acme/my-action@v1` always means the repository on GitHub, not on Diffinery. ## Secrets and variables - **Values come from people.** The importer copies repository variables with their values, and only the **names** of repository, environment and organization secrets. A repository admin who is a person sets each secret's value. Agents can't set secrets or variables anywhere. - **Where to set them:** repository secrets on **Settings → Secrets and variables** or with `diffinery secret set`; organization secrets with `diffinery secret set NAME --org acme`; environment secrets with the REST API. - **Pull request runs get secrets.** Every pull request comes from a branch of the same repository (there are no forks), so `pull_request` runs receive the repository's secrets. Runs of code an agent wrote don't, unless an admin allows it. See [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets). - **Tools that encrypt secrets first** (GitHub's sealed-box `encrypted_value`) don't work: send the plain `value` over HTTPS; it's encrypted at rest. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). ## Caches and artifacts `actions/cache`, `actions/upload-artifact` and `actions/download-artifact` work as on GitHub, and the built-in versions share their storage and formats with the official actions. Caches are scoped per ref with the same fallbacks to the base branch and the default branch, each repository keeps up to 10 GB of caches, and artifacts are kept for up to 90 days. The `cache` input of the setup actions works too. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). ## Docker Job containers, service containers, Docker container actions, `docker build`, `docker run` and `docker compose` work on hosted runners, and so do `docker/setup-buildx-action`, `docker/login-action` and `docker/build-push-action`. The Docker daemon starts on first use, and Docker Hub images are pulled through the `mirror.gcr.io` mirror. If you publish images to `ghcr.io`, push them to the registry at `diffinery.com` instead, signed in with the job's `GITHUB_TOKEN` and `packages: write`. See [Docker in workflows](https://diffinery.com/docs/actions/docker) and [Migrating from ghcr.io](https://diffinery.com/docs/packages/container-registry#migrating-from-ghcrio). ## OIDC OIDC works with `permissions: id-token: write`, with `https://diffinery.com` as the issuer. In your cloud provider's trust policy, change the issuer host from `token.actions.githubusercontent.com` to `diffinery.com`, and keep your `sub` patterns unless they rely on a customized subject template. See [OpenID Connect (OIDC)](https://diffinery.com/docs/actions/oidc#differences-from-github). ## Environments The importer creates your environments with their deployment branch policies and variables, and lists their secret names. Then: - Set environment secrets with the REST API, as a repository admin who is a person. See [Environments and their secrets](https://diffinery.com/docs/migrate-from-github#environments-and-their-secrets). - Set required reviewers and wait timers again under **Settings → Environments**, with Diffinery people. They don't come across. See [Required reviewers and wait timers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers). - Pull request runs never get environment secrets. Keep production credentials in an environment limited to protected branches. ## What doesn't work The full list, with error messages, is in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). The differences you're most likely to meet: - No hosted Windows, macOS or Arm runners, no larger runners above 4 vCPU and no runner groups (`group` is ignored). - Events other than the ten listed above (`issues`, `issue_comment`, `pull_request_review` and the rest) parse but never start a run. - A matrix on a job that calls a reusable workflow isn't supported yet. - `github.graphql` in `actions/github-script` fails. - Problem matchers aren't supported, and annotations show in the job log only. - The `cmd` shell isn't supported, and hosted runners have no PowerShell. - Hard-coded `github.com` and `api.github.com` URLs point at GitHub: use `${{ github.server_url }}` and `${{ github.api_url }}`. ## Checklist 1. Import the repository (or push it) and read the import's warnings. 2. Set every secret value listed by the import, including environment and organization secrets. 3. Run `diffinery workflow validate` on every workflow file. 4. Replace `windows-*`, `macos-*` and Arm labels with `[self-hosted, ...]` labels and start a runner, or move the job to Linux. Check `matrix.os` values used in `runs-on`. 5. Add `apt-get update` before `apt-get install`, and install tools the hosted image doesn't have. 6. Add `permissions:` to jobs that write with `GITHUB_TOKEN`. 7. Move actions from private GitHub repositories into Diffinery and use them as local actions. 8. Replace `ghcr.io` with your instance's host (`diffinery.com` here), and hard-coded GitHub URLs with `github.server_url` and `github.api_url`. 9. Update OIDC trust policies to the `https://diffinery.com` issuer. 10. Set environment secrets, required reviewers and wait timers. 11. Check the required check names under **Settings → Branch protection** (they're ` / `, such as `CI / test`). 12. Push and watch the first runs. ## Frequently asked questions ### Do my GitHub Actions workflows run unchanged on Diffinery? Most do. Diffinery reads `.github/workflows/` and supports the same syntax, triggers, expressions, matrices, caches, artifacts, secrets, OIDC and Docker. Jobs that need Windows, macOS or Arm, actions from private GitHub repositories, or `github.graphql` need changes. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ### Does Diffinery copy my GitHub secrets? It copies their names, never their values: GitHub doesn't reveal secret values, and the importer doesn't try. A repository admin who is a person sets each value on Diffinery, from the import's **Set secret values** page, the CLI or the API. ### Can I run macOS or Windows jobs on Diffinery? Not on hosted runners: Cloudflare Containers run Linux only. Run them on your own machine with a self-hosted runner (`diffinery runner`) and `runs-on: [self-hosted, ...]` labels. ### Do marketplace actions work on Diffinery? Yes. Diffinery downloads `uses: owner/repo@ref` actions from GitHub when the step runs. JavaScript, composite and Docker container actions are supported. Actions in private GitHub repositories can't be downloaded on hosted runners. ### Can I try Diffinery Actions before I move? Yes. Keep GitHub as the place where people push, and mirror every push to Diffinery with a GitHub workflow, so Diffinery runs your workflows on each commit. See [Run Diffinery CI while your code stays on GitHub](https://diffinery.com/docs/guides/mirror-from-github). --- # Make your CI faster on Diffinery > Speed up workflows on Diffinery Actions: runner sizes and auto-sizing, caching, setup and Docker head starts, matrices, and cancelling superseded runs, with measured numbers. Source: https://diffinery.com/docs/guides/faster-ci On Diffinery, a hosted job gets 4 vCPUs and 12 GiB of memory by default, and the common setup actions are built into the runner, so many workflows are fast without changes. To go further: let CPU-heavy jobs use all four cores, cache dependencies with keys that the default branch saves, use the Node.js and Python versions the image already has, split long jobs into parallel jobs with `needs` and matrices, and turn on cancelling of superseded pull request runs, which is off by default. Every job's log says how much CPU, memory and disk it used, so you can see where the time goes. ## Steps 1. **Read what a job used.** The last lines of each hosted job's log show its usage, for example `Resources: 0.1 of 4 vCPUs busy on average (0.3 at the 95th percentile), peak memory 0.3 GiB of 12.0 GiB, peak disk 1.6 GB of 19.3 GB`, and a line near the top names the runner size it got and why. The jobs API returns the same as `usage` and `runner_size`. 2. **Keep CPU-heavy jobs on the large size.** It's the default, and automatic sizing keeps builds and test suites there. Pin it with `runs-on: diffinery-large` if you want to be sure (see [How runner sizing works](#how-runner-sizing-works)). 3. **Cache dependencies** with the `cache` input of the setup actions or with `actions/cache`, keyed on your lock file (see [Caching](#caching)). 4. **Use the toolchains the image has**, and let Go, Java and Docker start early (see [Setup actions and head starts](#setup-actions-and-head-starts)). 5. **Run jobs in parallel.** Split a long job into jobs that `needs` only what they depend on, and spread tests over a matrix (see [Matrices and parallel jobs](#matrices-and-parallel-jobs)). 6. **Stop runs nobody will look at.** Add a `concurrency` group that cancels a pull request's older runs, and path filters for changes that don't need CI (see [Cancel superseded runs](#cancel-superseded-runs)). 7. **Re-run only what failed.** **Re-run failed jobs** on the run page (or `diffinery run rerun --failed`) runs the failed jobs and the jobs that depend on them, and keeps the rest from the earlier attempt without starting a runner. See [Re-run a run](https://diffinery.com/docs/actions/overview#re-run-a-run). ## How runner sizing works Hosted runners come in four sizes. Every size runs the same Ubuntu 24.04 image, so a job behaves the same on each. | Size | `runs-on` label | vCPU | Memory | Disk | | --- | --- | --- | --- | --- | | large (default) | `diffinery-large` | 4 | 12 GiB | 20 GB | | medium | `diffinery-medium` | 2 | 8 GiB | 16 GB | | small | `diffinery-small` | 1/2 | 4 GiB | 8 GB | | tiny | `diffinery-tiny` | 1/4 | 1 GiB | 4 GB | - **With a size label**, for example `runs-on: [ubuntu-latest, diffinery-small]`, the job gets that size (with several, the largest). - **Without one**, the job gets **large**, unless its own last runs (at least 3 of up to 5) all fit a smaller size with room to spare, including room for bursts onto several cores and for slower hosts. A job that ran out of memory, failed, timed out or got slower on a smaller size goes back to large for its next 20 runs, and re-runs always run on large. So a smaller size is never the reason a job fails or slows down twice. - **Smaller sizes are about cost, not speed.** A job that mostly waits (a deploy, API calls, polling) runs just as fast on a small machine and costs less. A job that computes is fastest on large. Large is the biggest hosted size; there are no larger runners. See [Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes) and [What hosted jobs cost](https://diffinery.com/docs/actions/runners#what-hosted-jobs-cost-and-keeping-it-low). ## Caching Diffinery's cache service works with the official `actions/cache` and with the built-in version, which downloads an archive in parallel ranges and extracts it while it arrives. The simplest cache is the `cache` input of a setup action: ```yaml - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm ``` `actions/setup-python` takes `cache: pip` or `pipenv`, and `actions/setup-go` caches by default. For anything else, use `actions/cache` with a key that changes when your lock file does: ```yaml - uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-${{ runner.os }}- ``` Things that make caches hit more often: - **Let the default branch save caches.** A run reads entries saved on its own ref, then (for pull requests) the base branch, then the default branch. Entries saved on one feature branch are never visible to another. - **Include a hash of the lock file in the key.** Entries are immutable: a save with an existing key is skipped, so a key that never changes never refreshes. - **Use `save-always: true`** if failing jobs should still save, since the post step saves only after a successful job by default. - **Cache Docker layers** with `cache-from: type=gha` and `cache-to: type=gha` in `docker/build-push-action`, or with `type=registry` in the [container registry](https://diffinery.com/docs/packages/container-registry). `type=gha` in a plain `docker buildx build` step doesn't work, because `run:` steps don't get the cache service credentials. Each repository keeps up to 10 GB of caches, and entries no job restored for 7 days are deleted. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). ## Setup actions and head starts - **Node.js 22.22.0 and Python 3.12 are preinstalled.** When the version you ask `actions/setup-node` or `actions/setup-python` for is satisfied by them, the step uses them without a download. Any other version is downloaded each time. - **Go and Java start installing as the job begins.** When a job has `actions/setup-go` (v4 to v6) or `actions/setup-java` (v2 to v5, `distribution: temurin`, a major version such as `java-version: 21`), and the version is written in the workflow or comes from `matrix`, `inputs` or `vars`, the runner downloads that toolchain while checkout runs. Versions read from a file (`go-version-file`, `java-version-file`) and jobs that run in a `container:` install the usual way, as the step runs. - **Docker starts early for jobs that use it.** A job with `container:`, `services:`, a `docker://` action, or a step whose `uses:` or `run:` names `docker` gets the Docker daemon started in the background as the job begins. A `docker` command as the job's very first step waits for the daemon to finish starting. - **Other toolchains** (Ruby, Rust, .NET, browsers) aren't in the image and install as their step runs, so cache what they download. See [The Ubuntu 24.04 image](https://diffinery.com/docs/actions/runners#the-ubuntu-2404-image) and [Built-in actions](https://diffinery.com/docs/actions/compatibility#built-in-actions). ## Matrices and parallel jobs Jobs without `needs` between them run at the same time, each in its own container, and a job starts as soon as the jobs it needs have finished. A matrix turns one job into several: ```yaml jobs: test: runs-on: ubuntu-latest strategy: matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 ``` - An account runs up to 20 hosted jobs at once by default, across all its repositories; further jobs wait in line, oldest first. If a large matrix often waits, cap it with `strategy.max-parallel`. - A matrix may produce at most 256 jobs, and a run at most 1,000. - With `fail-fast` (the default), one failed leg cancels its unfinished siblings, so you hear about a failure sooner. See [strategy](https://diffinery.com/docs/actions/workflow-syntax#strategy) and [Limits](https://diffinery.com/docs/reference/limits). ## Cancel superseded runs As on GitHub, a new push to a pull request doesn't cancel the run for the commit before it. Diffinery doesn't turn this on for you, because cancelling can cut off a deploy halfway or turn an older commit's checks into "cancelled". To opt in for pull requests only, add this to the workflow: ```yaml concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} ``` Older runs of the same pull request are cancelled when a new commit arrives, and runs on your default branch finish. A run on a protected branch is only ever cancelled by runs on that same branch. See [Concurrency](https://diffinery.com/docs/actions/overview#concurrency). To skip runs entirely for changes that don't need them, use path filters on `push` and `pull_request`, for example `paths-ignore: ["docs/**", "**.md"]`. A job that doesn't run reports no check, and a required check that never reports shows as `required check '' has not run`, so be careful with path filters on workflows whose checks are required. See [Path filters](https://diffinery.com/docs/actions/events#path-filters) and [Status checks](https://diffinery.com/docs/actions/overview#status-checks-on-commits-and-pull-requests). ## Measured numbers From Diffinery's measurements on its production hosted runners on October 16, 2026 (see [How fast Diffinery Actions runs, measured](https://diffinery.com/performance) for the method). Ranges are wide because Cloudflare places containers on hosts whose cores differ about 2x in speed. | Stage | Time | | --- | --- | | Push to first job running | 6 to 8 s | | Job queued to first step | About 3 to 5 s | | `actions/checkout` | 1 to 3 s | | Restore a 216 MB pnpm cache | 3 to 4 s on fast hosts, about 10 s on the slowest | | `pnpm install` from that cache | 3 to 7 s | | Upload a 200 MB artifact | 7 to 9 s | | `setup-python`, `setup-node` (preinstalled versions) | 0 s and 2 s | | `setup-go`, `setup-java` (Temurin) | 1 to 3 s and 2 to 5 s | | First `docker` command after checkout | 0.03 to 0.3 s | | First `docker` command as a job's very first step | 1.4 to 2.3 s | The same lint job (checkout, `npm ci`, eslint, prettier and `tsc` on 40 files) took 9 to 14 s on large, 10 to 13 s on medium, 13 to 14 s on small and 15 to 27 s on tiny. A job that only made ten REST requests took 4 seconds on both tiny and large. Diffinery's own pull requests run 10 required checks, including seven end-to-end suites, in about 3 minutes, and up to about 6 minutes on the slowest hosts. ## Frequently asked questions ### What hardware do Diffinery's hosted runners have? By default, 4 vCPUs, 12 GiB of memory and 20 GB of disk, running Ubuntu 24.04 on x64 in a fresh container for each job. Smaller sizes (2, 1/2 and 1/4 vCPU) are picked per job from its own measured runs, or with a `diffinery-*` label. ### Does Diffinery cancel superseded workflow runs automatically? No. As on GitHub, older runs keep running unless the workflow has a `concurrency` group with `cancel-in-progress`. Add one with `cancel-in-progress: ${{ github.event_name == 'pull_request' }}` to cancel older pull request runs and leave default-branch runs alone. ### Can I get a runner bigger than 4 vCPUs? Not as a hosted runner: large (4 vCPU, 12 GiB) is the biggest size. For bigger machines, GPUs or long-lived caches, connect your own machine as a [self-hosted runner](https://diffinery.com/docs/actions/runners#self-hosted-runners). ### Why did my job get a smaller runner? Its last runs all fit a smaller size with room to spare, so Diffinery picked it to save cost. The log's `Runner size:` line says which runs it was based on. If a smaller size ever makes the job fail or slower, it goes back to large for its next 20 runs. Pin `runs-on: diffinery-large` to keep it on large. --- # Give an AI coding agent its own account, with guardrails > Create an agent account for Claude Code, Codex or any MCP client on Diffinery, connect it, and let the server keep a person in charge of protected branches, merges and secrets. Source: https://diffinery.com/docs/guides/ai-agent-with-guardrails On Diffinery, an AI coding agent gets an account of its own, separate from yours: you create it under **Settings → Agents**, give it a token (or let Claude Code or Codex sign in as it through the browser), and its commits, pull requests and comments show as the agent's, on your behalf. The server holds it to guardrails whatever tool it uses: it can't push to or merge into the default branch or protected branches, its pull requests need a person's approval, its own approvals and check results never count, and runs of the code it writes get none of your repository or organization secrets. Repository admins relax these rules, per branch or per repository, when you're ready, for example to let its pull requests merge by themselves once CI passes and a person approves. ## Steps 1. **Create the agent.** Go to **Settings → Agents**, enter an **Agent username** (for example `review-bot`), choose the **Owner** (you, or an organization where you're an owner or admin) and keep the **Maximum role** at **Write**. Click **Create agent**. Only people can create agents, and an organization's agent can reach only that organization's repositories. 2. **Create a token for it**, unless the agent will sign in through the browser (step 4). In the agent's row, open **Create a token for review-bot**, keep the **Scope** at **Write**, choose the **Days** (1 to 366), and click **Create token**. Copy it: it's shown once. Give it to the agent as an environment variable such as `DIFFINERY_TOKEN`, never in a file you commit. See [Tokens for agents](https://diffinery.com/docs/agents/overview#tokens-for-agents). 3. **Protect the default branch.** Under the repository's **Settings → Branch protection**, add a rule for `main` with **Require a pull request before merging**, at least one **Required approvals**, and your CI checks under **Required status checks**. Agents can't push to the default branch either way; the rule makes sure every change is reviewed and tested. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection). 4. **Connect the agent** (see [Connect Claude Code, Codex or another MCP client](#connect-claude-code-codex-or-another-mcp-client)). 5. **Tell it how to work**, for example in your agent instructions file: work on a new branch, open a pull request with an `intent` that states the goal, wait for checks with `diffinery pr checks --watch`, and fix failures before asking for review. 6. **Review and merge its pull requests**, by hand or with merge on green (see [Review and merge agent pull requests](#review-and-merge-agent-pull-requests)). ## What the server enforces These rules hold for Git pushes, the REST API, MCP tools and sandboxes alike, whatever the agent's role: | Rule | What it means | | --- | --- | | Never more access than its owner | Its role is the lowest of its maximum role (write by default), its owner's role on the repository and its token's scope. | | No pushes to the default branch or protected branches | Pushes and commits there fail with `agents may not push to protected or default branches; open a pull request`. It pushes other branches freely. | | No merges into the default branch or protected branches | The merge blocker reads `agents may not merge into this branch; a human must merge`. | | A person approves its pull requests | At least one approval from a person, even on a branch with no rule. An agent's approval never counts; its requested changes still block. | | Its check results don't count | Commit statuses and check runs an agent reports never satisfy a required check, so it can't mark its own work as passing. | | No secrets for runs of its code | A workflow run whose code came from an agent gets no repository or organization secrets, only its job token, and its token is held to the same rules. Environment secrets reach it only from environments limited to protected branches, and never on pull request refs. | | No settings that guard the repository | It can't change branch protection, secrets, variables or environments, dismiss reviews, delete or transfer repositories, or create other agents. | | No push protection bypass | A push or commit containing something that looks like a credential is always refused. | | Stamped commits | Commits Diffinery writes for it carry a `diffinery-agent review-bot on-behalf-of diffy` header. | The full table, with how to relax each rule, is in [Guardrails](https://diffinery.com/docs/agents/overview#guardrails). ## Connect Claude Code, Codex or another MCP client Every app connects to the same MCP server, `https://diffinery.com/mcp`. Apps with browser sign-in need no token: on Diffinery's consent page, choose to **act as** your agent, and Write access. - **Claude Code:** install the CLI and run `diffinery setup`, which adds the Diffinery plugins. Then run `/mcp`, select **plugin:diffinery:diffinery**, choose **Authenticate**, and pick the agent on the consent page. See [Claude Code](https://diffinery.com/docs/connect/claude-code). - **Codex:** run `diffinery setup`, add the Diffinery plugin, then run `codex mcp login diffinery` and pick the agent on the consent page. See [Codex](https://diffinery.com/docs/connect/codex). - **Cursor, VS Code and other MCP apps:** add `https://diffinery.com/mcp` as a remote HTTP server and sign in. See [Other apps](https://diffinery.com/docs/connect/other-apps). - **Headless (CI, scripts, scheduled jobs):** send the agent's token instead. For Claude Code: ```sh claude mcp add --scope user --transport http diffinery https://diffinery.com/mcp \ --header 'Authorization: Bearer ${DIFFINERY_TOKEN}' ``` `diffinery mcp-config` prints a snippet for other clients. See [Headless use](https://diffinery.com/docs/connect/how-sign-in-works#headless-use). - **Git and the CLI:** with `DIFFINERY_TOKEN` set, `diffinery auth setup-git` lets Git push with it, and `diffinery auth setup-gh` makes `gh pr create` and `gh pr checks` work against Diffinery. See [GitHub CLI](https://diffinery.com/docs/agents/github-cli). Ask the agent "Who am I on Diffinery?" to check: the `whoami` tool answers with the agent's login. **Cloud environments.** For a Claude Code cloud environment, or any other agent sandbox, open the repository's **Settings → Agent setup** and choose **Generate agent token**. It makes an agent that can reach only that repository and gives you the allowed domain, a one-line setup script and the environment variables to paste. A **Check** turns green once a session has set itself up. See [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents). ## Review and merge agent pull requests - **Find what needs you.** [Today](https://diffinery.com/docs/agents/today), the first page after signing in, lists **Agent PR needs a person's approval** for agent pull requests no person has approved at their latest commit. - **Read the intent first.** Every pull request can carry an `intent`, one or two sentences on what the change is meant to do, shown at the top so you can check the diff against it. See [Pull requests](https://diffinery.com/docs/collaboration/pull-requests). - **Approve, then merge.** You (or anyone with write access other than the agent) approve. The agent's owner can approve, because the owner is a different account from the agent. - **Let it merge on green.** An admin opens the repository's **Settings → Merge on green**, ticks the checks that must pass and the agents, and chooses whether agents' pull requests merge **Once the checks pass and a person approved** or **As soon as the checks pass**. Each agent pull request then merges by itself when its conditions are met. See [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green). - **Or allow auto-merge per branch.** **Allow agents to land pull requests through auto-merge** on a branch's rule (with at least one required check) lets the agent turn on auto-merge for its own pull requests (`enable_auto_merge` over MCP, or `diffinery pr merge --auto`). Diffinery merges once every rule is met; if the agent pushes again, a person approves again. See [Agents landing changes](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes). - **Workflow changes always need a person.** A pull request that adds, changes or deletes a workflow file never lands through an agent's auto-merge: CI can't vouch for a change to itself. - **Plan for deploys after agent merges.** A merge that auto-merge makes for an agent is the agent's push, so later runs on that branch are runs of agent code and get no repository or organization secrets. Keep deploy credentials in an environment limited to protected branches, or have an admin turn on **Give runs of agent code the repository's and organization's secrets**. See [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets). To have one of your agents review pull requests for bugs before you do, turn on [Agent review](https://diffinery.com/docs/agents/review). To give an agent a goal and get a pull request back, see [Agent tasks](https://diffinery.com/docs/agents/tasks). ## Audit what agents did - **On every item:** agents have an `agent` badge and `on behalf of ` next to their name on pull requests, reviews, comments and workflow runs, and the API returns `"type": "Bot"` for them. - **In Git:** commits Diffinery writes for an agent carry the `diffinery-agent ... on-behalf-of ...` header, covered by the commit's SHA. See it with `git cat-file -p `. - **Its tokens:** **Settings → Agents** lists each agent's tokens with their scope and last use, and a **revoke** link. Diffinery sends a security email when an agent or an agent token is created. - **The audit log:** site administrators see every audited action under **Settings → Audit log**, with the agent as the actor and its owner alongside (`review-bot for diffy`). See [Audit log](https://diffinery.com/docs/security/audit-log). - **Cut it off:** revoke one token to stop one running copy, or **Disable** the agent under **Settings → Agents** to revoke all its tokens at once. ## Frequently asked questions ### Can an AI agent push to main on Diffinery? Not by default. Agents can't push to the default branch or to any protected branch; they push a branch and open a pull request. A repository admin can allow it on a branch's protection rule with **Allow agents to push directly**. ### Can an agent approve or merge its own pull request? An agent's approval never counts toward required approvals, and agents can't merge into the default branch or protected branches. A person approves its pull request. A repository admin can let agents land approved pull requests through auto-merge, so CI and a person decide rather than the agent, or waive the approval so green required checks are enough. ### Can an agent read my repository secrets? Agents can't create, change or delete secrets, and a workflow run of code an agent wrote gets no repository or organization secrets unless a repository admin turns that on. Environment secrets reach such a run only from environments limited to protected branches, and never on pull request refs. Agents with write access can read Actions variables, which aren't secret. ### Which AI coding agents work with Diffinery? Any agent that uses Git, the GitHub CLI, the REST API or MCP. There are guides for [Claude](https://diffinery.com/docs/connect/claude), [Claude Code](https://diffinery.com/docs/connect/claude-code), [Codex](https://diffinery.com/docs/connect/codex), Cursor and VS Code ([Other apps](https://diffinery.com/docs/connect/other-apps)). ### How do I take away an agent's access? Revoke its token under **Settings → Agents**, or click **Disable** next to the agent to revoke all its tokens at once. An app connected through the browser is disconnected under **Settings → Access tokens → Connected apps**. --- # Run Diffinery CI while your code stays on GitHub > Try Diffinery Actions on your existing workflows without moving: mirror every GitHub push to Diffinery with a GitHub workflow, copy the secrets it needs, and wait for the results. Source: https://diffinery.com/docs/guides/mirror-from-github You can run your GitHub Actions workflows on Diffinery without moving your team off GitHub. A small GitHub workflow pushes every branch and tag to a Diffinery repository on each push, and Diffinery then runs the same `.github/workflows/` files on each mirrored commit. The same workflow can copy the secrets your workflows need straight from GitHub's secret store, and fail on GitHub when the runs on Diffinery fail. Diffinery itself was developed this way. ## Steps 1. **Create the Diffinery repository**, for example `acme/website`, at `https://diffinery.com/new` or with `diffinery repo create website --org acme`. 2. **Create a Diffinery token** with the `admin` scope, for an account that is an admin of that repository, under **Settings → Access tokens**. If force pushes to protected branches should go through, the protection rules must let admins bypass them. 3. **Add it on GitHub** as a repository secret named `DIFFINERY_TOKEN`. 4. **Add the mirror workflow on GitHub** as `.github/workflows/mirror-to-diffinery.yml`. Copy it from [Keep GitHub as upstream and mirror to Diffinery](https://diffinery.com/docs/migrate-from-github#keep-github-as-upstream-and-mirror-to-diffinery), and list in its secrets step every secret your workflows use. 5. **Push to GitHub.** The mirror pushes every branch and tag to Diffinery, and Diffinery starts your workflows on the mirrored commit. Watch them on the repository's **Actions** tab, or with `diffinery run list -R acme/website`. 6. **Optionally, wait for the results on GitHub.** Add the [Wait for Actions on Diffinery](https://diffinery.com/docs/migrate-from-github#wait-for-diffinerys-runs) step, which polls Diffinery's runs for the pushed commit and fails the GitHub job if any of them fail. ## How the mirror behaves - **GitHub wins.** Every GitHub branch is pushed to the branch of the same name with a leading `+`, so changes made directly on Diffinery to mirrored branches are overwritten. - **Deleted branches stay.** Branches deleted on GitHub aren't deleted on Diffinery; delete them there by hand. - **Secrets go straight across.** The secrets step reads each value from GitHub's secret store inside your own workflow and sends it to Diffinery over HTTPS, without printing it. Variables can be copied the same way. - **The mirror doesn't run on Diffinery.** The workflow has an `if: ${{ github.server_url == 'https://github.com' }}` guard, because Diffinery also runs the mirrored copy of the file. - **Push protection still applies.** If a mirrored push is blocked because it contains something that looks like a credential, a repository admin can let it through with `-o allow-secrets=` on the `git push` line. ## Before you rely on the results - **Check what differs.** Windows, macOS and Arm jobs need a self-hosted runner on Diffinery, the job token is read-only by default, and a few features aren't supported. See [Move your GitHub Actions workflows to Diffinery](https://diffinery.com/docs/guides/move-github-actions-workflows) and [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). - **Keep Diffinery-only workflows apart.** Put workflows that should run only on Diffinery in `.diffinery/workflows/`, so GitHub doesn't run them, or guard a step with `if: github.server_url == 'https://diffinery.com'`. See [Where workflow files live](https://diffinery.com/docs/actions/overview#where-workflow-files-live). - **Compare speed with your own jobs.** Every hosted job's log ends with what it used and names the runner size it got. See [Make your CI faster on Diffinery](https://diffinery.com/docs/guides/faster-ci). ## When you're ready to move Import the repository with its issues, pull requests, reviews and releases (**Settings → Imports → Import from GitHub**, or `diffinery migrate github`), set its secrets, update everyone's remotes, then archive the GitHub repository or remove the mirror workflow. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). ## Frequently asked questions ### Can I use Diffinery Actions without moving my repository off GitHub? Yes. Mirror every push from GitHub to a Diffinery repository with a GitHub workflow; Diffinery then runs your `.github/workflows/` files on each mirrored commit. People keep pushing and reviewing on GitHub. ### Do I have to re-enter my secrets on Diffinery to mirror? No. The mirror workflow can copy the secrets you list from GitHub's secret store to Diffinery on each run, inside your own workflow, without printing them. You can also set them on Diffinery by hand. --- # Connect AI apps > Add Diffinery to Claude, Claude Code, Codex or any MCP app with one URL and a browser sign-in. Pick your app and follow its guide. Source: https://diffinery.com/docs/connect Diffinery works with AI apps through its [MCP server](https://diffinery.com/docs/agents/mcp). Every app connects to the same address: ```text https://diffinery.com/mcp ``` On a [self-hosted Diffinery](https://diffinery.com/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`. There is no token to copy. The app sends you to Diffinery in your browser, you choose whom it acts as and what it may do, and it's connected. You can see and disconnect every app under **Settings → Access tokens → Connected apps**, which also shows this URL with a copy button. ![Settings → Access tokens on Diffinery: the MCP server URL with a Copy button, links to the guides for Claude, Claude Code, Codex and other apps, and two connected apps, Claude Code and Codex, each acting as the agent ada-bot with write access.](https://diffinery.com/docs/images/connected-apps.webp) ## Pick your app | You use | What you do | Guide | | --- | --- | --- | | **Claude** on claude.ai, Claude Desktop or the Claude mobile apps | Add a custom connector with the URL, then approve it on Diffinery. | [Claude](https://diffinery.com/docs/connect/claude) | | **Claude Code** | Install the Diffinery plugin, then sign in from `/mcp`. | [Claude Code](https://diffinery.com/docs/connect/claude-code) | | **Live CI in Claude Code**, cloud sessions included | Install or upload the `diffinery-ci` plugin. | [CI status in Claude](https://diffinery.com/docs/connect/ci-status-in-claude) | | **Codex** (OpenAI's coding agent) | Install the Diffinery plugin, then run `codex mcp login diffinery`. | [Codex](https://diffinery.com/docs/connect/codex) | | **Cursor, VS Code or another MCP app** | Add the URL as a remote (HTTP) MCP server. | [Other apps](https://diffinery.com/docs/connect/other-apps) | | **Claude Code cloud environments** and other agent sandboxes | Paste three boxes from the repository's **Settings → Agent setup**. | [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents) | | **CI, scripts** (no browser) | Send an agent's token instead of signing in. | [Headless use](https://diffinery.com/docs/connect/how-sign-in-works#headless-use) | ## What a connected app can do Once connected, the app works with Diffinery the way you would, within the access you gave it: - Read code, branches and commits, compare branches, and commit files to a new branch without git. - Open pull requests with an [intent](https://diffinery.com/docs/collaboration/pull-requests), comment, review, merge, or turn on auto-merge. - Follow CI: list workflow runs, read the log of a failing job, and re-run or cancel runs. - File and update issues, and draft and publish releases. - Search and read these docs. The [MCP tool reference](https://diffinery.com/docs/reference/mcp-tools) lists every tool. In a terminal, Claude Code and Codex can also use git and the [`diffinery` CLI](https://diffinery.com/docs/reference/cli), which the Claude Code and Codex guides set up. ## You stay in control - **Act as an agent.** On the consent page you pick whether the app acts as you or as one of your [agents](https://diffinery.com/docs/agents/overview). An agent is the safer choice for an AI app: its commits and comments are marked as the agent's, it can't push to protected or default branches, and its pull requests need a person's approval unless a branch rule says otherwise. - **Choose the access.** Read, Write or Admin. Write is enough for pull requests, reviews and issues. Admin is never preselected. - **Disconnect any time.** **Settings → Access tokens → Connected apps** lists every app with whom it acts as, its access and when it was last used. **Disconnect** ends it at once. How the sign-in works, and what each part of the consent page means, is in [How sign-in works](https://diffinery.com/docs/connect/how-sign-in-works). ## For AI agents reading this page If you are an agent setting yourself up: - Running in a terminal with a person nearby: ask them to install the plugin for your app ([Claude Code](https://diffinery.com/docs/connect/claude-code), [Codex](https://diffinery.com/docs/connect/codex)) and approve the sign-in. Don't try to complete a browser sign-in yourself. - Running in a cloud sandbox for one repository: `diffinery setup --repo /` sets up git and gh and checks them; the person sets up the sandbox from the repository's **Settings → Agent setup** ([Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents)). - Running headless with `DIFFINERY_TOKEN` set: add the server with an `Authorization: Bearer ${DIFFINERY_TOKEN}` header, as in [Headless use](https://diffinery.com/docs/connect/how-sign-in-works#headless-use). `diffinery mcp-config` prints the snippet. - Either way, `diffinery auth setup-git` lets git push with the same sign-in, and `diffinery auth setup-gh` makes `gh` work against diffinery.com ([GitHub CLI](https://diffinery.com/docs/agents/github-cli)). --- # Claude > Add Diffinery to claude.ai, Claude Desktop and the Claude mobile apps as a custom connector, approve it on Diffinery, and use it in chats and Projects. Source: https://diffinery.com/docs/connect/claude Add Diffinery to Claude once as a custom connector, and Claude can read your repositories, open and review pull requests, follow CI and file issues from any chat. The connector works on claude.ai, in Claude Desktop and in the Claude mobile apps. You need a Diffinery account and a Claude plan that allows custom connectors. For Claude Code in a terminal, see [Claude Code](https://diffinery.com/docs/connect/claude-code) instead. ## Add the connector 1. On [claude.ai](https://claude.ai) or in Claude Desktop, open **Settings → Connectors** and choose **Add custom connector**. 2. Fill in the two fields and choose **Add**: | Field | Value | | --- | --- | | Name | `Diffinery` | | Remote MCP server URL | `https://diffinery.com/mcp` | Leave **Advanced settings** (OAuth client ID and secret) empty: Claude registers itself with Diffinery. On a [self-hosted Diffinery](https://diffinery.com/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`. 3. Choose **Connect** next to Diffinery. A Diffinery tab opens. Sign in with your passkey if you aren't signed in. 4. On the consent page, choose whom Claude acts as and its access, then choose **Connect**. The tab returns to Claude and the connector shows as connected. > [!TIP] > Pick one of your [agents](https://diffinery.com/docs/agents/overview) under **Act as** and **Write** access. Claude can then open pull requests and comment as that agent, and its pull requests wait for a person's approval like any agent's. > [!NOTE] > On a Team or Enterprise plan, an owner adds the connector once for the organization (in Claude's organization settings, under **Connectors**). Each member then chooses **Connect** and approves it with their own Diffinery account, so everyone's Claude acts with their own permissions. ## Use it in a chat Open the tools menu below the message box and make sure **Diffinery** is turned on. Then ask in plain words, for example: - "List my open pull requests on Diffinery and tell me which ones are waiting on CI." - "Why did the last workflow run on acme/widgets fail? Read the failing job's log." - "Open an issue in acme/widgets: the login page doesn't load on Safari." - "Review pull request 42 in acme/widgets and comment on anything risky." Claude asks before it uses a tool that changes something, unless you allowed that tool to run without asking. The connector also works in Claude Projects, and in Claude Desktop and the mobile apps once you've added it on claude.ai or in Desktop. ## Check that it works - In Claude, **Settings → Connectors** shows Diffinery as connected. - On Diffinery, **Settings → Access tokens → Connected apps** lists **Claude**, with whom it acts as and its access. - Ask Claude "Who am I on Diffinery?". It calls the `whoami` tool and answers with the account you picked. ## Disconnect On Diffinery, choose **Disconnect** next to Claude under **Settings → Access tokens → Connected apps**. Its tokens stop working at once. To remove the connector from Claude too, remove it under **Settings → Connectors**. ## Troubleshooting | Problem | What to do | | --- | --- | | The Diffinery tab says "Can't connect this app" | Claude's registration expired (registrations nobody used are removed after a week), or it asked to return somewhere it didn't register. Remove the connector in Claude and add it again. | | Claude says a tool needs the write scope | You connected with **Read** access. Disconnect Claude on Diffinery and connect again with **Write**. | | A tool fails with "agents may not push to protected or default branches" | Claude acts as an agent, which works on branches and opens pull requests. Ask it to open a pull request instead. | | Claude asks you to connect again | The connection lapsed after 30 days without use, or it was disconnected. Choose **Connect** again. | More detail on the consent page and what the tokens can do: [How sign-in works](https://diffinery.com/docs/connect/how-sign-in-works). --- # Claude Code > Install the Diffinery plugin for Claude Code, sign in from /mcp, and let Claude push branches, open pull requests and follow CI on Diffinery. Source: https://diffinery.com/docs/connect/claude-code The Diffinery plugin for Claude Code adds the Diffinery MCP server, a skill that teaches Claude how Diffinery repositories work, a `/diffinery:pr-status` command, and a hook that tells Claude when the repository it's in is hosted on Diffinery. Together with the `diffinery` CLI, Claude can then push a branch, open a pull request, watch CI and fix what fails, without leaving the terminal. ## 1. Install the CLI The CLI gives Claude git access and the pull request commands, and the plugin's source is fetched with git: ```sh curl -fsSL https://diffinery.com/install | bash diffinery setup ``` On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`, then `diffinery setup`. It opens your browser to sign in (on a machine without one it shows a code to enter instead), runs `diffinery auth setup-git` so git pushes and pulls with the same sign-in, signs in `gh` when you have it, and adds the Diffinery plugin and the [`diffinery-ci` plugin](https://diffinery.com/docs/connect/ci-status-in-claude) to Claude Code. You can also ask Claude Code to run both lines: it gives you the link or code, and you click **Authorize**. See [Install and sign in to the CLI](https://diffinery.com/docs/accounts/cli-sign-in). ## 2. The plugin `diffinery setup` installed it: its last lines include `✓ Claude Code plugins: diffinery … and diffinery-ci … load in new Claude Code sessions`. Start a new Claude Code session (or run `/reload-plugins` in an open one), and `/plugin` lists `diffinery` and `diffinery-ci`. Run `diffinery setup` again to bring them up to date. You can also install from the marketplace kept in the public [codician/diffinery](https://diffinery.com/codician/diffinery) repository, which `/plugin` then keeps up to date: `claude plugin marketplace add https://diffinery.com/codician/diffinery.git`, then `claude plugin install diffinery@diffinery`. The plugins' sources are in its `plugins/` folder. ## 3. Sign in 1. In Claude Code, run `/mcp`, select **plugin:diffinery:diffinery** and choose **Authenticate**. 2. Your browser opens Diffinery's consent page. Choose whom Claude Code acts as and its access, then choose **Connect**. 3. Back in the terminal, `/mcp` shows the server as connected. ![Diffinery's consent page for Claude Code: "Connect Claude Code to Diffinery", an app published by claude.ai that sends you back to an app on this computer, with Act as set to the agent ada-bot and Write access selected, and the Connect and Cancel buttons.](https://diffinery.com/docs/images/consent-claude-code.webp) On a machine without a browser, run `claude mcp login plugin:diffinery:diffinery --no-browser` (or `claude mcp login diffinery` without the plugin), open the printed link on any device, and paste the address you land on back into the terminal. > [!TIP] > Act as one of your [agents](https://diffinery.com/docs/agents/overview). Claude Code's commits, comments and pull requests then show as the agent's, and they wait for a person's approval before they land. ## Check that it works ```sh claude mcp list ``` `plugin:diffinery:diffinery` shows **Connected** (before you sign in it shows **Needs authentication**). Then try it: ```sh claude -p "Who am I on Diffinery?" ``` In a clone of a Diffinery repository, `/diffinery:pr-status` shows the current branch's pull request, its checks, and the end of the log of any job that failed. ## What the plugin adds | Part | What it does | | --- | --- | | MCP server | `https://diffinery.com/mcp` (or your [own instance's](#self-hosted-diffinery)) with browser sign-in: every [Diffinery tool](https://diffinery.com/docs/reference/mcp-tools). | | `diffinery` skill | Teaches Claude the loop on Diffinery: branch, `diffinery pr create --intent`, `diffinery pr checks --watch`, read failing logs with `diffinery run logs`, auto-merge, and what agents can and can't do. | | `/diffinery:pr-status` | This branch's pull request, its checks, and the log tail of failing jobs. Changes nothing. | | Session hook | In a Diffinery clone, tells Claude the repository is hosted on Diffinery and whether the CLI is installed and signed in. Elsewhere it stays silent. It never uses the network. | To update it later: `claude plugin marketplace update diffinery`, then `claude plugin update diffinery@diffinery`. ## Without the plugin Add only the MCP server: ```sh claude mcp add --transport http diffinery https://diffinery.com/mcp ``` Then `/mcp` → **diffinery** → **Authenticate**, as above. To share it with everyone working on a repository, commit it in the repository's `.mcp.json`; each person signs in with their own account: ```json { "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp" } } } ``` ## Pull requests and CI from the terminal With the CLI installed, Claude Code works through the usual loop on its own: ```sh git push -u origin my-branch diffinery pr create --title "Fix the login redirect" --intent "Sign-in should return people to the page they came from" diffinery pr checks --watch # exit 0 when all checks passed, 1 when any failed diffinery run logs # the log of a failed job diffinery pr merge --auto --squash ``` The pull request commands use the current branch's pull request when you leave out the number. To let Claude use `gh` as it would on GitHub, and to see the branch's pull request with its CI status in Claude Code's footer and the desktop app, sign gh in to Diffinery once. `diffinery setup` in step 1 did this if gh was installed then; otherwise run: ```sh diffinery auth setup-gh ``` See [GitHub CLI](https://diffinery.com/docs/agents/github-cli) for what works with `gh`. The `diffinery-ci` plugin, which `diffinery setup` added too, shows a live band of the pull request's checks above the prompt, and tells Claude when CI on its pushed commit fails. See [CI status in Claude](https://diffinery.com/docs/connect/ci-status-in-claude). ## Headless: CI and cloud sessions Where nobody can approve a browser sign-in, give Claude Code an [agent's token](https://diffinery.com/docs/agents/overview) in `DIFFINERY_TOKEN` and add a server that sends it: ```sh claude mcp add --scope user --transport http diffinery https://diffinery.com/mcp \ --header 'Authorization: Bearer ${DIFFINERY_TOKEN}' ``` Keep the single quotes: Claude Code reads `${DIFFINERY_TOKEN}` from the environment each time it connects, so the token is never written to its settings. This entry takes the place of the plugin's own server. The CLI and `diffinery auth setup-git` read `DIFFINERY_TOKEN` too. For Claude Code on the web, set up the environment from the repository's [Agent setup](https://diffinery.com/docs/connect/cloud-agents), which also brings the CLI and both plugins. More in [Headless use](https://diffinery.com/docs/connect/how-sign-in-works#headless-use). ## Self-hosted Diffinery The plugins work with any Diffinery instance, such as one you [deploy on your own Cloudflare account](https://diffinery.com/docs/self-hosting). Their **Diffinery URL** setting, `https://diffinery.com` unless you change it, decides the MCP server's address (`/mcp`), the instance the session hook recognises in a clone, and the instance the [CI band](https://diffinery.com/docs/connect/ci-status-in-claude) reads. Set it in one of these ways: - **With the CLI.** Install the CLI from your instance and run `diffinery setup` with its URL: ```sh curl -fsSL https://git.example.com/install | bash diffinery setup --url https://git.example.com ``` It signs you in there, adds both plugins, and sets their Diffinery URL in `~/.claude/settings.json` (under `pluginConfigs`), for plugins installed from the marketplace too. It ends with `✓ Claude Code plugins: … their Diffinery URL is https://git.example.com`. - **From the marketplace.** `claude plugin install diffinery@diffinery` asks for the Diffinery URL when it enables the plugin. Change it later in `/config`, where each plugin option has its own row. - **As files.** The plugin zips your instance serves at `https://git.example.com/-/claude/diffinery.zip` and `https://git.example.com/-/claude/diffinery-ci.zip` already default to that instance, for claude.ai's **Upload plugin** too. `DIFFINERY_URL` in the environment takes precedence for the session hook and the CI band, as it does for the CLI. The MCP server always follows the setting. For a server without the plugin, use your instance's address: `claude mcp add --transport http diffinery https://git.example.com/mcp`. ## Troubleshooting | Problem | What to do | | --- | --- | | `/plugin` doesn't list `diffinery` | Run `diffinery setup` (step 1) and start a new session. Its `Claude Code plugins` line says what went wrong, if anything. | | `claude plugin marketplace add` fails with an authentication error | Git can't read codician/diffinery with your sign-in. Run `diffinery auth setup-git`, or use `diffinery setup` instead (step 1). | | `/mcp` shows `https://diffinery.com/mcp` but your repositories are on your own instance | Set the plugin's Diffinery URL: run `diffinery setup --url https://`, or change it in `/config`. See [Self-hosted Diffinery](#self-hosted-diffinery). | | `/mcp` shows the server as needing authentication | Choose **Authenticate**, or run `claude mcp login plugin:diffinery:diffinery`. | | The browser says "Can't connect this app" | Remove the server and add it again, then sign in. | | A tool says it needs the write scope | You connected with **Read**. Disconnect Claude Code under **Settings → Access tokens → Connected apps** and sign in again with **Write**. | | `git push` to `main` fails with "agents may not push to protected or default branches" | Claude Code acts as an agent: push a branch and open a pull request. | --- # CI status in Claude > See a Diffinery pull request and its CI checks live in Claude Code, and have Claude told when CI on its pushed commit passes or fails, cloud sessions included. Source: https://diffinery.com/docs/connect/ci-status-in-claude Claude Code shows the pull request of the branch you're on, with its CI. On Diffinery, the `diffinery-ci` plugin adds a live view of the checks, and tells Claude when CI on a commit it pushed passes or fails, so it can fix what broke. `diffinery setup` installs it for you, with the `diffinery` plugin, in every place Claude Code runs: | Where you run Claude Code | What you see | Set up | | --- | --- | --- | | Terminal, the desktop app's Code tab, VS Code, JetBrains | Claude Code's own pull request badge, a live band of the PR's checks above the prompt, a checks pane and a status line entry. Claude is told when CI on its pushed commit fails. | [Two commands, once per computer](#on-your-computer) | | Cloud sessions on claude.ai/code, and project threads | Claude is told when CI on its pushed commit passes or fails, and says so in the thread; `/diffinery-ci` answers in text. No floating card (see [what Claude's cloud doesn't show](#what-claudes-cloud-doesnt-show)). | [The repository's Agent setup](#in-cloud-sessions-and-project-threads), which you need there anyway | | Anywhere | Claude can follow CI itself with `diffinery pr checks --watch`, `gh pr checks` or the MCP tools | [Claude Code](https://diffinery.com/docs/connect/claude-code) | ## On your computer 1. Install the CLI and run its setup, in a terminal: ```sh curl -fsSL https://diffinery.com/install | bash diffinery setup ``` On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`, then `diffinery setup`. 2. `diffinery setup` opens your browser: choose **Authorize**. It then sets up git and `gh`, and adds both Claude Code plugins. Its output includes these lines (the `gh` one when gh is installed): ```text ✓ git will use diffinery for https://diffinery.com ✓ gh is signed in to diffinery.com ✓ Claude Code plugins: diffinery 0.1.4 and diffinery-ci 0.2.4 load in new Claude Code sessions (named in ~/.claude/settings.json) ``` 3. Start a new Claude Code session in a clone of a Diffinery repository (or run `/reload-plugins` in an open one). On a branch with a pull request, the band appears above the prompt. That's all. `gh` signed in to Diffinery also gives Claude Code's own footer badge (`PR #12`) and the desktop app's session chip; see [GitHub CLI and Claude Code](https://diffinery.com/docs/agents/github-cli#claude-codes-pull-request-badge). Run `diffinery setup` again at any time to bring the plugins up to date. > [!NOTE] > `diffinery setup` saves the plugins in `~/.claude/diffinery-plugins/` and names them in `CLAUDE_CODE_PLUGIN_DIRS`, in the `env` block of `~/.claude/settings.json`, which Claude Code reads at the start of every session in the terminal, the desktop app and the editors. It keeps everything else in that file. To skip it, run `diffinery setup --no-claude-plugins`. A plugin you already installed from the Diffinery marketplace (`claude plugin install diffinery-ci@diffinery`) is left as it is. > [!NOTE] > On a [self-hosted Diffinery](https://diffinery.com/docs/self-hosting), install the CLI from your instance and give `diffinery setup` its URL: `curl -fsSL https://git.example.com/install | bash`, then `diffinery setup --url https://git.example.com`. The band then reads that instance: it follows `DIFFINERY_URL`, else the plugin's **Diffinery URL** setting (which `diffinery setup` sets, and `/config` changes), else the URL `diffinery login` saved. It sends a token only to the instance the token belongs to. See [Claude Code: Self-hosted Diffinery](https://diffinery.com/docs/connect/claude-code#self-hosted-diffinery). ## In cloud sessions and project threads A Claude Code cloud environment gets the plugins from the setup script of the repository's **Settings → Agent setup**, the same three boxes that give it git, gh and the CLI. Follow [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents#set-up-a-claude-code-cloud-environment). In short: 1. On Diffinery, open the repository's **Settings → Agent setup** and choose **Generate agent token**. 2. At [claude.ai/code](https://claude.ai/code), edit the cloud environment (the one your sessions or your project's threads use) from the environment selector under the message box. 3. **Network access**: **Custom**, with `diffinery.com` under **Allowed domains**. 4. **Setup script**: box 3, `curl -fsSL https://diffinery.com///agent-setup.sh | bash`. 5. **Environment variables**: box 4, as it is. 6. Save and start a session. The setup log has the line `✓ Claude Code plugins: … load in new Claude Code sessions` and ends with `✓ Ready: git, gh and the diffinery CLI work with /`, and the Agent setup page's **Check** turns green. From then on, when Claude pushes the branch or opens its pull request, it hears how CI went once the checks finish, and tells you in the thread. > [!NOTE] > Claude Code keeps a snapshot of the environment after its setup script runs, for about seven days or until you change the script or the allowed domains. An environment set up before the plugins were added gets them when the snapshot is next made: to get them now, change the setup script (adding a blank line is enough) and save. You don't need to upload anything on claude.ai, and the cloud environment needs no other variable. ## What the plugin shows `diffinery-ci` is a Claude Code [mod](https://code.claude.com/docs/en/plugins/mods/overview): a plugin whose code runs inside Claude Code. It needs Claude Code 2.1.287 or later in the terminal (2.1.286 in the desktop app). It reads your token from `DIFFINERY_TOKEN`, from `diffinery login`, or from gh signed in to Diffinery, and sends it only to Diffinery. - **A band above the prompt** for the current branch's pull request: its number and title, the checks (`✗ 1 failed · ● 3 running · ✓ 6`), and the review and merge state (approved, changes requested, merge conflict, auto-merge on). **Checks** opens the pane, **Open** opens the pull request, **Hide** folds the band away until you run `/diffinery-ci`. - **`/diffinery-ci`** opens a pane with every check, how long it took and a link to its log, plus **Refresh**, **Fix CI** (asks Claude to read the failing logs and fix what's clear) and a switch for the note below. - **The status line** reads `PR #12 ✓ 10/10`, `PR #12 ● 4/10` or `PR #12 ✗ 1 failed`, and a toast says when CI passes or fails. It looks again every 15 seconds while checks run, every minute otherwise, and right after Claude pushes, switches branch or runs a `pr` command. In a folder with several clones it follows the one Claude last worked in. ## Claude hears when CI settles When Claude pushes the branch or opens its pull request in the session, the plugin follows that exact commit. Once its checks finish, it sends Claude a note. When something failed, the note names each failed check with its log link and asks Claude to fix what's clear and say so when the failure isn't caused by the branch. This is what GitHub repositories get from Anthropic's GitHub App as auto-fix. Where the band shows (terminal, desktop app, VS Code), Claude hears about failures only, since the band already shows a pass. Where nothing draws (cloud sessions, project threads, `claude -p`), Claude hears about passes too and says so in one line, so you see the result in the transcript either way. | To | Do | | --- | --- | | Stop the notes for this session | `/diffinery-ci off` (`on` turns them back on), or the switch in the pane | | Choose which results Claude hears | `/diffinery-ci notes auto` (the default), `failures`, `all` or `off`. It's kept for later sessions; `/diffinery-ci notes` says the current choice. | The note comes from the plugin, and Claude reads it as one: the transcript names `diffinery-ci` as its sender. ## What Claude's cloud doesn't show The floating pull request and CI cards in claude.ai/code cloud sessions and in projects come from Anthropic's GitHub integration, the Claude GitHub App, and GitHub's own webhooks. As of October 2026: - **Projects** work with repositories on github.com only. - **Cloud sessions** reach GitHub Enterprise Server instances that an Owner connected under **Organization settings → Git providers**, on Team and Enterprise plans only. Projects aren't offered on those plans. - Claude Code reports a pull request on any other host (`code_change_published` with provider `github-enterprise`), but the cloud only draws cards for hosts its GitHub integration reaches. So a Diffinery pull request gets no card there today, and nothing a repository or plugin does can add one. That's Anthropic's to change; the plugin covers what it can in the meantime. ## Troubleshooting | Problem | What to do | | --- | --- | | The band says `not signed in to Diffinery` | Set `DIFFINERY_TOKEN`, run `diffinery login`, or `diffinery auth setup-gh`. The plugin reads the token again on its next look. | | No band, although the branch has a pull request | The folder's remote isn't on the Diffinery the band reads (`git remote -v`; for your own instance, see the note under [On your computer](#on-your-computer)), the band is hidden (run `/diffinery-ci`), or mods are off: `/plugin` lists the mods a session loaded. | | `/diffinery-ci` says there's no pull request | The current branch has none yet. Open one with `diffinery pr create` or `gh pr create`. | | Claude wasn't told when CI failed | Notes follow commits the session itself pushed, and stop after `/diffinery-ci off`. A push from outside the session shows in the band only. | | `diffinery setup` says `~/.claude/settings.json isn't valid JSON` | Claude Code's settings file has a typo. Fix it (or move it away), then run `diffinery setup` again. | | A cloud session has no `/diffinery-ci` command | Look for the `Claude Code plugins` line at the end of the environment's setup log. No line: the snapshot is older than the plugins, so change the setup script and save (see the note in [the cloud steps](#in-cloud-sessions-and-project-threads)). A `✗` line: it says what failed, usually `diffinery.com` missing from the allowed domains. | | You'd rather install from the marketplace | Anyone who can read codician/diffinery can run `claude plugin marketplace add https://diffinery.com/codician/diffinery.git`, then `claude plugin install diffinery-ci@diffinery`. `diffinery setup` then leaves that plugin to the marketplace. | --- # Codex > Install the Diffinery plugin for OpenAI's Codex, sign in with codex mcp login, and let Codex work with pull requests, CI and issues on Diffinery. Source: https://diffinery.com/docs/connect/codex Codex, OpenAI's coding agent, reads the same Diffinery plugin as Claude Code. It adds the Diffinery MCP server, a skill that teaches Codex how Diffinery repositories work, a `pr-status` skill, and a session hook that tells Codex when it's working in a Diffinery clone. ## 1. Install the CLI ```sh curl -fsSL https://diffinery.com/install | bash diffinery setup ``` On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`, then `diffinery setup`. It signs you in through your browser, lets git use the sign-in (as `diffinery auth setup-git` does) and signs in `gh` when you have it. The CLI gives Codex git access and the pull request commands. The plugin's marketplace lives in the public codician/diffinery repository. See [Install and sign in to the CLI](https://diffinery.com/docs/accounts/cli-sign-in). ## 2. Install the plugin ```sh codex plugin marketplace add https://diffinery.com/codician/diffinery.git codex plugin add diffinery@diffinery ``` `codex plugin list` now shows `diffinery@diffinery` as installed and enabled. ## 3. Sign in ```sh codex mcp login diffinery ``` Codex opens Diffinery's consent page in your browser. Choose whom Codex acts as and its access, then choose **Connect**. ![Diffinery's consent page for Codex: "Connect Codex to Diffinery", an app published by chatgpt.com that sends you back to an app on this computer, with Act as set to the agent ada-bot and Write access selected.](https://diffinery.com/docs/images/consent-codex.webp) Without a browser on the machine, add `--no-browser`: Codex prints the link, you open it on any device, and paste the address you land on back into the terminal. > [!TIP] > Act as one of your [agents](https://diffinery.com/docs/agents/overview), so Codex's commits and pull requests are marked as the agent's and wait for a person's approval. ## Check that it works ```sh codex mcp list ``` The `diffinery` server shows **OAuth** under **Auth** (before you sign in it says **Not logged in**). Then ask Codex: "Who am I on Diffinery?" ## What the plugin adds | Part | What it does | | --- | --- | | MCP server | `https://diffinery.com/mcp` (see [Self-hosted Diffinery](#self-hosted-diffinery) for your own instance) with browser sign-in: every [Diffinery tool](https://diffinery.com/docs/reference/mcp-tools). | | `diffinery` skill | The loop on Diffinery: branch, `diffinery pr create --intent`, `diffinery pr checks --watch`, `diffinery run logs`, auto-merge, and the agent rules. | | `pr-status` skill | This branch's pull request, its checks, and the log tail of failing jobs. (Codex plugins have no slash commands, so this is a skill.) | | Session hook | In a Diffinery clone, tells Codex the repository is hosted on Diffinery and whether the CLI is installed and signed in. Codex runs plugin hooks only after you trust them: open `/hooks` in Codex and trust it. The skills work without it. | To update it later: `codex plugin marketplace upgrade diffinery`, then `codex plugin add diffinery@diffinery` again. ## Without the plugin ```sh codex mcp add diffinery --url https://diffinery.com/mcp codex mcp login diffinery ``` or in `~/.codex/config.toml`: ```toml [mcp_servers.diffinery] url = "https://diffinery.com/mcp" ``` ## Pull requests and CI With the CLI installed, Codex pushes a branch, opens a pull request with `diffinery pr create --intent ...`, waits with `diffinery pr checks --watch`, reads failures with `diffinery run logs `, and lands it with `diffinery pr merge --auto --squash`. For `gh`, run `diffinery auth setup-gh` once; see [GitHub CLI](https://diffinery.com/docs/agents/github-cli). ## Headless: CI and cloud sessions Give Codex an [agent's token](https://diffinery.com/docs/agents/overview) in `DIFFINERY_TOKEN` and point the server at it. It takes the place of the plugin's server: ```sh codex mcp add diffinery --url https://diffinery.com/mcp --bearer-token-env-var DIFFINERY_TOKEN ``` or, for one run without changing `config.toml`: ```sh codex -c 'mcp_servers.diffinery.url="https://diffinery.com/mcp"' \ -c 'mcp_servers.diffinery.bearer_token_env_var="DIFFINERY_TOKEN"' exec "..." ``` `codex mcp list` then shows **Bearer token** under **Auth**. More in [Headless use](https://diffinery.com/docs/connect/how-sign-in-works#headless-use). ## Self-hosted Diffinery The plugin's skills and session hook work with any Diffinery instance, such as one you [deploy on your own Cloudflare account](https://diffinery.com/docs/self-hosting). The hook recognises a clone of your instance when its URL is in `DIFFINERY_URL` or saved by `diffinery login`. Codex plugins have no settings, so the plugin's MCP server stays `https://diffinery.com/mcp`; a server of your own named `diffinery` takes its place. `diffinery setup` adds it for you when Codex is installed: ```sh curl -fsSL https://git.example.com/install | bash diffinery setup --url https://git.example.com # ✓ Codex: the diffinery MCP server is now https://git.example.com/mcp codex mcp login diffinery ``` Or add it yourself: `codex mcp add diffinery --url https://git.example.com/mcp`. `codex mcp list` then shows your instance's URL for `diffinery`. For headless use, add `--bearer-token-env-var DIFFINERY_TOKEN` and set `DIFFINERY_URL=https://git.example.com` for the CLI. ## Troubleshooting | Problem | What to do | | --- | --- | | `codex plugin marketplace add` fails to fetch | Run `diffinery setup` first (step 1). | | `codex mcp list` says **Not logged in** | Run `codex mcp login diffinery`. | | The browser says "Can't connect this app" | Run `codex mcp logout diffinery`, then `codex mcp login diffinery` again. | | A tool says it needs the write scope | You connected with **Read**. Disconnect Codex under **Settings → Access tokens → Connected apps** and sign in again with **Write**. | --- # Cloud agent environments > Set up a Claude Code cloud environment, or any other agent sandbox, for a Diffinery repository by pasting three boxes from Settings → Agent setup. git, gh and the CLI then work from the first session. Source: https://diffinery.com/docs/connect/cloud-agents A cloud agent runs in a sandbox that starts fresh: a Claude Code cloud environment, Codex in the cloud, or any other agent platform. **Settings → Agent setup** on a repository gives it everything it needs to work on that repository from its first session, with no manual wiring: - a token that reaches **only that repository**, - the domain to allow, - a one-line setup script hosted on Diffinery, - the environment variables, with the token filled in, - and a **Check** that turns green once a session has set itself up and passed a self-test. Then the agent can clone, push branches, open pull requests and use `gh` against the repository. The page is the same for every agent platform: each one asks for the same three things. ## Before you start - On a [self-hosted Diffinery](https://diffinery.com/docs/self-hosting), everything below works the same with your instance's domain in place of `diffinery.com`: its **Agent setup** page shows the setup script and environment variables with its own URLs, and the network setting must allow its domain. - You need write access to the repository. Admins find **Agent setup** among the repository's settings; people with write access get a **Settings** tab that opens it. - Generating a token needs a passkey sign-in from the last 2 hours, as for any token. If yours is older, Diffinery asks you to confirm with your passkey first. ## Set up a Claude Code cloud environment 1. Open the repository on Diffinery, then **Settings → Agent setup** (`https://diffinery.com///settings/agent-setup`). 2. Choose **Generate agent token**. Change the label if you like, so you can tell environments apart later. The token appears once, already filled into box 4. 3. In Claude Code, open the environment selector under the message box at [claude.ai/code](https://claude.ai/code), and edit the environment (the settings icon on its row) or add a new one. The **Edit cloud environment** dialog has the three fields to fill. 4. **Network access**: choose **Custom**, put `diffinery.com` under **Allowed domains**, and keep **Also include default list of common package managers** ticked. **Full** works too. The default **Trusted** level doesn't include diffinery.com. 5. **Setup script**: paste box 3, which looks like this: ```sh curl -fsSL https://diffinery.com///agent-setup.sh | bash ``` 6. **Environment variables**: paste box 4 as it is: ```text DIFFINERY_TOKEN=dfy_… GH_ENTERPRISE_TOKEN=dfy_… GH_HOST=diffinery.com DIFFINERY_REPO=/ ``` 7. Save, and start a session in the environment. Back on the Agent setup page, the **Check** turns green within seconds of the setup script finishing (the page updates by itself). In the session, the repository is cloned at `/home/user/`, Claude Code has the Diffinery plugins (so Claude hears when CI on its pushed commit passes or fails), and these all work with no prompt and no path prefix: ```sh diffinery --version diffinery whoami git clone https://diffinery.com//.git gh api user --hostname diffinery.com --jq .login gh pr list --state all # inside the clone ``` > [!NOTE] > Claude Code runs the setup script once and keeps a snapshot of the machine for later sessions, for about seven days or until you change the script or the allowed domains. So a later session's clone can be behind: ask Claude to `git pull` first. A new token needs no new snapshot: the token is read from the environment each time, never from disk. ## Other agent platforms Every agent sandbox has the same three settings under its own names: | Box | What it is | Where it goes | | --- | --- | --- | | Network | `diffinery.com` | The sandbox's allowed domains or internet access setting. | | Setup script | `curl -fsSL https://diffinery.com///agent-setup.sh \| bash` | A setup, install or startup command that runs before the agent starts. It needs bash, curl and git, and Node.js 22 or later (it downloads Node.js from nodejs.org when there isn't one, so allow that domain too in that case). | | Environment variables | The lines in box 4 | The sandbox's environment variables or secrets that are passed to commands. | On a machine you already work on, you don't need any of this: install the CLI and run `diffinery setup` ([Install and sign in to the CLI](https://diffinery.com/docs/accounts/cli-sign-in)). Workflows on Diffinery Actions get their own token automatically ([Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions)). ## What the setup script does The script is the same for everyone, with the repository's name in it, and holds no secret: [read it](https://diffinery.com/codician/diffinery/agent-setup.sh) before you use it. It runs these steps, in order: 1. Installs the `diffinery` CLI and puts it on PATH (`/usr/local/bin/diffinery` when it runs as root, as in Claude Code). It skips the download when the installed CLI is already the server's current build, so later runs take a few seconds. 2. Stops with a clear message if `DIFFINERY_TOKEN` isn't set, after setting up what it can: git is then ready to use the token as soon as it is in the environment. 3. Runs `diffinery setup --repo /`, which: - makes git use the token for diffinery.com (`diffinery auth setup-git`): git asks the CLI, which reads `DIFFINERY_TOKEN` from the environment each time. The token is never written to disk, so no snapshot or file holds it. - leaves `gh` to `GH_ENTERPRISE_TOKEN` and `GH_HOST` from the environment. Without them it signs `gh` in with `DIFFINERY_TOKEN` instead (`gh` then keeps it in its own config). - clones the repository unless it's there already, or fetches it when it is. It goes in `/home/user/` in a Claude Code cloud environment and in the current directory elsewhere; set `DIFFINERY_CLONE_DIR` to choose, or `DIFFINERY_NO_CLONE=1` to skip it. - adds Claude Code's Diffinery plugins, `diffinery` and `diffinery-ci`, so sessions in the environment load them: Claude then hears when CI on a commit it pushed passes or fails ([CI status in Claude](https://diffinery.com/docs/connect/ci-status-in-claude)). Set `DIFFINERY_NO_CLAUDE_PLUGINS=1` to skip it. - checks each part and prints one line per check, saying what to change when one fails. - reports the result to the repository's **Check**. It is fully non-interactive, never prints the token, and is safe to run at the start of every session. It exits with 1 when the CLI can't be installed or a check fails, so a broken setup isn't kept in a snapshot and the session's setup log shows why. ## The token Each person gets their own agent for each repository the first time they generate a token there: an agent account named after the repository, such as `zutrix-agent`. Its commits, comments and pull requests show as the agent's, acting for you. | Setting | What it is | | --- | --- | | Repository | This repository only. The agent, and every token it has, gets no access to any other repository or to account or organization settings, even public ones. | | Code | Read and write. It pushes branches; protected branches and the default branch take pull requests instead. | | Pull requests and issues | Read and write: open, comment, review (advisory), update. | | Workflows and logs | Read, re-run and cancel. No access to secrets or variables. | | Merge | On by default. See below. | | Most it can do | Write, and never more than you can do: if you lose access, so does the agent. | | Expiry | 90 days. **Renew** gives the same token another 90 days, so nothing needs pasting again. | | Label | Shown in the token list and in the audit log. | ### Merging With **Can merge pull requests** on (the default), the agent may merge a pull request, or turn on auto-merge for it, once: - a person has approved the pull request's **latest commit**: agents' own reviews never count, and a push after the approval needs a new one; - every rule of the branch is met: required approvals, required checks, linear history, the merge queue; - the pull request doesn't change a workflow file (`.github/workflows/` or `.diffinery/workflows/`): those always need a person to merge, because CI can't vouch for a change to itself. Turn it off on the page and the agent can't merge into the default branch or a protected one at all; a person merges. Rules on the branch itself still apply either way, such as **Allow agents to land pull requests through auto-merge** in [Branch protection](https://diffinery.com/docs/repositories/branch-protection). **Merge its pull requests on green** (offered under **Label** when you generate the first token, and next to **Can merge pull requests** afterwards) has Diffinery turn on auto-merge for each pull request the agent opens, so it lands by itself once the default branch's required checks pass: right away where the repository waives the approval through [Merge on green](https://diffinery.com/docs/collaboration/merge-on-green), otherwise once a person approved its latest commit. The text under the box says which applies in this repository. It needs at least one required check on the branch, and a workflow change still needs a person. ### Renew, revoke and regenerate - **Renew** keeps a token working for 90 days from today. - **Revoke and regenerate** stops the token at once and makes a new one, filled into box 4 for you to paste. Use it whenever a token may have leaked. - **Revoke** stops it. - Admins of the repository see everyone's agents for it on the page and can turn any of them off, which revokes their tokens. Every token change is in the audit log (`agent_setup.token.create`, `.regenerate`, `.renew`, `.revoke`), and you get the usual email when a token is created. ## Keep the token safe - Environment variables are visible to anyone who uses the environment, so treat the token as shared with them. - Never paste the token into a chat or a screenshot. If it leaks, choose **Revoke and regenerate**. - Claude Code's **Network secrets** keep a credential outside the session, but they don't reach setup scripts, and `gh` won't run without a token of its own, so Agent setup uses environment variables. ## When the Check fails The page shows each failed line with what to change. The usual ones: | Line | What to do | | --- | --- | | `server: can't reach diffinery.com` | Allow `diffinery.com` in the environment's network settings (Custom with diffinery.com, or Full). | | `DIFFINERY_TOKEN: not set` | Add the Environment variables lines from the page. | | `DIFFINERY_TOKEN: refused (revoked or expired)` | **Revoke and regenerate** (or generate a new token), then paste the new token in both places. | | `repository: the token can't see /` | Use a token from this repository's Agent setup page: each token reaches only its own repository. | | `GH_HOST: not set` | Add `GH_HOST=diffinery.com` to the environment variables. | | `gh: GH_ENTERPRISE_TOKEN isn't the same token as DIFFINERY_TOKEN` | Paste the same token into both. | | `clone: … exists and isn't a clone of …` | Move that directory, or set `DIFFINERY_CLONE_DIR`. | Until a session runs the script with every check passing, the Check says **Not connected yet**. To check again inside a running session, run the setup script again, or `diffinery setup --repo /`. ## For AI agents reading this page If you are an agent in a sandbox with `DIFFINERY_TOKEN` set: - `diffinery setup --repo /` checks your setup and fixes what it can; each failed line says what the person has to change in the environment's settings. Don't try to change those settings yourself. - If `DIFFINERY_TOKEN` isn't set, ask the person to open the repository's **Settings → Agent setup** on Diffinery and paste its boxes into your environment's settings. - Open pull requests with `diffinery pr create --intent` or `gh pr create`, and follow CI with `diffinery pr checks --watch`. You may merge only what a person approved; see [Merging](#merging). - The [REST API](https://diffinery.com/docs/api) reports the Check at `GET /api/v3/repos///diffinery/agent-setup`. --- # Other apps > Connect Cursor, VS Code or any other MCP client to Diffinery with the server URL and a browser sign-in, or with a token where sign-in isn't supported. Source: https://diffinery.com/docs/connect/other-apps Any app that supports remote MCP servers over HTTP can connect to Diffinery. Apps that support MCP's OAuth sign-in need only the URL; the rest send a token. ```text https://diffinery.com/mcp ``` On a [self-hosted Diffinery](https://diffinery.com/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`. Use it in place of `https://diffinery.com/mcp` in the examples below. ## Cursor Add Diffinery to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project to share it with everyone who opens the project: ```json { "mcpServers": { "diffinery": { "url": "https://diffinery.com/mcp" } } } ``` Cursor lists the server in its MCP settings with a way to sign in; Diffinery's consent page opens in your browser. To clone Diffinery repositories in Cursor (**Clone from Diffinery** in the clone picker) and push without a password, install the Diffinery editor extension: see [Clone in VS Code, Cursor and other editors](https://diffinery.com/docs/repositories/clone-in-your-editor). ## VS Code Add Diffinery to `.vscode/mcp.json` in a workspace (or run **MCP: Add Server** from the Command Palette and choose **HTTP**): ```json { "servers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp" } } } ``` Start the server from the file or the MCP view, and sign in when VS Code asks. To clone Diffinery repositories in VS Code (**Clone from Diffinery** in the clone picker) and push without a password, install the Diffinery editor extension: see [Clone in VS Code, Cursor and other editors](https://diffinery.com/docs/repositories/clone-in-your-editor). ## Any other app Look for "remote MCP server", "HTTP" or "Streamable HTTP" in the app's settings, and give it `https://diffinery.com/mcp`. If the app supports OAuth for MCP, it finds everything else on its own: Diffinery answers its first request with a `401` that points at the sign-in details, the app registers itself, and you approve it on the consent page. See [How sign-in works](https://diffinery.com/docs/connect/how-sign-in-works). ## Apps without OAuth sign-in Send a Diffinery token in the `Authorization` header instead. Use an [agent](https://diffinery.com/docs/agents/overview)'s token (created under **Settings → Agents**) and keep it in an environment variable, never in a file you commit: ```json { "mcpServers": { "diffinery": { "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" } } } } ``` `diffinery mcp-config` prints this snippet. Apps that can only start local (stdio) servers, such as those set up through Claude Desktop's configuration file, can reach Diffinery through a small bridge; [MCP server](https://diffinery.com/docs/agents/mcp#claude-desktop-and-other-clients-that-start-local-servers) has the setup for those and for many more clients. ## Check that it works Ask the app "Who am I on Diffinery?". It calls the `whoami` tool and answers with the account it acts as. Apps that signed in also show under **Settings → Access tokens → Connected apps** on Diffinery. --- # How sign-in works > How AI apps sign in to Diffinery's MCP server with OAuth 2.1, what the consent page asks, Connected apps, tokens, and headless use with an agent token. Source: https://diffinery.com/docs/connect/how-sign-in-works Diffinery follows the [MCP authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) (OAuth 2.1), so any MCP app that supports it can connect with nothing but the URL, not only Claude and Codex. ## The steps 1. The app calls `https://diffinery.com/mcp` without a token and gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://diffinery.com/.well-known/oauth-protected-resource/mcp"`. 2. It reads that document and the authorization server's at `/.well-known/oauth-authorization-server`. 3. It identifies itself: by the URL of its own client metadata document (Claude Code uses `https://claude.ai/oauth/claude-code-client-metadata`, Codex `https://chatgpt.com/oauth/codex/client.json`), or by registering at `/oauth/register` (RFC 7591), as claude.ai and most other apps do. 4. It sends you to `/oauth/authorize` with a PKCE challenge, and you approve it on [the consent page](#the-consent-page). 5. Diffinery sends your browser back to the address the app registered with a one-time code, which only works together with the PKCE secret the app kept. The app trades it at `/oauth/token` for an access token and a refresh token. | | | | --- | --- | | Access token | An ordinary Diffinery token (`dfy_…`) for the account you picked, with the access you picked. It lasts 8 hours and works everywhere a token does (MCP, the REST API, git), with every permission check and branch rule that applies to that account. | | Refresh token | `dfr_…`. Each use returns a new one and retires the old. A connection that isn't used for 30 days lapses. A retired refresh token used again later is taken as stolen and ends the connection. | | Redirect addresses | `https` anywhere, `http` only on your own computer (`localhost`, `127.0.0.1`), or an app's own scheme such as `cursor://`. Loopback addresses may use any port. | | Endpoints | `/.well-known/oauth-protected-resource/mcp`, `/.well-known/oauth-authorization-server`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`, `/oauth/revoke` (RFC 7009). | | Grants | Authorization code with PKCE (S256 only), and refresh tokens. No implicit, password or client credentials grants. | ## The consent page ![Diffinery's consent page for Claude Code: the app's name, "An app published by claude.ai", where it sends you back, the Act as choice between yourself and your agents, Read, Write and Admin access, and the Connect and Cancel buttons.](https://diffinery.com/docs/images/consent-claude-code.webp) The page names the app and where it will send you back. An app identified by a client metadata document shows the site that publishes it ("An app published by claude.ai"); an app that registered itself shows the name it chose, marked as not checked by Diffinery. Only connect an app you are setting up yourself at that moment. - **Act as:** yourself, or one of your agents (yours, or an organization's you own or administer). Acting as an agent is a good default for an AI app: its commits and comments show as the agent's, on your behalf, and its pull requests need a person's approval, so nothing it does lands unreviewed. - **Access:** Read, Write (preselected when the app asks for more) or Admin. Admin is never preselected, and isn't offered for a site administrator's own account: an app would carry site-wide administration in a token. Approving needs a passkey sign-in from the last 2 hours, as creating a token does. ## Connected apps **Settings → Access tokens → Connected apps** lists every app you connected: its name, where it sends you back, whom it acts as, its access, and when it was last used. **Disconnect** ends the connection and its tokens at once. Each approval is its own connection, so the same app on two computers shows twice. Connections and disconnections are in the [audit log](https://diffinery.com/docs/security/audit-log) as `oauth.connect` and `oauth.disconnect`. ## Headless use Where nobody can open a browser (CI jobs, cloud agent sessions, scheduled tasks), give the app a token instead: an [agent](https://diffinery.com/docs/agents/overview)'s token is best, created under **Settings → Agents**. Put it in `DIFFINERY_TOKEN`, never in a file you commit. | App | Setup | | --- | --- | | Claude Code | `claude mcp add --scope user --transport http diffinery https://diffinery.com/mcp --header 'Authorization: Bearer ${DIFFINERY_TOKEN}'`, or `"headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" }` in `.mcp.json`. With the plugin installed, this entry takes the place of the plugin's own. | | Codex | `codex mcp add diffinery --url https://diffinery.com/mcp --bearer-token-env-var DIFFINERY_TOKEN`, or `bearer_token_env_var = "DIFFINERY_TOKEN"` under `[mcp_servers.diffinery]`. It takes the place of the plugin's entry. | | Other apps | An `Authorization: Bearer ...` header; see [Other apps](https://diffinery.com/docs/connect/other-apps#apps-without-oauth-sign-in). | | git, gh and the CLI | The CLI reads `DIFFINERY_TOKEN`. Run `diffinery auth setup-git` once so git uses it too, and `diffinery auth setup-gh` for gh. | | Claude Code on the web and other cloud sessions | Allow `diffinery.com` in the environment's network settings, add `DIFFINERY_TOKEN` as an environment secret, and clone over HTTPS. | A token and the browser sign-in can't share one server entry: with the header set, neither Claude Code nor Codex falls back to OAuth. `diffinery mcp-config` prints the snippet for your token. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | The browser says "Can't connect this app" | The app's registration expired (registrations nobody used are removed after a week), or it asked to send you back to an address it didn't register. Remove the server or connector and add it again. | | An app that worked asks you to sign in again | The connection lapsed after 30 days unused, or it was disconnected (by you, or after a reused refresh token). Sign in again. | | Tools fail with `this token needs the write scope` | You connected with Read access. Disconnect it under Connected apps and connect again with Write. | | A tool fails with `agents may not push to protected or default branches` | The app acts as an agent. It should work on a branch and open a pull request; see [What agents can and can't do](https://diffinery.com/docs/agents/overview). | --- # Passkeys and sign-in > How people sign in to Diffinery with passkeys, join from an invite or open sign-up, manage passkeys and browser sessions, and recover a lost passkey. Source: https://diffinery.com/docs/accounts/passkeys-and-sign-in People sign in to Diffinery in the browser with a passkey (WebAuthn). There are no passwords. This page covers signing in, joining from an invite or through open sign-up, adding and removing passkeys, managing signed-in browsers, the security emails you get, and how to get back in when you lose a passkey. Passkeys are only for the web UI. Git, the `diffinery` CLI, scripts and MCP clients use personal access tokens instead (see [Personal access tokens](https://diffinery.com/docs/accounts/tokens)); the CLI gets its own when you approve `diffinery login` in the browser (see [Install and sign in to the CLI](https://diffinery.com/docs/accounts/cli-sign-in)). For your username, display name, profile picture and light or dark theme, see [Profile and preferences](https://diffinery.com/docs/accounts/profile-and-preferences). ## How passkey sign-in works A passkey is a key pair that your device, browser or password manager creates for `diffinery.com`. Diffinery stores only the public key. When you sign in, your device proves it holds the private key after you unlock it with your fingerprint, face, PIN or screen lock. Details that matter in practice: | Behaviour | Value | | --- | --- | | Where a passkey works | Only on the host it was created for (the hostname of the instance, `diffinery.com`). A passkey for one Diffinery instance does not work on another. | | Kind of passkey | Discoverable credentials (resident keys). You don't type a username: the browser offers the passkeys it holds for the site. | | User verification | Required. The device must unlock the passkey with a biometric, PIN or screen lock, not just a tap. | | Attestation | None requested. Diffinery accepts any authenticator that can create a discoverable, user-verifying passkey. | | Time to complete the browser prompt | 2 minutes. The sign-in attempt itself expires after 5 minutes. | | Browser session length | 30 days, extended by another 30 days on each day you use it, up to 90 days after you signed in. | Diffinery is invite-only unless the instance turns on open sign-up. On an invite-only instance (the default) you can't create an account from the sign-in page: new people create their first passkey from an invite link (see [Join from an invite](#join-from-an-invite)). With open sign-up, the sign-in page also links to **Create an account** (see [Open sign-up](#open-sign-up)). ## Sign in 1. Open `https://diffinery.com/login`, or any page that needs you to be signed in. Diffinery redirects you to the sign-in page and back to that page afterwards. 2. Click **Sign in with a passkey**. 3. Your browser shows its passkey prompt. Pick the passkey for your Diffinery account, or choose to use a phone or a security key if the browser offers it. 4. Unlock the passkey with your fingerprint, face, PIN or screen lock. You land on the page you were trying to open (or your dashboard). To sign out, click **Sign out** in the header; that ends this browser's session only. The sign-in page also says how to recover a lost passkey on this instance: either a **Recover your account by email** link (when email recovery is set up) or "Ask an administrator for a sign-in link". ## Join from an invite When someone invites you, Diffinery emails you a link like `https://diffinery.com/invite/`. If you don't have an account yet, opening it shows a **Join Diffinery** page: 1. Check the **Username** field. It is filled in from the part of your email address before the `@`; change it if you like. Usernames start with a letter or digit and use letters, digits, `-` and `_`, up to 39 characters. Some names are reserved (for example `api`, `settings`, `login`, `admin`, `docs`). 2. Optionally fill in a **Display name** (up to 100 characters). 3. Click **Create a passkey and join**, and complete your browser's passkey prompt. Diffinery creates your account, saves the passkey, signs you in and shows "Welcome to Diffinery, . You can sign in with this passkey from now on." You land on the organization or repository the invite was for, or on your dashboard for an invite to the instance. You also get a welcome email. Creating the account accepts every open invite sent to your email address, so you join every organization or repository you were invited to at once. If the email address already has an account, the invite page asks you to sign in with that account's passkey first; after that the same page shows an **Accept invite** button. See [Invitations](https://diffinery.com/docs/accounts/invitations) for how invites are created, how long they last and what each kind grants. ## Open sign-up An instance whose operator set `SIGNUP=open` (see [Configuration](https://diffinery.com/docs/self-hosting/configuration#signup)) lets anyone create an account. The sign-in page then says **New here? Create an account**, which leads to `/signup`: 1. Enter your email address and, if the page shows one, complete the Cloudflare Turnstile check. Click **Email me a code**. 2. The page says "We sent a code to
". Find the email "Your sign-up code" and enter its 6 digits under **Code**, then click **Continue**. The code works once, for 10 minutes; a wrong code says how many tries are left (5 in all), and **send a new code** sends another one (up to 5 a day for an address). 3. Pick a **Username** and an optional **Display name**, as when joining from an invite, and click **Create a passkey and join**. This step must be finished within 30 minutes. Diffinery creates your account (active right away, no invite needed), saves the passkey, signs you in and sends a welcome email. Any open invites for your address are accepted at the same time. If the address already has an account, the page reads exactly the same, so nobody can use it to find out who has an account. No code is sent: the account's owner gets an email "Someone tried to sign up with your email address" with a link to sign in instead. On an invite-only instance `/signup` doesn't exist (`404`). Limits on codes are listed in [Limits](https://diffinery.com/docs/reference/limits#sign-in-and-sessions). ## Manage your passkeys Go to **Settings → Passkeys and sessions** (`/settings/security`). The **Passkeys** box lists every passkey on your account with: - its name, - a **synced** badge when the passkey is backed up by your password manager or platform (so it syncs to your other devices), or **this device only** when it is stored on a single device or security key, - when it was added and when it was last used to sign in (or "never used to sign in"). ### Add a passkey Add a passkey on each device you use, or keep one in a password manager that syncs, so losing one device doesn't lock you out. 1. On the device or browser you want to add, sign in and open **Settings → Passkeys and sessions**. 2. Under **Add a passkey**, optionally type a name (up to 100 characters). If you leave it empty, Diffinery names it after the device and browser, for example `Mac · Safari` or `Android · Chrome`. 3. Click **Add a passkey** and complete the browser prompt. You see "Passkey "" added." and Diffinery sends you a "A passkey was added to your account" email. If the browser says "This device already has a passkey for this account.", the authenticator you picked already holds a Diffinery passkey for you. Pick a different one (another device, a phone or a security key). ### Remove a passkey Click **Remove** next to the passkey and confirm. Removing a passkey also signs out every other browser that signed in with it, which is what you want for a lost or stolen device. The browser you are using stays signed in. Diffinery sends you a "A passkey was removed from your account" email. You can't remove your last passkey. The list shows **Only passkey** instead of a **Remove** button, and the server refuses with "Add another passkey before removing your last one." Add a new passkey first, then remove the old one. Passkeys can only be managed from a signed-in browser. An API request made with an access token gets "Sign in with your passkey first." ## Confirming it's you Adding or removing a passkey, creating a token or an agent, changing your username, deleting or transferring a repository or organization, exporting or deleting your account, and (for site administrators) deleting someone's account or changing who is a site administrator need a sign-in from the last 2 hours. If you signed in earlier, Diffinery shows **Confirm it's you**. Use your passkey (or **Confirm by email**, where email recovery is set up), and you go back to the page you were on. Then do the action again. See [Security overview](https://diffinery.com/docs/security/overview#confirming-its-you). ## Sessions and signing out other browsers Each successful sign-in creates a browser session, stored in an HTTP-only, secure cookie (`__Host-dfy_session`) that only `diffinery.com` can read. A session lasts 30 days and is extended by another 30 days on each day you use it, so a browser you use regularly stays signed in. However often you use it, a session ends 90 days after you signed in, and you sign in with your passkey again. The **Signed-in browsers** box on **Settings → Passkeys and sessions** lists your active sessions (up to 100), newest activity first. Each row shows the device and browser, a **this browser** badge for the one you are using, when it signed in, how (a passkey, email recovery or a sign-in link) and when it was last active. To end one session, click **Sign out** on its row and confirm. You see "Signed out of that browser." To end every session except the current one, click **Sign out everywhere else** and confirm "Sign out every other browser?". You see "Signed out of every other browser." Those browsers need a passkey to sign in again. When a site administrator disables your account, every browser session ends at once, and signing in again is refused with "Your account is disabled." until the account is enabled. Signing out other browsers does not revoke access tokens. If you think someone has your token, revoke it under **Settings → Access tokens** (see [Personal access tokens](https://diffinery.com/docs/accounts/tokens#revoke-a-token)). ## Security emails about sign-in These emails always send; you can't turn them off in notification settings. | Email | When it is sent | | --- | --- | | New sign-in to your account | You sign in with a passkey from a browser and passkey combination Diffinery hasn't seen for your account before, and every time you sign in through email recovery. It shows when, the device, the location (city and country) and the IP address. | | A passkey was added to your account | You add a passkey in settings, or with a sign-in link from an administrator. | | A passkey was removed from your account | You remove a passkey. | | Welcome to Diffinery | You create your account from an invite or through open sign-up. | | Someone tried to sign up with your email address | With open sign-up, someone asked for a sign-up code for your address. No account was created and no code was sent. | | Your one-time sign-in link | A site administrator creates a sign-in link for you. | A browser is identified by the passkey used plus its operating system and browser name (for example `Mac · Chrome`). Signing in again from the same browser with the same passkey doesn't send another email. The device you joined or added a passkey from counts as known. If you get one of these emails and it wasn't you: open **Settings → Passkeys and sessions**, click **Sign out everywhere else**, remove any passkey you don't recognize, revoke any token you don't recognize under **Settings → Access tokens**, and tell a site administrator. See [Notifications](https://diffinery.com/docs/accounts/notifications) for every email Diffinery sends. ## Recover your account If you have lost every passkey (or the device that held your only passkey), there are two ways back in. Both let you add a new passkey to your existing account; your repositories, tokens and settings are untouched. Recovering your account signs out every other browser signed in to it, in case the device you lost is one of them. Only browser sessions end: your access tokens keep working, so scripts and Git keep running. If you think someone has a token, revoke it under **Settings → Access tokens** once you are back in. The page you land on says "Every other browser signed in to your account was signed out; your access tokens keep working.", and the audit log records it as `session.revoke_others`. Email recovery from a browser that is already signed in to the same account (Diffinery sends you there to [confirm it's you](#confirming-its-you)) doesn't sign anything out. ### Sign-in link from an administrator A site administrator can create a one-time sign-in link for you: 1. The administrator opens **Settings → People and invitations**, finds you in the **Users** table and clicks **Sign-in link** (see [Site administration](https://diffinery.com/docs/admin/site-administration#sign-in-links)). 2. Diffinery emails you "Your one-time sign-in link". The administrator also sees the link and can send it to you another way. 3. Open the link (`https://diffinery.com/signin/`). The **Add a passkey** page names the account it belongs to. 4. Optionally enter a **Name for this passkey**, click **Create a passkey** and complete the browser prompt. You are signed in and taken to **Settings → Passkeys and sessions** with "Passkey "" added. Use it to sign in from now on." Every other browser signed in to your account is signed out. | Sign-in link rule | Value | | --- | --- | | Lifetime | 24 hours from when it was created | | Uses | Once. After that, or after it expires, it shows "this sign-in link is invalid, already used, or expired". | | Accounts | People only, and not disabled accounts | | What it does | Adds a passkey to the account, signs that browser in and signs every other browser out. It doesn't remove your other passkeys or revoke your access tokens. | Anyone with the link can add a passkey to your account, so don't forward the email. After you are back in, remove the passkey of the device you lost. ### Email recovery through /auth/access An instance can also offer recovery by email. This path is protected by Cloudflare Access, which checks that you control your email address with a one-time PIN. Diffinery then signs you in to the account with that email address. 1. Open `https://diffinery.com/auth/access`, or click **Recover your account by email** on the sign-in page. 2. Enter your account's email address on the Cloudflare Access page. Cloudflare emails you a one-time PIN. 3. Enter the PIN. Cloudflare Access sends you back to Diffinery, which signs you in. 4. If your account has no passkeys, you land on **Settings → Passkeys and sessions** with "You're signed in. Add a passkey now so you can sign in directly next time." Add one right away. If you still have passkeys, you go to the page you were trying to open. Every email-recovery sign-in sends a "New sign-in to your account" email and is recorded in the audit log. Unless this browser was already signed in to the account, every other browser is signed out; your access tokens keep working. When email recovery isn't set up on an instance, `/auth/access` returns 404 with "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.", and the sign-in page doesn't show the email recovery link. If the email address you verify has no account, Diffinery creates one that waits for an invite (see [Invitations](https://diffinery.com/docs/accounts/invitations#access-requests)), unless that address has an open invite or is a bootstrap site administrator. ## Browser and device requirements - A browser with WebAuthn passkey support, such as a current version of Chrome, Safari, Edge or Firefox. In a browser without it, the sign-in button shows "This browser does not support passkeys. Try a current version of Chrome, Safari, Edge or Firefox." - JavaScript turned on. Without it the page shows "Passkeys need JavaScript." - An authenticator that can store a discoverable passkey and verify you: a phone or computer with a fingerprint reader, face unlock, PIN or screen lock, a password manager that supports passkeys, or a hardware security key that stores passkeys and has a PIN or biometric. - The real site address. Passkeys are bound to `diffinery.com`, so they don't work through another hostname or a raw IP address. ## Password managers and synced passkeys Passkeys saved in a password manager or your platform's keychain usually sync to your other devices, so one passkey covers your laptop and phone. Diffinery shows these with the **synced** badge. A passkey on a hardware security key or in a device-only store shows **this device only**; keep a second passkey somewhere else so you can't be locked out by losing that one key. If your browser offers several places to save the passkey when you create it, pick the password manager or keychain you use on all your devices. ## How Diffinery sign-in differs from GitHub - Diffinery has no passwords and no username-and-password sign-in, so there is no password reset and no separate two-factor setup: the passkey's user verification is the second factor. - Unless the instance turns on open sign-up, you can't sign up from the sign-in page: accounts come from invites. With open sign-up, an emailed code confirms your address instead of a password. - Account recovery is a one-time link from a site administrator, or email recovery when the instance has it set up. There are no recovery codes. - Git over HTTPS and the API never accept a passkey or browser session; they take a personal access token, much like GitHub. ## Troubleshooting | Message or symptom | What it means and what to do | | --- | --- | | "The passkey prompt was closed or timed out. Try again when you are ready." | The browser prompt was cancelled, or 2 minutes passed. Click the button again. If no passkey is offered at all, this device has no passkey for Diffinery: use another device, or recover your account. | | "This passkey is not registered with Diffinery. It may have been removed." | The passkey you picked was removed from your account (or belongs to another instance). Pick another passkey or recover your account. You can also delete the stale passkey from your device or password manager. | | "This sign-in attempt expired. Please try again." | More than 5 minutes passed since the attempt started, or the browser didn't send the sign-in cookie back (for example, blocked cookies). Reload the page and try again. | | "Passkey check failed: ..." | The browser's response didn't verify, for example because the page was opened on a different hostname. Open `https://diffinery.com/login` directly and try again. | | "Your account is disabled." | A site administrator disabled your account. Ask them to enable it. | | "This device already has a passkey for this account." | When adding a passkey: pick a different authenticator. | | "This browser does not support passkeys..." | Update your browser or use another one. | | "this invite is invalid, already used, or expired" | Ask the person who invited you for a new invite. | | "That code has expired or was entered wrong too many times. Send a new code." | Sign-up codes last 10 minutes and allow 5 tries. Click **send a new code**. | | "We have sent several codes to
today." | At most 5 sign-up codes a day go to one address. Check your spam folder, or try again tomorrow. | | "Sign-up needs Turnstile configured" | The instance turned on open sign-up without Turnstile keys. Ask its administrators for an invite. | | "this sign-in link is invalid, already used, or expired" | Sign-in links work once within 24 hours. Ask a site administrator for a new one. | | "Waiting for an invite" page after email recovery | Your email address has no Diffinery account. Ask someone to invite that address, or click **Request access**. | | Lost every passkey | Use [email recovery](#email-recovery-through-authaccess) if the sign-in page offers it, otherwise ask a site administrator for a [sign-in link](#sign-in-link-from-an-administrator). | | `git` or the CLI asks for a password | Use a personal access token, not a passkey. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). | --- # Invitations > How people join an invite-only Diffinery instance, who can invite, Add people by username or email, accepting or declining invitations, revoking them, the CLI and API, and access requests. Source: https://diffinery.com/docs/accounts/invitations Diffinery is invite-only by default: nobody can sign up from the sign-in page. (An operator can turn on [open sign-up](https://diffinery.com/docs/accounts/passkeys-and-sign-in#open-sign-up) with `SIGNUP=open`, where people create an account by confirming their email with a code; invitations work the same either way.) A person joins by opening an invite link sent to their email address, picking a username and creating a passkey. Organizations and repositories have an **Add people** form that takes a username or an email address. Either way the person gets an invitation, as on GitHub: someone added by username accepts it under **Settings → Invitations**, and an email address gets an invite link that also creates the account if there is none. Without an invitation, someone can [request early access](https://diffinery.com/docs/accounts/early-access) at `/early-access`, which the sign-in page links to. A site administrator reads the request and decides whether to invite them. ## How sign-up works 1. Someone with permission creates an invite for an email address. The invite can be for the whole instance, for an organization (with an organization role) or for a repository (with a repository role). 2. Diffinery emails the invite link to that address, and shows the link to the person who created it so they can also send it another way. 3. The invited person opens the link from the invitation email, picks a username, creates a passkey and is signed in. The emailed link carries a proof that they can read mail at that address. The link the inviter sees (`https://diffinery.com/invite/`) doesn't: opening it shows **Confirm your email** with an **Email me the link** button, which sends the link to the invited address again (at most once every 2 minutes and 5 times a day). 4. Their account is active, and every open invite for their email address is accepted at once: they join each organization and repository they were invited to. 5. They land on the organization or repository of the invite they opened (or the dashboard, for an instance invite). Nobody needs a password or a Cloudflare account. After joining, they use their passkey in the browser and create [personal access tokens](https://diffinery.com/docs/accounts/tokens) for Git, the CLI and agents. ## Who can invite | Who | What they can invite to | Where | | --- | --- | --- | | Site administrators | The instance (no organization or repository), and any organization | **Settings → People and invitations**, an organization's **People** tab, `diffinery invite`, the API | | Organization owners | Their organization, as member, admin or owner | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API | | Organization admins | Their organization, as member or admin | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API | | Repository admins (including the owner of a personal repository) | One repository, with a repository role from read to admin | The repository's **Settings → Collaborators** page (**Add people**), `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` | Everyone else, including organization members and people with a lower repository role, can't create invites. ## Invite from the web UI ### Invite someone to the instance Site administrators only. An instance invite lets the person create an account, without adding them to any organization or repository. 1. Open **Settings → People and invitations** (`/settings/admin`). 2. Under **Invite someone to this instance**, enter the **Email address**. 3. Click **Send invitation**. The page shows "Invite created for " with the link and a copy button: "We emailed it to ; you can also send it yourself. It expires in 14 days and works once: they pick a username and create a passkey." An address can have only one pending instance invitation at a time (letter case doesn't matter). A second one is refused with "friend@example.com already has a pending invitation; revoke it first to send a new one"; revoke the first one under **Pending invitations** on the same page, then invite the address again. ### Add people to an organization Organization owners and admins, and site administrators. The **Add people** form on the organization's **People** tab takes a username or an email address. 1. Open the organization's page, for example `https://diffinery.com/acme`, and click the **People** tab. 2. Under **Add people**, enter a **Username or email**: `diffy` (or `@diffy`) for someone who has an account, or `friend@example.com` for anyone. 3. Choose a **Role**: **Member** (default), **Admin** or **Owner**. Only owners can choose **Owner**. 4. Click **Add**. | You entered | What happens | | --- | --- | | A username | Diffinery sends them an invitation: "Invited diffy to acme as member. They join once they accept." They get an email and an in-app notification, and join when they accept it under **Settings → Invitations** (see [Invitations you receive](#invitations-you-receive)). If they already have a pending invitation to the organization, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers the admin role." | | An email address | Diffinery emails an invitation, whether or not the address has an account, so the form never tells you who an address belongs to and nobody joins without agreeing. The page shows **Invitation sent to friend@example.com** with the link and a copy button, and the note "We emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: they join when they accept it, creating an account and passkey first if they don't have one." | A site administrator who enters their own username joins at once. The invitation then appears under **Pending invitations** on the same tab, with who sent it, when, the expiry date and the role. An invitation sent to a username shows as `@diffy`, never the person's email address. See [Organizations](https://diffinery.com/docs/accounts/organizations#add-people) for every message the form can show. An email address can have only one pending invitation per organization, whether it came from this form, the CLI or the API. Letter case doesn't matter: `Friend@Example.com` and `friend@example.com` are the same address. To send a new one, revoke the old one first ("friend@example.com already has a pending invitation; revoke it first to send a new one"). ### Revoke an invitation Every pending invitation can be revoked until it's used: - organization invitations on the organization's **People** tab (owners and admins); - repository invitations on the repository's **Settings → Collaborators** page (repository admins); - instance invitations on **Settings → People and invitations** (site administrators). Under **Pending invitations**, click **Revoke** next to the invitation and confirm "Revoke the invitation for friend@example.com? The link stops working." (for an invitation sent to a username: "Revoke the invitation for @diffy?"). You see "Invitation revoked." Opening the link afterwards shows "this invite is invalid, already used, or expired", and an invitation sent to a username disappears from the person's **Settings → Invitations**. The person isn't notified, and you can invite the same address or username again. ### Add people to a repository Repository admins. The **Add people** form on the repository's **Settings → Collaborators** page (`/acme/website/settings/access`) works the same way, and also accepts agents: 1. Under **Add people**, enter a **Username or email**: a person (`diffy`), an agent (`review-bot`) or an email address (`friend@example.com`). 2. Choose a **Role**: **Read**, **Triage**, **Write** (default), **Maintain** or **Admin**. 3. Click **Add**. | You entered | What happens | | --- | --- | | The username of a person | Diffinery sends them an invitation: "Invited diffy with write access. They get access once they accept." They get an email and an in-app notification, and get the role when they accept it under **Settings → Invitations** (see [Invitations you receive](#invitations-you-receive)). If they already have a pending invitation to the repository, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers admin access." | | The username of an agent, of a person who already is a direct collaborator, of a member of the organization that owns the repository, or your own | They get the role right away as a direct collaborator, with no invitation: "diffy now has write access. We let them know." People get an email and an in-app notification. A collaborator's role changes to the one you chose. | | An email address | Diffinery emails an invitation, whether or not the address has an account, and shows **Invitation sent to friend@example.com** with the link to send yourself. When the person accepts it, they get that role on the repository. | | Message | Cause | | --- | --- | | "no one has the username diffy; to invite someone new, enter their email address" | No account has that username. | | "acme is an organization; add its people instead" | Organizations can't be collaborators. | | "diffy's account is disabled" | A site administrator disabled the account. | | "enter a valid email address" | The text has an `@` but isn't a complete address. | | "friend@example.com already has a pending invitation; revoke it first to send a new one" | The address already has an open invitation to this repository (letter case doesn't matter). Revoke it under **Pending invitations**, then add the address again. | Pending invitations are listed under **Pending invitations** on the same page, where you can revoke them. See [Permissions](https://diffinery.com/docs/accounts/permissions) for what each role allows. ## Invite from the CLI `diffinery invite` invites to an organization, or to the instance when you leave out `--org` (site administrators only). You need to be logged in (`diffinery login`) with a token that has the `admin` scope and isn't restricted to repositories; a `read` or `write` token gets `403 this token needs the admin scope`. If you don't have the CLI yet: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login --scope admin ``` The CLI needs Node.js 22 or later. ```sh # Invite to the acme organization as a member (the default role) diffinery invite friend@example.com --org acme # Invite as an organization admin diffinery invite friend@example.com --org acme --role admin # Invite to the instance only (site administrators) diffinery invite friend@example.com ``` | Option | Description | | --- | --- | | `-o, --org ORG` | Organization to join. Leave it out for an instance invite, which only site administrators can create. | | `-r, --role ROLE` | `member` (default), `admin` or `owner`. Needs `--org`. | | `--json` | Print the API response (`id` and `url`) as JSON. | The command prints the invite link and reminds you that it is valid for 14 days. Like the web form, `diffinery invite` always creates an invitation, even when the address already has an account (they accept it with **Accept invite**; see [Accept an invite with an existing account](#accept-an-invite-with-an-existing-account)). It refuses an address that already has a pending invitation to the organization (see [API errors](#api-errors)). The CLI can't create repository invites; use the repository's **Settings → Collaborators** page or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}`. See [CLI reference](https://diffinery.com/docs/reference/cli) for every command. ## Invite with the API The organization and instance endpoints take a JSON body and return `201 Created` with the invite's `id` and `url`. Diffinery also emails the link. Inviting to an organization or the instance is an account-level action, so both endpoints need a personal access token with the `admin` scope that isn't restricted to repositories (or a browser session). A token with only `read` or `write` gets `403 this token needs the admin scope`, and a token restricted to repositories gets `403 this token is limited to repositories and can't change account settings`. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens#account-level-actions). ### Organization invite `POST /api/v3/orgs/{org}/invitations` | Field | Type | Description | | --- | --- | --- | | `email` | string | Required. The address to invite. | | `role` | string | `member` (default), `admin` or `owner`. | ```sh curl -X POST https://diffinery.com/api/v3/orgs/acme/invitations \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"email": "friend@example.com", "role": "member"}' ``` ```json { "id": "3f1c0e8a9b7d4c2e8f6a5b4c3d2e1f0a", "url": "https://diffinery.com/invite/..." } ``` The caller must be an owner or admin of the organization, or a site administrator. Only owners (and site administrators) can invite with the `owner` role. Like the CLI and the web form, this endpoint always creates an invitation; it doesn't add existing accounts directly. It refuses an address that already has a pending invitation to the organization (compared without regard to letter case). An address whose account is already a member still gets an invitation, so the answer never tells you whose address it is; accepting it changes nothing. ### Instance invite `POST /api/v3/invitations` with `{"email": "friend@example.com"}`. Site administrators only. ### Repository invite by username `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` with `{"permission": "write"}` works like **Add people** on the repository's **Settings → Collaborators** page. It needs admin on the repository, through an `admin` token (it may be restricted to that repository) or a browser session. - When the person gets an invitation, the answer is `201 Created` with the invitation: `id`, `repository` (`full_name`, `name`, `owner.login`), `invitee`, `inviter` (`login`), `permissions`, `created_at`, `expires_at` and `html_url` (their **Settings → Invitations** page). If they already had a pending invitation to the repository, the same invitation now offers the new role. - When the role applies at once (an agent, someone who already is a direct collaborator, a member of the organization that owns the repository, or yourself), the answer is `204 No Content`. ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"permission": "write"}' ``` It answers `404 user not found` when no account has that username, `422 diffy's account is disabled` for a disabled account, and `429 you can send at most 50 invitations in 24 hours; try again later` like the other ways of inviting. ### List and revoke pending invitations | Method and path | Who | | --- | --- | | `GET /api/v3/orgs/{org}/invitations`, `DELETE /api/v3/orgs/{org}/invitations/{id}` | The organization's owners and admins | | `GET /api/v3/repos/{owner}/{repo}/invitations`, `DELETE /api/v3/repos/{owner}/{repo}/invitations/{id}` | The repository's admins | | `GET /api/v3/invitations`, `DELETE /api/v3/invitations/{id}` | Site administrators (instance invitations only) | Each listed invitation has an `id`, `email`, `login`, `role`, `inviter`, `created_at` and `expires_at`. An invitation sent to an email address has the `email` and a `login` of `null`; one sent to a username has the `login` and an `email` of `null`. `DELETE` returns `204 No Content`, or `404 no pending invitation with this id` when the invitation was already used or revoked, or belongs to another organization, repository or kind. Revoking organization and instance invitations needs an `admin` token; revoking a repository invitation needs admin on the repository. Agents use the MCP tools `list_invitations` and `revoke_invitation` with `owner` and `repo`, `org`, or neither. ### API errors | Status | Message | Cause | | --- | --- | --- | | 401 | `sign in required` | No token was sent. | | 403 | `this token needs the admin scope` | The token's scope is `read` or `write`. Create an `admin` token. | | 403 | `this token is limited to repositories and can't change account settings` | The token was created with a repository list. Use a token without one. | | 403 | `only organization owners and admins can invite` | You are not an owner or admin of the organization. | | 403 | `only owners can invite owners` | An organization admin asked for `"role": "owner"`. | | 403 | `only site admins can invite people without an organization` | Instance invites need a site administrator. | | 429 | `you can send at most 50 invitations in 24 hours; try again later` | You sent 50 invitations in the last 24 hours. Site administrators aren't limited. See [Limits](https://diffinery.com/docs/reference/limits#email). | | 404 | `not found` | The organization doesn't exist, or the name is not an organization. | | 422 | `a valid email is required` | The `email` field is missing or isn't an email address. | | 422 | `role must be member, admin or owner` | `role` has another value. | | 422 | `friend@example.com already has a pending invitation; revoke it first to send a new one` | The address already has an open invitation to this organization (or, for `POST /api/v3/invitations`, an open instance invitation). Letter case doesn't matter. Revoke the organization invitation on the **People** tab first. | Error bodies are JSON: `{"message": "...", "documentation_url": "..."}`. The full endpoint list is in the [API reference](https://diffinery.com/docs/api). ## The invite email Diffinery sends the invite from `notifications@diffinery.com`. The subject depends on the kind of invite: | Invite | Subject | | --- | --- | | Organization | `@diffy invited you to join the acme organization on Diffinery` | | Repository | `@diffy invited you to collaborate on acme/website` | | Instance | `@diffy invited you to Diffinery` | The email lists who invited you, the organization or repository and the role (for organization and repository invites) and the expiry date, and has an **Accept invitation** button. It says the invitation only works for that email address. Invite emails can't be turned off, because they are the only way to reach someone without an account. If the address already belongs to a Diffinery account, the email goes to that account. An invitation sent to a username has the same subject and details, but its button is **View invitation** and opens **Settings → Invitations** (`/settings/invitations`), where you accept or decline it. It carries no link that accepts the invitation by itself. See [Invitations you receive](#invitations-you-receive). ## Invite link rules | Rule | Value | | --- | --- | | Lifetime | 14 days from creation | | Uses | Once. A used, revoked or expired link shows "this invite is invalid, already used, or expired" (HTTP 404). If you are already signed in, the page still shows **Accept invite**, and clicking it answers "this invite is invalid or has expired" (HTTP 404). | | Who can use it | Only the email address it was sent to | | Link format | The emailed link is `https:///invite/#proof=`. The proof, which shows you received the email, is in the URL fragment: browsers never send it to the server, so it can't end up in request logs. The page removes it from the address bar. Older links with `?proof=` still work. | | What joining accepts | Every open invite for that email address, not just the one you opened | | Withdrawing | Pending invitations can be revoked: organization invitations on the organization's **People** tab, repository invitations on the repository's **Settings → Collaborators** page, instance invitations on **Settings → People and invitations**. Otherwise they stop working once used or after 14 days. | If you sent a repository or instance invite to the wrong address, a site administrator can disable the account it creates (see [Site administration](https://diffinery.com/docs/admin/site-administration#people)). ## Choosing a username The **Join Diffinery** page suggests a username made from the part of the email address before the `@`. Rules: - Starts with a letter or digit, then letters, digits, `-` and `_`, up to 39 characters. - Not already taken (case-insensitive) and not reserved. Reserved names include `api`, `mcp`, `settings`, `new`, `notifications`, `invite`, `login`, `admin`, `explore`, `docs`, `auth`, `signin`, `signup`, `security`, `status`, `diffinery`, `ghost`, `github-actions`, `noreply`, `support`, `abuse`, `postmaster`, `root` and `staff`. Your username is part of your repository URLs (`https://diffinery.com//`). You can change it later under **Settings → Profile**, which changes those URLs; existing clones then need a new remote URL (see [Profile and preferences](https://diffinery.com/docs/accounts/profile-and-preferences#change-your-username)). | Message | What to do | | --- | --- | | "Usernames use letters, numbers and dashes (up to 39), and some names are reserved." | Pick a name that follows the rules above. | | "The username is taken. Pick another one." | Pick another name. | | " already has an account. Sign in with its passkey, then open this invite again." | The address already has an account: sign in, then open the link and click **Accept invite**. | | "This invite has no email address. Sign in first, then open it again." | Sign in with your existing account, then open the link again. | ## Accept an invite with an existing account If the invited address already has an account: 1. Open the invite link from the email. The page says the address already has an account and shows a **Sign in** button. (A copy of the link without the email's proof, such as the one the inviter sees, asks you to confirm your email instead, and also offers **Sign in** to accept.) 2. Sign in with your passkey. Diffinery brings you back to the invite. 3. Click **Accept invite**. You are taken to the repository or organization you were invited to. The invite only works when the signed-in account's email address matches; otherwise you get "this invite was sent to ; sign in with that address" (HTTP 403). When someone accepts your invite, you get an "@ accepted your invitation" email (part of the **Organizations** email group, which you can turn off; see [Notifications](https://diffinery.com/docs/accounts/notifications)). For an invitation you sent to a username, the email doesn't show the person's email address. ## Invitations you receive When an organization owner or admin, or a repository admin, adds you by username, you get an invitation instead of access, as on GitHub. You get an in-app notification (kind `invitation`, for example "diffy invited you to acme as member" or "diffy invited you to acme/website as write") and an email with a **View invitation** button. Both lead to **Settings → Invitations**. You have no access until you accept. ### Accept or decline in the web UI Open **Settings → Invitations** (`/settings/invitations`). It lists your pending invitations to organizations and repositories: those sent to your username, and those sent to your email address. Each one shows the organization or repository, the role, who invited you, when, and when it expires. With none, the page shows "No pending invitations." - **Accept** gives you the role and takes you to the repository or organization. - **Decline** deletes the invitation. You see "Invitation declined." Nobody is told, and they can invite you again. An invitation expires after 14 days. If the admin adds you again while one is pending, that invitation offers the new role; you don't get a second one. If your account is waiting for an invite (see [Pending accounts](#pending-accounts)), your invitations appear under **Your invitations** on the **Waiting for an invite** page instead, with the same **Accept** and **Decline** buttons. Accepting one makes your account active. ### Accept or decline with the API The endpoints follow GitHub's. Listing needs a token with at least the `read` scope; accepting and declining need `write` or `admin`. All of them refuse tokens restricted to repositories (`403 this token is limited to repositories and can't change account settings`). | Method and path | What it does | | --- | --- | | `GET /api/v3/user/repository_invitations` | Your pending repository invitations: `id`, `repository` (`full_name`, `name`, `owner.login`), `invitee`, `inviter` (`login`), `permissions`, `created_at`, `expires_at`, `html_url` | | `PATCH /api/v3/user/repository_invitations/{id}` | Accept a repository invitation. Returns `204 No Content`. | | `DELETE /api/v3/user/repository_invitations/{id}` | Decline a repository invitation. Returns `204 No Content`. | | `GET /api/v3/user/memberships/orgs` | Your organization memberships, pending invitations first. Add `?state=active` or `?state=pending` for only one kind. | | `GET /api/v3/user/memberships/orgs/{org}` | Your membership of one organization, active or pending. `404 not found` when you have neither. | | `PATCH /api/v3/user/memberships/orgs/{org}` | With `{"state": "active"}`, accept your pending invitation to the organization. Returns the membership. | A membership has `url`, `state` (`active` or `pending`), `role` (`admin` or `member`, as on GitHub, where owners count as `admin`), `diffinery_role` (`owner`, `admin` or `member`), `organization` (`login`, `url`) and `user`. ```sh # List your repository invitations curl https://diffinery.com/api/v3/user/repository_invitations -H "Authorization: Bearer dfy_..." # Accept one curl -X PATCH https://diffinery.com/api/v3/user/repository_invitations/3f1c0e8a9b7d4c2e8f6a5b4c3d2e1f0a -H "Authorization: Bearer dfy_..." # Accept an invitation to the acme organization curl -X PATCH https://diffinery.com/api/v3/user/memberships/orgs/acme \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"state": "active"}' ``` | Status | Message | Cause | | --- | --- | --- | | 404 | `no pending invitation with this id` | The id is unknown, the invitation was already accepted, declined, revoked or expired, or it is someone else's. | | 404 | `no pending invitation to this organization` | `PATCH /user/memberships/orgs/{org}` when you have no pending invitation to it and aren't a member. If you already are a member, the answer is your active membership. | | 422 | `state must be "active"` | The `PATCH` body has another `state`, or none. | An account that is waiting for an invite can't use the API; it accepts on the **Waiting for an invite** page. ## Pending accounts An account can exist without being active. Its status is **pending** when someone verified their email address through email recovery (`/auth/access`) without having an invite. A pending account: - can sign in, but only sees a **Waiting for an invite** page, which lists its pending invitations to organizations and repositories under **Your invitations**, - can't see repositories, create organizations or tokens, or use the API (requests return "your account is waiting for an invite"), - becomes active as soon as anyone creates an invite for its email address (including with **Add people**), the person accepts an invitation on the **Waiting for an invite** page, or a site administrator approves it. Being invited by username doesn't make the account active by itself. > **Note:** If email recovery is set up on the instance and an invited person verifies their address at `/auth/access` instead of opening the invite link, Diffinery creates their account right away with a username based on their email address, accepts their invites and asks them to add a passkey. ## Access requests From the **Waiting for an invite** page, a person with a pending account can ask the administrators for access: 1. Optionally write a **Note to the administrators** (up to 500 characters), for example who you are and what you want to work on. 2. Click **Request access**. You see "Request sent. An administrator will review it." Diffinery emails up to 20 active site administrators an " asked for access" message with the note. The request appears under **Pending access requests** on **Settings → People and invitations**, where an administrator clicks **Approve** (the account becomes active) or **Deny** (the account is disabled). Sending another request replaces the earlier note. Approving an account doesn't add it to any organization or repository; invite the person or add them by username for that. ## How invitations differ from GitHub - On GitHub anyone can create an account. On an invite-only Diffinery instance (the default) the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance; with open sign-up, anyone who can read mail at their address can join too. - GitHub organization and repository invitations go to existing GitHub users. Diffinery invites can also go to an email address, and the same link creates the account. - **Add people** takes a username or an email address, as on GitHub. Someone added by username gets an invitation to accept under **Settings → Invitations**, as on GitHub. An email address always gets an invitation link, whether or not it has an account. - Pending invitations of every kind can be revoked, in the web UI, the API or over MCP; otherwise they expire after 14 days. --- # Early access > Ask for an invitation to an invite-only Diffinery instance, what happens to your request and data, how the form is protected, and how site administrators invite, decline or delete requests. Source: https://diffinery.com/docs/accounts/early-access While a Diffinery instance is invite-only (the default), people without an invitation can ask for one at `/early-access` on that instance. The sign-in page and the sign-up address lead there. A request never grants access by itself: a site administrator reads it and decides whether to send an [invitation](https://diffinery.com/docs/accounts/invitations). On diffinery.com, the project's own instance, this is how to try Diffinery before you deploy your own: [request early access there](https://diffinery.com/early-access). The form is only there while sign-up is closed. On an instance with [open sign-up](https://diffinery.com/docs/accounts/passkeys-and-sign-in#open-sign-up) (`SIGNUP=open`), `/early-access` leads to the sign-up page instead. ## Request early access 1. Open `/early-access`. If you're already signed in, you go to your dashboard instead. 2. Fill in the form. Only **Work email** is required: | Field | Limit | What it's for | | --- | --- | --- | | **Work email** | a valid address, up to 254 characters | Where the confirmation link and, later, the invitation go | | **Name** | up to 100 characters | How the site administrators address you | | **Company or team** | up to 120 characters | Who you'd use Diffinery with | | **What would you use Diffinery for?** | up to 1,000 characters, plain text | What you'd move or build, so the site administrators can prioritize | | **Where do you host your code today?** | one of GitHub, GitLab, Bitbucket, Azure DevOps, Self-hosted, Somewhere else, Nowhere yet | Helps plan your move | 3. Complete the "are you a person" check if one shows, and click **Request early access**. 4. You see **Check your inbox**. Open the email titled "Confirm your early access request" and click **Confirm my request**, then press **Confirm my request** on the page that opens. The link works once, for 48 hours; asking again sends a new link, and only the newest one works. 5. You see **You're on the list**. Your request now waits for a site administrator. When a site administrator invites you, you get the usual invitation email ("invited you to Diffinery"). Its link lets you pick a username and create a passkey; see [How sign-up works](https://diffinery.com/docs/accounts/invitations#how-sign-up-works). Nobody emails you if your request is declined. ### What the email says The form always shows **Check your inbox**, whatever the address. What arrives depends on the address: | The address | The email | | --- | --- | | New, or asked before but never confirmed | "Confirm your early access request", with the link. The new details replace the unconfirmed ones. | | Already confirmed or declined | "We already have your early access request": nothing else to do | | Already invited | "You're already invited", with the date the invitation was sent | | Belongs to an active Diffinery account | "You already have a Diffinery account", with a link to sign in | | Belongs to a disabled or suspended account | Nothing | So only the person who reads that inbox learns whether the address has an account or asked before; the form itself never tells anyone. These emails never contain anything typed into the form. ### Removing your request You can delete your request yourself at any time, whatever its state: 1. Open `/early-access/remove` (the form's privacy note links to it). 2. Enter the address of the request, complete the check if one shows, and click **Email me a removal link**. 3. Open the email titled "Remove your early access request" and click **Remove my request**, then press **Remove my request** on the page that opens. You see **Your request is removed**. The link works once, for 24 hours. The page answers **Check your inbox** whether or not the address has a request, and only an address with one gets an email, so nobody can remove or look up someone else's request. Removal links count towards the same daily limits as requests. A site administrator can also delete a request for you. Unconfirmed requests are deleted automatically 7 days after they were last sent. ## What is stored | What | Kept | | --- | --- | | The fields you filled in and the request's state | Until a site administrator deletes the request; unconfirmed requests for 7 days | | A SHA-256 hash of the confirmation link's code | Until the link is used or replaced | | A SHA-256 hash of a removal link's code, when you ask for one | Until the request is removed | | One row per form submission with SHA-256 hashes of your IP address and of the address typed | One day, for the daily limits only, even after the request is deleted | Your IP address and the address you typed are never stored in the clear for the limits, and nothing you type is written to logs. Only site administrators can read requests. In the [data model](https://diffinery.com/docs/self-hosting/architecture), these are the `early_access` and `early_access_attempts` tables. ## How the form is protected | Protection | What it does | | --- | --- | | Cloudflare Turnstile | The "are you a person" check, the same one the [security report form](https://diffinery.com/docs/security/disclosure) uses. A deployed instance without Turnstile keys (`TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`) doesn't show the form; on your own machine the check is skipped. | | Honeypot and timing | A field people never see, and a signed time stamp: a form that fills the hidden field, comes back within 3 seconds, or carries a missing or forged stamp gets the usual **Check your inbox** page and nothing is stored or sent. A form left open more than 24 hours asks you to send it again. | | Per-minute limit | At most 20 sign-in attempts, security reports and early access requests a minute from one network (`RL_AUTH`, see [Rate limits](https://diffinery.com/docs/reference/rest-api#rate-limits)). | | Daily limits | At most 10 submissions from one IP address, 3 emails to one address, and 500 emails from the form in total, in 24 hours. Past the network limit you see "Too many requests from your network today"; past the other two the page looks the same and no email is sent. | | Double opt-in | A request counts only after its address follows the emailed link, so nobody can sign up someone else. | | Strict input | Lengths are capped (longer is refused, never cut), the email address is checked, the hosting answer must be one of the list, and control and text-direction characters are removed. Everything is shown as plain text, never as HTML or Markdown. | ## For site administrators **Settings → Early access** (`/settings/admin/early-access`) is in the **Site administration** group. The sidebar shows how many confirmed requests are waiting, **People and invitations** has a reminder while any are, and each site administrator gets one in-app notification, "New early access requests are waiting for you", which stays until it is read however many requests arrive. There are no per-request emails. Anyone who isn't a site administrator gets `404`. The tabs filter by state, with counts: | Tab | State | Meaning | | --- | --- | --- | | **Confirmed** (the default) | `confirmed` | The address confirmed; waiting for you to invite or decline | | **Unconfirmed** | `unconfirmed` | The address hasn't followed the link yet | | **Invited** | `invited` | Invited, by whom and when | | **Declined** | `declined` | Declined, by whom and when | | **All** | | Every request | The search box finds requests whose address, name, company or use contains what you type. Each request shows its name and address, company, where they host code, when they asked, the start of what they'd use Diffinery for, and an amber **has an account** badge when an active account already uses the address. Click a name to see everything, including the full text and its reference. - **Invite** asks you to confirm, then sends the address the instance's invitation email, exactly like **Invite someone to this instance** on [People and invitations](https://diffinery.com/docs/admin/site-administration#invites): it works once for 14 days. The request becomes **Invited**. If the address already has a pending instance invitation, that one is kept and no second email is sent. An address that already has an account has no **Invite** button. You can invite an unconfirmed request too: the invitation itself only works for someone who reads that inbox. - **Decline** marks the request **Declined**. Nobody is emailed. **Put back** on a declined request returns it to **Confirmed** (or **Unconfirmed**, if it never was). - **Delete request**, on a request's page, removes it for good, for someone who asks for their data to be removed. An invitation already sent stays; revoke it on **People and invitations**. Every decision is in the [audit log](https://diffinery.com/docs/security/audit-log): `early_access.invite` (with `invite.create` when a new invitation is sent), `early_access.decline`, `early_access.restore` and `early_access.delete` (also when someone removes their own request), and `early_access.confirm` when an address confirms. Entries name the request by its id; none holds what the request said, and `early_access.delete` doesn't hold the address. ### From the API With a site administrator's token (`read` to list, `admin` to change): | Endpoint | What it does | | --- | --- | | `GET /api/v3/diffinery/admin/early-access` | List requests, newest first. `state`, `q`, `per_page` (up to 500) and `page` filter and page them. | | `GET /api/v3/diffinery/admin/early-access/{id}` | One request | | `POST /api/v3/diffinery/admin/early-access/{id}/invite` | Invite it, as **Invite** does | | `PATCH /api/v3/diffinery/admin/early-access/{id}` | `{"state": "declined"}` declines it; `{"state": "confirmed"}` puts a declined request back | | `DELETE /api/v3/diffinery/admin/early-access/{id}` | Delete it (`204`) | ```bash # Confirmed requests waiting for a decision curl -H "Authorization: Bearer $DIFFINERY_TOKEN" \ "https://diffinery.com/api/v3/diffinery/admin/early-access?state=confirmed" # Invite one curl -X POST -H "Authorization: Bearer $DIFFINERY_TOKEN" \ https://diffinery.com/api/v3/diffinery/admin/early-access//invite ``` Each request has `id`, `state`, `email`, `name`, `company`, `use_case`, `hosting`, `confirmed_at`, `invite_id`, `decided_by`, `decided_at`, `created_at`, `updated_at` and `html_url`. Everyone else gets `404`. See the [API reference](https://diffinery.com/docs/api) for every field. ## Troubleshooting | What you see | Why, and what to do | | --- | --- | | No email after **Check your inbox** | Check the spam folder. The address may have had 3 emails from the form today, or the address belongs to a disabled account; try again tomorrow, or ask someone who uses the instance. | | "This link doesn't work any more" | The link was already used, is older than 48 hours, or a newer one was sent. If you confirmed already, you're on the list. Otherwise request again. | | "This link is incomplete" | The part after `#` was cut off. Open the link from the email in full, or copy all of it. | | "This link doesn't work any more" after **Remove my request** | The removal link was already used, is older than 24 hours, or a newer one was sent. If you used it, the request is gone. | | "We couldn't confirm that you're a person" | The Turnstile check failed or expired. Reload the page and try again. | | "This form was open for a long time" | The page was open more than 24 hours. Check the fields and send it again. | | "Too many requests from your network today" | 10 submissions came from your network in 24 hours. Try again tomorrow. | --- # Organizations > Create organizations, add people by username or email, manage roles and invitations, set the base permission and picture, and share repositories. Source: https://diffinery.com/docs/accounts/organizations An organization is a shared account that owns repositories, agents, container images and Actions secrets and variables on behalf of a group of people. Use one when more than one person works on the same code, so access doesn't depend on any one personal account. ## Organization roles Every member has one of three roles. The role decides what they can do in the organization and the role they get on every repository the organization owns: owners and admins always get `admin`, and members get the organization's [base permission](#base-permission). | Capability | member | admin | owner | | --- | --- | --- | --- | | Role on every organization repository | The base permission (`read` for new organizations) | admin | admin | | See the organization's private repositories | Yes, unless the base permission is `none` | Yes | Yes | | Create repositories in the organization, and get `admin` on the ones you create | Yes | Yes | Yes | | Create container images in the organization and pull its images that aren't linked to a repository | Yes | Yes | Yes | | Push to, delete and change the settings of an organization image that isn't linked to a repository | Only images they created | Yes | Yes | | See the **People** and **Agents** tabs | Yes | Yes | Yes | | **Add people** (by username or email) | No | Yes (as member or admin) | Yes (any role) | | See and revoke pending invitations | No | Yes | Yes | | Change roles, remove members | No | Yes, except owners | Yes, including owners | | Create and manage the organization's agents | No | Yes | Yes | | Manage organization Actions secrets | No | Yes | Yes | | Read and manage organization Actions variables | No | Yes | Yes | | Open the **Settings** tab: rename, display name, base permission, picture | No | No | Yes | | Set the [agent policy](https://diffinery.com/docs/agents/organizations), and see or change the organization's model keys | No | No | Yes | | Transfer repositories into the organization | No | Yes | Yes | | Delete the organization (once it owns no repositories or images) | No | No | Yes | | Change the display name, base permission and picture through the API | No | No | Yes | | Get emails when repositories are created or deleted in the organization | No | Yes | Yes | Site administrators can do everything an owner can in every organization, and have admin on every repository. A container image linked to a repository follows that repository's roles instead (read pulls, write pushes, admin deletes), so a member who can't see a private repository can't pull its images either. See [Container registry](https://diffinery.com/docs/packages/container-registry). A member's role on a repository is the highest of the role their organization role gives them (the base permission for members, `admin` for owners and admins) and any role given to them directly on that repository. A direct collaborator role can raise an organization member's access (for example to `maintain` or `admin`) but never lowers it. See [Permissions](https://diffinery.com/docs/accounts/permissions) for what each repository role allows. ## Base permission The base permission is the repository role that every member of an organization gets on every repository the organization owns, like GitHub's "Base permissions" setting. It only applies to members: owners and admins always have `admin` on every organization repository, whatever the base permission is. | Value | Option in the web UI | What members get on every organization repository | | --- | --- | --- | | `none` | No permission: members see only repositories they're given | No role. Members see only public repositories, repositories they were added to as collaborators, and repositories they created. | | `read` | Read: members can clone and view | `read`: view and clone, open issues and comment | | `write` | Write: members can also push | `write`: also push, open and merge pull requests and run workflows | | `admin` | Admin: members can also change settings | `admin`: also change settings, collaborators, branch protection, secrets and webhooks | `triage` and `maintain` can't be a base permission; give those roles per repository under **Settings → Collaborators**. See [Permissions](https://diffinery.com/docs/accounts/permissions) for everything each role allows. ### Default base permission - A new organization starts with `read`, as on GitHub. - Organizations that existed before Diffinery added the base permission were set to `write`, so their members kept the access they already had. ### How the base permission combines with other access A member's role on an organization repository is the highest of: | Source | Role | | --- | --- | | The organization's base permission | `none`, `read`, `write` or `admin` | | A collaborator role on that repository (**Settings → Collaborators**, an accepted repository invitation, or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}`) | The role they were given | | They created the repository | `admin` | | The repository is public | `read` | A collaborator role can raise a member above the base permission but can't lower it, so to give most members less access and a few people more, set a low base permission and add those people as collaborators on the repositories they need. Diffinery has no teams. Any member can create repositories in the organization, whatever the base permission. As on GitHub, the member who creates a repository becomes its admin: Diffinery adds them as an `admin` collaborator, so they appear under the repository's **Settings → Collaborators**. When a member's personal agent creates the repository, the member gets that grant. Owners and admins already have `admin` everywhere, so no grant is added for them. The base permission also limits agents that belong to a member, because a personal agent never has more access than its owner. Organization agents aren't affected: their access comes from their **Maximum role** (see [Agents](https://diffinery.com/docs/agents/overview)). Container images that aren't linked to a repository aren't affected: every member can still create images in the organization and pull them. An image linked to a repository follows that repository's access, so with `none` a member can't pull images of private repositories they can't see, and the registry catalog leaves those images out (see [Container registry](https://diffinery.com/docs/packages/container-registry)). Roles are worked out on every request, so a change to the base permission applies to every member right away, including in Git and through tokens they already have. ### Change the base permission in the web UI Organization owners and site administrators: 1. Open the organization's page and click the **Settings** tab (`https://diffinery.com/acme?tab=settings`). 2. In the **Base permission** menu, pick **No permission**, **Read**, **Write** or **Admin**. The hint under it reads "What every member can do in all of this organization's repositories. Owners and admins always have admin access, and repository settings can give people more." 3. Click **Save**. You see "Organization updated." Admins and members can't open the **Settings** tab: they get `403 organization owners only`. The change is recorded in the audit log as `org.update`, with the new value as `base_role` (see [Audit log](https://diffinery.com/docs/security/audit-log)). ### Change the base permission with the API The API uses GitHub's field name, `default_repository_permission`. `GET /api/v3/orgs/{org}` returns the organization; members of the organization, their agents and site administrators also see `default_repository_permission` in it. Agents can also read it with the MCP tool `get_organization`. Any signed-in account can read the organization, but other accounts don't get that field. ```sh curl https://diffinery.com/api/v3/orgs/acme \ -H "Authorization: Bearer dfy_..." ``` ```json { "login": "acme", "type": "Organization", "html_url": "https://diffinery.com/acme", "avatar_url": "https://diffinery.com/api/avatars/acme", "name": "Acme Inc.", "default_repository_permission": "read" } ``` (Other fields of GitHub's user object, such as `id`, `node_id` and `url`, are left out here.) `PATCH /api/v3/orgs/{org}` changes it. Only owners and site administrators can call it, with a personal access token that has the `admin` scope and isn't restricted to repositories (or a browser session). The body takes: | Field | Type | Description | | --- | --- | --- | | `default_repository_permission` | string | `none`, `read`, `write` or `admin`. | | `name` | string | The display name. An empty value clears it, and pages show the organization name instead. | ```sh curl -X PATCH https://diffinery.com/api/v3/orgs/acme \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"default_repository_permission": "write"}' ``` The response is the updated organization, including `default_repository_permission`. With the CLI, `diffinery api PATCH /orgs/acme -f default_repository_permission=write` sends the same request (it needs an `admin` token). There is no dedicated CLI command. | Status | Message | Cause | | --- | --- | --- | | 403 | `this token needs the admin scope` | The token's scope is `read` or `write`. | | 403 | `this token is limited to repositories and can't change account settings` | The token was created with a repository list. | | 403 | `only organization owners can change its settings` | You are an admin or member of the organization, not an owner. | | 404 | `not found` | The organization doesn't exist, or you aren't a member of it. | | 422 | `base permission must be none, read, write or admin` | `default_repository_permission` has another value. | | 422 | `name must be a string` | `name` is a number, object or other non-string value. | Every field is checked before anything is saved, so a refused value changes nothing. ## Create an organization Any active account can create an organization and becomes its first owner. ### In the web UI 1. Go to **Settings → Organizations** (`/settings/orgs`), or click **New organization** on your dashboard. 2. Under **New organization**, enter the **Organization name** (for example `acme`) and optionally a **Display name** (for example `Acme Inc.`). 3. Click **Create organization**. Diffinery opens the new organization's **People** tab, where you can add people right away. ### With the API ```sh curl -X POST https://diffinery.com/api/v3/user/orgs \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"login": "acme", "name": "Acme Inc."}' ``` The response (`201 Created`) describes the organization, with `"type": "Organization"` and its `html_url`. Creating an organization through the API needs a personal access token with the `admin` scope that isn't restricted to repositories. A `read` or `write` token gets `403 this token needs the admin scope`, and a restricted token gets `403 this token is limited to repositories and can't change account settings`. In the browser no token is needed. ### Name rules The organization name is its URL (`https://diffinery.com/acme`) and the owner part of every repository URL. It shares one namespace with usernames and agent names. - Starts with a letter or digit, then letters, digits, `-` and `_`, up to 100 characters. Dots aren't allowed. - Not already used by a person, organization or agent: "name already taken". - Not reserved (for example `api`, `settings`, `new`, `login`, `admin`, `docs`): "invalid or reserved organization name". ## The organization page Open `https://diffinery.com/acme` (you need to be signed in). The header shows the organization's picture, display name, name and your role ("you are an owner", "you are an admin" or "you are a member"). The tabs are: | Tab | Who sees it | What it shows | | --- | --- | --- | | **Repositories** | Everyone signed in | Repositories you can see. Members get a **New repository** button. | | **People** | Everyone signed in | Members and their roles. Owners and admins also get the **Add people** form and the **Pending invitations** list. | | **Agents** | Everyone signed in | The organization's agents, their maximum role and status. Owners and admins get a **Manage agents** button. | | **Settings** | Owners | The organization's picture, name, display name and [base permission](#base-permission), and its [agent policy and model keys](https://diffinery.com/docs/agents/organizations). Others get `403 organization owners only`. | | **Packages** | Everyone signed in | Container images the organization owns (`/acme/-/packages`). See [Container registry](https://diffinery.com/docs/packages/container-registry). | Members see every repository of the organization, unless the base permission is `none`: then they see public repositories and the ones they were given access to or created. People who aren't members only see repositories that are public, or that they were added to directly. ## Add and remove members Owners and admins manage members on the **People** tab. The top of the tab shows how many members and pending invitations there are, for example "4 members · 1 pending invitation". ### Add people The **Add people** form takes a username or an email address, like GitHub's "Add people" or "Invite member" dialog: 1. Open `https://diffinery.com/acme?tab=people`. 2. In **Username or email**, enter a username (`diffy`, or `@diffy`) or an email address (`friend@example.com`). 3. Choose a **Role**: **Member** (default) or **Admin**. Owners can also choose **Owner**. 4. Click **Add**. What happens depends on who you entered: | You entered | Result | | --- | --- | | The username of a person | Diffinery sends them an invitation, as on GitHub. You see "Invited diffy to acme as member. They join once they accept." They get an email ("@ invited you to join the acme organization on Diffinery", with a **View invitation** button) and an in-app notification (" invited you to acme as member"), and become a member when they accept it under **Settings → Invitations** (see [Invitations you receive](https://diffinery.com/docs/accounts/invitations#invitations-you-receive)). If they already have a pending invitation to the organization, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers the admin role." | | An email address | Diffinery creates an invitation and emails it, whether or not the address has an account: the form doesn't reveal who an address belongs to, and they join only by accepting. The page shows **Invitation sent to friend@example.com** with the link and a copy button: "We emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: they join when they accept it, creating an account and passkey first if they don't have one." | A site administrator who enters their own username joins at once. A person whose account is waiting for an invite (pending) becomes active when you invite their email address, or when they accept an invitation sent to their username. See [Invitations](https://diffinery.com/docs/accounts/invitations) for how invitations work, including inviting from the CLI and API. | Message | Cause | | --- | --- | | "enter a username or an email address" | The field was empty. | | "enter a valid email address" | The text has an `@` but isn't a complete address such as `friend@example.com`. | | "no one has the username diffy; to invite someone new, enter their email address" | No account has that username. Enter their email address to invite them. | | "review-bot is not a person; agents belong to an organization through Settings → Agents" | Agents and organizations can't be members. Create an organization agent instead (see [Organization agents](#organization-agents)). | | "diffy's account is disabled" | A site administrator disabled the account. | | "diffy is already a member of acme" | Change their role in the members table instead. | | "friend@example.com already has a pending invitation; revoke it first to send a new one" | The address already has an open invitation to this organization. Letter case doesn't matter: `Friend@Example.com` counts as the same address. Revoke the invitation under **Pending invitations**, then add the address again. | | "only owners can add owners" | Admins can add members and admins only. | | "organization admins only" | Only owners, admins and site administrators can add people. | ### Pending invitations Owners and admins see a **Pending invitations** list on the **People** tab with each invitation's email address (or `@diffy` for an invitation sent to a username, never the person's email address), who invited them and when, the expiry date and the role. Accepted, declined and expired invitations aren't listed. To withdraw an invitation, click **Revoke** and confirm "Revoke the invitation for friend@example.com? The link stops working." (or "Revoke the invitation for @diffy?"). You see "Invitation revoked." Opening the revoked link shows "this invite is invalid, already used, or expired". The person isn't told. ### Change a member's role In the member's row, pick a new role (**Owner**, **Admin** or **Member**) in the **Role** menu and click **Save**. The member gets an email saying their role changed and who changed it. ### Remove a member Click **Remove** in the member's row and confirm "Remove diffy from acme?". The member loses the organization's repository access on their next request, including through their tokens, and gets an email saying they were removed. Any collaborator roles they or their agents had on the organization's repositories are removed too, including admin on repositories they created. Their personal repositories and accounts are not affected. Rules: | Rule | Message | | --- | --- | | Only owners can promote someone to owner, change an owner's role or remove an owner. | "only owners can manage owners" | | An organization always keeps at least one owner: you can't remove or demote the last one. | "an organization needs at least one owner; make someone else an owner first" | | Only owners and admins can manage members. | "organization admins only" | | The username must belong to a person. | "user not found" | ### Leave an organization There is no separate **Leave** button. Ask an owner or admin to remove you. Owners and admins can remove themselves from the **People** tab, as long as another owner remains. ## Organization repositories Any member can create a repository owned by the organization. - **Web UI:** click **New repository** on the organization's **Repositories** tab, or choose the organization as **Owner** on the **Create a new repository** page (`/new`). - **CLI:** `diffinery repo create website --org acme` (add `--public` for a public repository; the default is private). - **API:** `POST /api/v3/orgs/acme/repos` with `{"name": "website", "visibility": "private"}`. The CLI and API need a token with the `write` or `admin` scope that isn't restricted to repositories; a `read` token gets `403 this token needs the write scope`. Access to organization repositories comes from the organization role (members get the [base permission](#base-permission), owners and admins get admin), plus any collaborators added under the repository's **Settings → Collaborators**, which can include people outside the organization and agents. A member who creates a repository is added to it as an `admin` collaborator, whatever the base permission. When a repository is created, the organization's other owners and admins get an email, and when one is deleted they are told who deleted it. ## Organization secrets Organization Actions secrets are available to workflows in every repository the organization owns, as `${{ secrets.NAME }}`. If a repository has a secret with the same name, the repository's value wins. Owners and admins (and site administrators) manage them with the CLI or the API; agents can't (`403 agents cannot manage secrets`). There is no page for them in the web UI yet. Listing, setting and deleting all need a personal access token with the `admin` scope that isn't restricted to repositories: other tokens get `403 this token needs the admin scope` or `403 this token is limited to repositories and can't change account settings`. ```sh # Create or update (the value is read from a hidden prompt, stdin or --body) diffinery secret set NPM_TOKEN --org acme # List names and when they were updated (values are never returned) diffinery secret list --org acme # Delete diffinery secret delete NPM_TOKEN --org acme ``` | Method and path | What it does | | --- | --- | | `GET /api/v3/orgs/{org}/actions/secrets` | List secret names and `updated_at` | | `PUT /api/v3/orgs/{org}/actions/secrets/{name}` | Create or update; body `{"value": "..."}`; returns `204` | | `DELETE /api/v3/orgs/{org}/actions/secrets/{name}` | Delete; returns `204` | Names are stored in upper case and must match `^[A-Z_][A-Z0-9_]*$` after upper-casing, otherwise the API returns `422 invalid secret name`. Values are encrypted at rest. Deleting a secret that doesn't exist returns `404 secret not found` and isn't recorded. Each change is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `org.secret.set` or `org.secret.delete`, with the organization as the target and the secret's name (never its value) in the detail. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables) for how workflows use them. ## Organization variables Organization Actions variables are available to workflows in every repository the organization owns, as `${{ vars.NAME }}`, including repositories created later. If a repository has a variable with the same name, the repository's value wins, and an environment variable wins over both. Unlike secrets, variable values are stored and returned in plain text. Owners and admins (and site administrators) manage them with the REST API; there is no page for them in the web UI and no CLI command. Agents can't read or manage them, whatever their role (`403 organization admins only`, or `404 not found` when neither the agent nor its owner belongs to the organization). Members who aren't owners or admins get `403 organization admins only`. Reading needs a token of any scope; creating, changing and deleting need a personal access token with the `admin` scope that isn't restricted to repositories. | Method and path | What it does | | --- | --- | | `GET /api/v3/orgs/{org}/actions/variables` | List variables with their values, sorted by name (not paged) | | `GET /api/v3/orgs/{org}/actions/variables/{name}` | One variable, or `404 variable not found` | | `POST /api/v3/orgs/{org}/actions/variables` | Create or replace; body `{"name": "...", "value": "..."}`; returns `201` | | `PATCH /api/v3/orgs/{org}/actions/variables/{name}` | Set the value (creating the variable if needed); body `{"value": "..."}`; returns `204` | | `DELETE /api/v3/orgs/{org}/actions/variables/{name}` | Delete; returns `204` | ```sh curl -X POST https://diffinery.com/api/v3/orgs/acme/actions/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "NPM_REGISTRY", "value": "https://registry.npmjs.org"}' ``` Names must match `^[A-Za-z_][A-Za-z0-9_]*$`, can't start with `GITHUB_` (`422 invalid variable name`) and are stored in upper case. Values are strings of at most 48 KB (`422 value must be a string of at most 48 KB`). Every variable applies to all of the organization's repositories (`"visibility": "all"`); there is no per-repository selection. Each change is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `org.variable_set` or `org.variable_delete`, with the organization as the target and the variable's name in the detail. A [GitHub organization import](https://diffinery.com/docs/migrate-from-github) adds the GitHub organization's variables that don't exist here yet. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables#organization-variables) for the full details and the differences from GitHub. ## Organization agents An agent can be owned by an organization instead of a person, so it keeps working when people come and go. Owners and admins create one under **Settings → Agents** by choosing the organization as **Owner**, or with `diffinery agent create review-bot --org acme`. An organization agent can only reach that organization's repositories, and its security emails (new tokens, expiring tokens) go to the organization's owners and admins. See [Agents](https://diffinery.com/docs/agents/overview). ## Organization picture The organization's picture shows on its page, next to its repositories and in member lists. Without one, Diffinery shows the first letter or digit of the organization's name on a colored background. Owners change it on the **Settings** tab, in the **Profile picture** box: 1. Under **Upload a new picture**, choose a JPEG, PNG or WebP file of up to 5 MB. It is cropped to a centred square; a preview shows the result. 2. Click **Save picture**. You see "Organization picture updated." 3. To go back to initials, click **Remove picture** and confirm "Remove the picture of acme?". You see "Organization picture removed." Owners (and site administrators) can also change it through the API, with a token that has the `write` or `admin` scope and isn't restricted to repositories. The body is the raw image file: ```sh curl -X PUT https://diffinery.com/api/v3/orgs/acme/avatar \ -H "Authorization: Bearer dfy_..." \ --data-binary @logo.png curl -X DELETE https://diffinery.com/api/v3/orgs/acme/avatar \ -H "Authorization: Bearer dfy_..." ``` Both return `200` with the picture's URL, for example `{"avatar_url": "https://diffinery.com/api/avatars/acme?v=1760350000"}` after an upload and `{"avatar_url": "https://diffinery.com/api/avatars/acme"}` after removing it. Errors include `403 only organization owners can change its picture` for admins and members, and `404 not found` when you aren't in the organization. See [Profile and preferences](https://diffinery.com/docs/accounts/profile-and-preferences#profile-pictures) for the limits, error messages, the public picture URL and caching. ## Rename an organization Owners open the **Settings** tab, change **Organization name**, **Display name** or **Base permission** and click **Save**. You see "Organization updated." Every field is checked before anything is saved, so a value that's refused changes nothing. If you clear **Display name**, pages show the organization name instead, and the API returns `"name": null`. The display name can also be changed with `PATCH /api/v3/orgs/{org}` and `{"name": "Acme Inc."}` (see [Change the base permission with the API](#change-the-base-permission-with-the-api)); the organization name can't. Renaming changes every repository URL in the organization, so existing clones need a new remote URL: ```sh git remote set-url origin https://diffinery.com/acme-inc/website.git ``` URLs with the old name keep working, like those of a [transferred repository](https://diffinery.com/docs/repositories/overview#old-urls-after-a-transfer-or-rename): web pages and the API redirect, and Git and container images are served under the old name, until another person or organization takes it. ## Delete an organization Deleting an organization removes it for good. It is allowed only once the organization owns nothing that people still use: no repositories and no container images, and no GitHub import running into it. Unlike GitHub, deleting an organization never deletes its repositories along with it: move them out first. ### Before you delete - **Who:** organization owners (and site administrators). Admins and members can't delete it. - **Repositories:** [transfer](https://diffinery.com/docs/repositories/overview#transfer-a-repository) each repository to another owner, or delete it. Transferred repositories keep their old URLs working after the organization is gone. - **Container images:** delete the organization's images (images linked to a repository move with it when you transfer the repository). See [Container registry](https://diffinery.com/docs/packages/container-registry). - **Imports:** wait for an import into the organization to finish, or cancel it, including drafts and queued imports. ### Delete in the web UI 1. Open the organization's page and its **Settings** tab (owners only). 2. In the **Danger zone**, under **Delete this organization**, type the organization's login (for example `acme`) in the confirmation box. 3. Click **Delete this organization**. You see `Deleted acme.` and land on your home page. If you signed in more than 2 hours ago, Diffinery first asks you to [Confirm it's you](https://diffinery.com/docs/security/overview#confirming-its-you) with your passkey. ### Delete with the API ```sh curl -X DELETE https://diffinery.com/api/v3/orgs/acme \ -H "Authorization: Bearer dfy_..." ``` It returns `202 Accepted` with an empty object (`{}`) once the organization is deleted. It needs a token with the `admin` scope that isn't limited to repositories. ### What deleting does - Its members, pending invitations, organization secrets and organization variables are removed. - Its agents are disabled, and their tokens revoked. - Its login is freed: anyone can create a user or organization with that name afterwards. Older logins from earlier renames stop redirecting. - Repositories transferred out of it earlier keep their redirects: their old URLs under its name still lead to them, until a repository is created at that owner and name (see [Old URLs after a transfer or rename](https://diffinery.com/docs/repositories/overview#old-urls-after-a-transfer-or-rename)). - Its page and API answer `404`. - The audit log keeps the entries that name it, and records the deletion as `org.delete`. Its import history and agents still refer to it. Deleting can't be undone. To use the name again, create a new organization. ### Errors when deleting | Message | Cause and fix | | --- | --- | | `acme still owns 3 repositories; transfer or delete them before deleting the organization` (422) | Transfer or delete the repositories first. | | `acme still owns 2 container images; delete them before deleting the organization` (422) | Delete the images first. | | `an import into acme is still in progress; wait for it to finish or cancel it first` (422) | Wait for the import, or cancel it. | | `only organization owners can delete it` (403) | You are an admin or member. Ask an owner. | | `not found` (404) | The organization doesn't exist, or you aren't a member of it. | | `this token needs the admin scope` (403) | Use a token with the `admin` scope. | | `this token is limited to repositories and can't change account settings` (403) | Use a token that isn't restricted to repositories. | | `type acme to confirm deletion` | The web confirmation box didn't match the organization's login. | | `organization owners only` | Only owners can delete the organization from the web UI. | ## Organizations API | Method and path | Description | | --- | --- | | `GET /api/v3/user/orgs` | Organizations you belong to, each with your `role` | | `POST /api/v3/user/orgs` | Create an organization: `{"login": "acme", "name": "Acme Inc."}`. Needs an `admin` token. | | `GET /api/v3/orgs/{org}` | The organization; members, their agents and site administrators also get `default_repository_permission` | | `PATCH /api/v3/orgs/{org}` | Change `name` or `default_repository_permission` (`none`, `read`, `write` or `admin`). Owners only; needs an `admin` token. | | `DELETE /api/v3/orgs/{org}` | Delete the organization once it owns no repositories, container images or running imports; returns `202`. Owners only; needs an `admin` token. See [Delete an organization](#delete-an-organization). | | `GET /api/v3/orgs/{org}/members` | Members and their roles. Members and site administrators only; others get `404`. | | `POST /api/v3/orgs/{org}/invitations` | Invite by email: `{"email": "...", "role": "member"}`. Needs an `admin` token. See [Invitations](https://diffinery.com/docs/accounts/invitations#invite-with-the-api). | | `GET /api/v3/orgs/{org}/invitations` | Pending invitations, each with an `id`. Owners and admins. | | `DELETE /api/v3/orgs/{org}/invitations/{id}` | Revoke a pending invitation; returns `204`. Owners and admins; needs an `admin` token. | | `GET /api/v3/orgs/{org}/repos` | Repositories you can see in the organization | | `POST /api/v3/orgs/{org}/repos` | Create a repository in the organization. Needs a `write` or `admin` token. | | `GET`, `PUT`, `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | Organization secrets. Needs an `admin` token. | | `GET`, `POST`, `PATCH`, `DELETE /api/v3/orgs/{org}/actions/variables[/{name}]` | Organization variables. Owners and admins; changes need an `admin` token. | | `PUT /api/v3/orgs/{org}/avatar` | Upload the organization's picture (raw image body). Owners only. | | `DELETE /api/v3/orgs/{org}/avatar` | Remove the organization's picture. Owners only. | Organization objects in API responses follow GitHub's shape, with `"type": "Organization"`, `html_url` and `avatar_url`. Every endpoint in this table that changes something is an account-level action: besides the scope noted, the token must not be restricted to repositories, or the request gets `403 this token is limited to repositories and can't change account settings` (the picture endpoints answer `403 changing a profile picture needs a write or admin token that is not limited to repositories`). See [Personal access tokens](https://diffinery.com/docs/accounts/tokens#account-level-actions). Adding people by username, changing a member's role and removing a member are done in the web UI. A person accepts an invitation to an organization with `PATCH /api/v3/user/memberships/orgs/{org}` and `{"state": "active"}` (see [Invitations you receive](https://diffinery.com/docs/accounts/invitations#accept-or-decline-with-the-api)). The full list of endpoints is in the [API reference](https://diffinery.com/docs/api). ## How organizations differ from GitHub - Diffinery has three organization roles: owner, admin and member. Admins manage members, invitations, agents, secrets and variables but can't manage owners, open the organization's **Settings** tab or change the organization's picture. - The base permission works like GitHub's, with the same four values (`none`, `read`, `write`, `admin`) and the same default for new organizations (`read`). Organizations created before Diffinery had the setting use `write`. - A member who creates a repository gets `admin` on it, as on GitHub. - There are no teams. Give extra access per repository under **Settings → Collaborators**. - **Add people** works like GitHub's: enter a username or an email address. Someone added by username gets an invitation and joins when they accept it under **Settings → Invitations**. An email address gets an invitation link, which also creates the account if there is none. - Organization pages need you to be signed in. - Organization secrets and variables apply to every repository in the organization; there is no per-repository selection. - Deleting an organization is refused while it owns repositories or container images; GitHub deletes them with it. Transfer or delete them first. - Repositories can be transferred only to your own account or to an organization you own or administer, so a transfer needs no acceptance by the new owner. --- # Permissions > The repository roles (read, triage, write, maintain, admin), where roles come from, and how token scopes and agent policies cap them. Source: https://diffinery.com/docs/accounts/permissions Every request to a repository, whether it comes from the web UI, Git, the REST API or MCP, runs with one effective role on that repository. This page explains the five roles, where a person's role comes from, and how token scopes, repository-restricted tokens and agent policies lower it. ## Repository roles Diffinery uses the same five roles as GitHub. Each role includes everything the roles before it can do. | Role | In short | | --- | --- | | `read` | View and clone; open issues and comment | | `triage` | Manage issues, pull requests and labels without pushing code | | `write` | Push, open and merge pull requests, run workflows | | `maintain` | Also move or delete tags and edit other people's comments | | `admin` | Settings, access, branch protection, secrets and webhooks | ### What each role can do | Action | read | triage | write | maintain | admin | | --- | --- | --- | --- | --- | --- | | View code, history, issues, pull requests, workflow runs, logs and artifacts | Yes | Yes | Yes | Yes | Yes | | Clone and fetch over Git, download Git LFS objects | Yes | Yes | Yes | Yes | Yes | | List collaborators | Yes | Yes | Yes | Yes | Yes | | Open issues, comment, and edit or close your own issues | Yes | Yes | Yes | Yes | Yes | | Leave comment-only reviews and line comments on pull requests | Yes | Yes | Yes | Yes | Yes | | Add and remove your own reactions | Yes | Yes | Yes | Yes | Yes | | See published releases, download release assets and source archives (zip and tar) | Yes | Yes | Yes | Yes | Yes | | See commit statuses, check runs, deployments and environments | Yes | Yes | Yes | Yes | Yes | | Watch the repository and subscribe to threads | Yes | Yes | Yes | Yes | Yes | | Create, apply and delete labels; set assignees (labels and assignees sent by someone below triage, when opening or editing an issue or pull request, are dropped) | No | Yes | Yes | Yes | Yes | | Edit, close or reopen anyone's issue or pull request | No | Yes | Yes | Yes | Yes | | Request reviewers on anyone's pull request | No | Yes | Yes | Yes | Yes | | Lock and unlock conversations; comment and react on locked ones | No | Yes | Yes | Yes | Yes | | Push branches and new tags; upload Git LFS objects | No | No | Yes | Yes | Yes | | Create, update and delete branches and refs through the API; commit files in the web editor or API | No | No | Yes | Yes | Yes | | Open pull requests | No | No | Yes | Yes | Yes | | Approve or request changes on a pull request (not your own) | No | No | Yes | Yes | Yes | | Merge pull requests, add them to or remove them from a merge queue, turn [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge) on or off | No | No | Yes | Yes | Yes | | Resolve review threads | No | No | Yes | Yes | Yes | | Run, cancel and re-run workflows | No | No | Yes | Yes | Yes | | Report check runs and commit statuses (`POST /repos/{owner}/{repo}/check-runs`, `POST /repos/{owner}/{repo}/statuses/{sha}`) | No | No | Yes | Yes | Yes | | Create, edit and delete releases and their assets; see draft releases | No | No | Yes | Yes | Yes | | Create and delete deployments; report deployment statuses | No | No | Yes | Yes | Yes | | Update a pull request's branch from its base (`PUT .../pulls/{number}/update-branch`) | No | No | Yes | Yes | Yes | | Write Git objects through the API (blobs, trees, commits, tags, merges) | No | No | Yes | Yes | Yes | | Trigger `repository_dispatch` (`POST /repos/{owner}/{repo}/dispatches`) | No | No | Yes | Yes | Yes | | List Actions variables and environment variables | No | No | Yes | Yes | Yes | | Start agent sandboxes | No | No | Yes | Yes | Yes | | Move or delete existing tags | No | No | No | Yes | Yes | | Edit or delete other people's comments | No | No | No | Yes | Yes | | Dismiss a review on someone else's pull request (people only) | No | No | No | Yes | Yes | | Change settings: name, description, visibility, default branch | No | No | No | No | Yes | | Archive, unarchive and delete the repository | No | No | No | No | Yes | | Transfer the repository to your own account or an organization you own or administer (people only; a personal repository only by its owner) | No | No | No | No | Yes | | Add, change and remove collaborators; invite by email | No | No | No | No | Yes | | Manage branch protection rules | No | No | No | No | Yes | | List, set and delete Actions secrets; set and delete variables | No | No | No | No | Yes | | Create, change and delete environments, their deployment branch policies and secrets (people only) | No | No | No | No | Yes | | Set and delete environment variables | No | No | No | No | Yes | | Set repository topics | No | No | No | No | Yes | | Change the repository's workflow permissions setting: the default `GITHUB_TOKEN` permissions and whether workflows may create and approve pull requests (people only) | No | No | No | No | Yes | | Delete other people's reactions | No | No | No | No | Yes | | Manage webhooks | No | No | No | No | Yes | | Run self-hosted runners for the repository (`diffinery runner`) | No | No | No | No | Yes | | Bypass branch protection: push past it, merge past unmet rules by asking to (`"bypass_rules": true`), or merge without the merge queue by asking to (`"bypass_merge_queue": true`), unless the rule turns on **Do not allow administrators to bypass these rules** (people only for merges) | No | No | No | No | Yes | | Commit past push protection with `git push -o allow-secrets=`, or with `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits` (people only) | No | No | No | No | Yes | Some actions have extra conditions on top of the role: - Branch protection can block pushes and merges for every role below admin, and for admins too when the rule enforces administrators. An admin's merge only skips unmet rules when they ask for it explicitly (**Merge without waiting for requirements (bypass)** in the web UI, `"bypass_rules": true` in the API, `diffinery pr merge --admin`). Each flag covers only what it names: `bypass_rules` skips unmet rules (including a merge commit on a branch that requires linear history), and `bypass_merge_queue` skips only the merge queue. Every bypass, including a bypassed linear-history rule or merge queue, is written to the audit log as `pull_request.merge_bypassed`. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection). - The `read` role's actions that change something (opening issues, commenting, reacting, comment-only reviews, watching) need a signed-in account. Anonymous visitors to a public repository can only read, and a `read`-scoped token can only read; see [Anonymous access](#anonymous-access) and [Token scopes cap your role](#token-scopes-cap-your-role). - An archived repository refuses every write with `403 repository is archived (read-only)`, for every role and from the web UI, Git, the REST API and MCP alike: pushes, issues, comments, reviews, settings, collaborators, branch protection, secrets, variables and webhooks. Only unarchiving, deleting the repository, watching it, and linking a container image to it still work. See [Archive a repository](https://diffinery.com/docs/repositories/overview#archive-a-repository). - You can't approve or request changes on your own pull request, and you can't dismiss reviews on it. - Pull requests opened by agents always need at least one approval from a person. Container images have their own permissions. An image linked to a repository follows that repository's roles: `read` pulls, `write` pushes, `admin` deletes. In an organization, every member can create images and pull the organization's images that aren't linked to a repository, but an image that isn't linked to a repository can be pushed to and managed only by the member who created it and the organization's owners and admins. See [Container registry](https://diffinery.com/docs/packages/container-registry). ## Where your role comes from Diffinery works out your role on each request, so a change takes effect immediately for the browser, Git and every token you hold. Your role on a repository is the highest of: | Source | Role | | --- | --- | | You own the repository (a personal repository) | admin | | You are an owner or admin of the organization that owns it | admin | | You are a member of the organization that owns it | the organization's base permission: `none`, `read`, `write` or `admin` (`read` for new organizations) | | You created it in an organization as a member | admin (you are added as an `admin` collaborator) | | You were added as a collaborator on the repository (**Settings → Collaborators**, an accepted repository invite, or `PUT /repos/{owner}/{repo}/collaborators/{username}`) | the role you were given | | The repository is public | read | | You are a site administrator | admin on every repository | Because the highest role wins, a collaborator entry can raise an organization member's access above the base permission but can't lower it. Accounts that are pending or disabled have no role anywhere. ### Collaborators Repository admins manage collaborators under **Settings → Collaborators** (`/acme/website/settings/access`): - **Add people**: enter a **Username or email** (a person, an agent or an email address), pick a **Role** (**Read**, **Triage**, **Write**, **Maintain** or **Admin**; default **Write**) and click **Add**. A person's username gets an invitation, and they get the role once they accept it under **Settings → Invitations** ("Invited diffy with write access. They get access once they accept."). Agents, people who already are direct collaborators, members of the organization that owns the repository and you yourself get the role immediately ("diffy now has write access. We let them know."). An email address gets an invitation link; see [Invitations](https://diffinery.com/docs/accounts/invitations#add-people-to-a-repository). - Change a collaborator's role with the **Role** menu and **Save**, or click **Remove** and confirm. The person gets an email when their access is granted, changed or removed, and an in-app notification when someone adds or invites them with **Add people**. With the API: ```sh # Invite diffy with the triage role, or change their role if they already are a collaborator # (GitHub's "pull" and "push" also work: they mean read and write) curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"permission": "triage"}' # Remove diffy curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ -H "Authorization: Bearer dfy_..." ``` Without a body, `PUT` gives `write`. `PUT` answers `201 Created` with the invitation when it invited the person, and `204 No Content` when the role applied at once (the same cases as **Add people**). Both need admin on the repository and a token with the `admin` scope. Any other `permission` than `pull`, `read`, `triage`, `push`, `write`, `maintain` or `admin` answers `422 permission must be pull, triage, push, maintain or admin`, and an organization's login answers `422` too: add its members instead, or transfer the repository to it. ## Organization roles Organizations have their own three roles, which map onto repository roles for every repository the organization owns. Members get the organization's base permission, which owners choose: | Organization role | Role on organization repositories | Can also | | --- | --- | --- | | member | The base permission: `none`, `read`, `write` or `admin` | Create repositories (and get `admin` on the ones they create) and container images in the organization | | admin | admin | Manage members (except owners), invites, agents and organization secrets | | owner | admin | Everything an admin can, plus manage owners and the organization's **Settings** tab (name, display name, base permission, picture) | New organizations start with a base permission of `read`; organizations that existed before the setting was added use `write`. Owners change it on the organization's **Settings** tab (**Base permission**) or with `PATCH /api/v3/orgs/{org}` and `{"default_repository_permission": "write"}`. With `none`, members don't see the organization's private repositories unless they are collaborators on them or created them. See [Base permission](https://diffinery.com/docs/accounts/organizations#base-permission) for details. ## Site administrators Site administrators run the instance. They have admin on every repository, can act as an owner of every organization, and see a **Site administration** group in **Settings** with **People and invitations**, **Email** and **Audit log**. Accounts become site administrators when their email address or username is listed in the instance's `BOOTSTRAP_ADMINS` setting when the account is created. See [Site administration](https://diffinery.com/docs/admin/site-administration). A site administrator's tokens are still capped by their scopes, like everyone's. ## Anonymous access Without signing in or sending a token, you have `read` on public repositories, but only to look: you can browse them, clone and fetch over Git, download LFS objects and call the API's read endpoints (`GET` and `HEAD`) for those repositories. Anonymous callers can't change anything, as on GitHub: opening an issue, commenting, reacting, watching or any other request that isn't a read gets `401 authentication required`, even on a public repository. Everything else, including MCP, needs an account. | Situation | Response | | --- | --- | | No credentials, private repository, or one that doesn't exist | Git, Git LFS and the web UI: `401 authentication required` (Git then asks for a username and password). REST API: `404`, as on GitHub. A signed-out caller can't tell the two apart. | | No credentials, any change to a public repository (for example `POST /repos/{owner}/{repo}/issues`) | `401 authentication required` | | Signed in or token, but no access to a private repository | `404 repository not found` (Diffinery doesn't reveal that it exists) | | Access, but the role is too low | `403 access required`, for example `403 write access required` | ## Token scopes cap your role A personal access token never does more than its scope allows, whatever your role is. The cap is: | Token scope | Highest effective role | | --- | --- | | `read` | read | | `write` | maintain | | `admin` | admin | Your effective role through a token is the lower of your own role and the cap. If a token has several scopes, the highest one sets the cap. For example, an organization owner using a `write` token can push and merge in the organization's repositories but can't change their settings, secrets or branch protection; for that they need an `admin` token or the web UI. A `read` token goes further than the cap: it can't make any change at all. Any repository request other than `GET` or `HEAD` gets `403 this token needs the write scope`, including the changes the `read` role allows (opening issues, commenting, reacting, comment-only reviews). `git fetch` and Git LFS downloads keep working, because they only read even though they use `POST`. Browser sessions (signing in with a passkey) aren't capped: in the web UI you always act with your full role. Account-level actions check the scope directly, and most also refuse tokens restricted to repositories (`403 this token is limited to repositories and can't change account settings`): | Action | Needs | | --- | --- | | Create a repository (`POST /api/v3/user/repos`, `POST /api/v3/orgs/{org}/repos`) | a `write` or `admin` token that isn't restricted to repositories, or a browser session | | Mark notifications read, change notification settings, send a test email | a `write` or `admin` token that isn't restricted to repositories, or a browser session | | Watch or unwatch a repository | a `write` or `admin` token, or a browser session | | Upload or remove your profile picture or an organization's (`PUT`/`DELETE /api/v3/user/avatar`, `/api/v3/orgs/{org}/avatar`; organization owners only) | a `write` or `admin` token that isn't restricted to repositories, or a browser session | | Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | an `admin` token or a browser session | | Revoke a token, create an organization, invite people, manage organization secrets, create agents and agent tokens, disable agents | an `admin` token that isn't restricted to repositories, or a browser session | | Send every email template (site administrators) | an `admin` token that isn't restricted to repositories, or a browser session | | Change an organization's display name or base permission (`PATCH /api/v3/orgs/{org}`, owners only) | an `admin` token that isn't restricted to repositories, or a browser session | | Change your username, display name or appearance | a browser session; there is no API | | Add or remove passkeys, sign out other browsers | a browser session; tokens can't do this | A token whose scope is too low gets `403 this token needs the write scope` or `403 this token needs the admin scope`. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens) for how to create tokens. ## Repository-restricted tokens A token can be restricted to a list of repositories when you create it. With such a token, every other repository behaves as if you had no access: you get `404 repository not found`, even for repositories you could otherwise read. Inside the listed repositories, the usual role and scope cap apply. Restricting a token doesn't limit account-level reads such as `GET /api/v3/user`, but a restricted token can't make account-level changes: creating repositories or organizations, inviting, managing agents, organization secrets, notification settings or profile pictures, or revoking tokens. Those answer `403 this token is limited to repositories and can't change account settings` (profile pictures have their own message). ## Agents Agents are accounts that act for a person or an organization. Their role on a repository is the lowest of: 1. **The owner's role.** A personal agent can never do more than the person who owns it. An organization agent has a role only on that organization's repositories (as an organization admin would) and none anywhere else. 2. **The agent's cap.** By default an agent's maximum role is `write`. The owner can choose another **Maximum role** when creating the agent. If the agent was added as a collaborator on a repository, that collaborator role replaces the maximum role for that repository. 3. **The token's scope**, as for any token. On top of the role, these guardrails apply to agents everywhere (Git, the REST API, MCP and the web UI): - Agents can't push to the default branch or any protected branch, unless a protection rule turns on **Allow agents to push directly** (`agentsMayPush`). They get "agents may not push to protected or default branches; open a pull request". - Agents can't merge into the default branch or a protected branch, unless the rule turns on **Allow agents to merge pull requests** (`agentsMayMerge`). - A pull request opened by an agent needs at least one approving review from a person. Only people's approvals count toward required approvals: approvals from agents and from workflow tokens never do. - Agents can never bypass push protection, and the merge-protection bypass for admins (`"bypass_rules": true`) applies only to people. - Check runs and commit statuses reported by agents never satisfy a required status check. - Agents can't dismiss reviews. - Through the API, agents can't change branch protection, delete repositories, or manage environments. Whatever their role, they can't create, update or delete Actions secrets (repository, environment or organization), and can't create, change or delete Actions variables (repository or environment); they get `403 agents cannot manage secrets` or `403 agents cannot manage variables`. An agent with `write` or higher can still list variables. - A workflow run started by an agent only gets an environment's secrets when the environment is limited to protected branches. Tags created or moved by an agent never match an environment's tag patterns. See [Environments](https://diffinery.com/docs/actions/environments). - Commits an agent makes carry a `diffinery-agent` header naming the agent and the person it acts for. See [Agents](https://diffinery.com/docs/agents/overview) for creating and managing agents. ## Job tokens and sandbox tokens Workflows and sandboxes get short-lived tokens of their own: | Token | Prefix | Where it comes from | What it can do on the repository | | --- | --- | --- | --- | | Job token | `djt_` | Each workflow job gets one as `GITHUB_TOKEN` (also `DIFFINERY_TOKEN`) | Act as `diffinery-actions[bot]` with what the workflow's `permissions:` grant (see below). Container images follow the job's `permissions: packages`: it pulls its own repository's images, its owner's images not linked to a repository and public images, and pushes only to images linked to its own repository (creating them, linked, on first push) | | Sandbox token | `dst_` | Each agent sandbox gets one | Act as the person or agent that started the sandbox, capped at `write`, on that repository | | Runner token | `drn_` | A self-hosted runner gets one when it registers with a repository admin's registration token | Claim that repository's queued jobs, with the labels it registered. Nothing else: no reads, pushes or other API calls. It lasts until the runner is removed, or until its one job for an `--ephemeral` runner. See [Manage runners](https://diffinery.com/docs/actions/runners#manage-runners) | Job and sandbox tokens can also read other public repositories (as anyone can), but no other private repository. They stop working when the job completes or the sandbox closes. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables) and [Sandboxes](https://diffinery.com/docs/agents/sandboxes). ### What a job token may do A job token follows GitHub's `permissions:` model. Its permissions come from the job's `permissions:`, else the workflow's, else the repository's default: | Repository default | Permissions without `permissions:` | | --- | --- | | Read (the default for new repositories) | `contents` and `packages` read, nothing else | | Read and write (repositories created before the setting existed) | Write on every permission except `id-token`, which stays off | An admin changes the default, and whether workflows may create and approve pull requests (off by default), on the repository's **Settings → Actions** tab or with `PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow`. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions) for the details. On top of its permissions: - Each endpoint needs its permission (`issues`, `pull-requests`, `contents`, `statuses`, `checks`, `deployments`, `actions` and so on). A request without it gets `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: : )`. Reads of a public repository are always allowed. - The token's role is at most `write`, so branch protection always applies to it. Repository settings, collaborators, secrets, variables, webhooks, runners and branch protection are out of its reach (`403 this endpoint is not available to a workflow's GITHUB_TOKEN`). - It never creates, changes or deletes files under `.github/workflows/` or `.diffinery/workflows/`, by push, API commit or merge. The refusal reads: refusing to allow a GitHub App to create or update workflow `` without `workflows` permission. - It can't open, approve or request changes on pull requests unless the repository allows it, and even then its approvals don't count toward required approvals. - A reusable workflow never gets more than its caller. - A run whose code came from an agent (an agent triggered it, pushed the branch last, or opened the pull request, and any re-run of such a run, whoever starts it, or `workflow_run` run that follows it) acts as an agent, so the agent guardrails apply: its token can't push or merge to the default branch or a protected branch. ## How permissions differ from GitHub - The five repository roles match GitHub's names. Diffinery's `maintain` adds the ability to move or delete tags and to edit other people's comments; repository settings stay with `admin`. - Opening a pull request needs `write`: its head branch must be a branch in the same repository. - Organization members get the organization's base permission on every organization repository, as on GitHub, with the same values (`none`, `read`, `write`, `admin`) and the same default for new organizations (`read`). Organization owners and admins get `admin`. There are no teams or custom roles. - Personal access tokens have three scopes (`read`, `write`, `admin`) instead of GitHub's fine-grained permissions; the scope caps your role. - `GITHUB_TOKEN` in a workflow follows GitHub's `permissions:` model, but new repositories default to read-only tokens, the token never administers the repository or edits workflow files, and code that came from an agent runs under the agent rules. - Agents are a separate account type with their own caps and guardrails. --- # Personal access tokens > Create, scope, restrict, use and revoke Diffinery personal access tokens (dfy_) for Git, the REST API, the CLI and MCP clients. Source: https://diffinery.com/docs/accounts/tokens A personal access token lets Git, the `diffinery` CLI, scripts and MCP clients act as you. Tokens start with `dfy_`. You need one for anything outside the browser: passkeys only sign in to the web UI. ## What a token is | Property | Value | | --- | --- | | Format | `dfy_` followed by 40 random characters (letters, digits, `-` and `_`) | | Shown | Once, when you create it. Diffinery stores only a hash. | | Prefix | The first 10 characters (for example `dfy_Ab12Cd`) are kept so you can recognize the token in lists and emails | | Scopes | `read`, `write` or `admin` (default `write`) | | Repository restriction | Optional list of repositories; default is every repository you can access | | Lifetime | 1 to 366 days (default 90) | | Last used | Recorded at most once an hour | A token acts as your account with your current role. If your access changes (you leave an organization, a collaborator role is removed), the token's reach changes on the next request. If your account is disabled, all your tokens are revoked. ## Scopes The scope caps the role a token can use on a repository. Your effective role through a token is the lower of your own role and this cap. | Scope | Highest role | Typical use | | --- | --- | --- | | `read` | read | Clone and fetch, download Git LFS objects, read the API, CI that only downloads | | `write` | maintain | Push, issues, pull requests, merging, running workflows; the right choice for most Git and CLI use | | `admin` | admin | Everything you can do, including repository settings, collaborators, branch protection, secrets, webhooks, self-hosted runners and revoking tokens. No token can create tokens or agents | The web form describes them as: "read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings." ### Read tokens never write A `read` token only reads. Every request that changes something in a repository (any method other than `GET` or `HEAD`) is refused with `403 this token needs the write scope`, even actions the `read` role itself allows, such as opening an issue, commenting, reacting or leaving a comment-only review. Two Git operations use `POST` but only read, and keep working with a `read` token: `git fetch` and `git clone` (`git-upload-pack`) and Git LFS downloads (an LFS batch request for `download`). Pushing and LFS uploads need `write`. The scope is checked after Diffinery has found a repository you can see, and before your role on it. So a `read` token that tries to change a private repository you can't see gets `404 repository not found`, the same answer as any other request for it, rather than a scope error that would reveal the repository exists. Use a `write` token for anything that opens, comments, pushes or merges, and an `admin` token for repository settings. ### Account-level actions Actions that change your account or an organization, rather than a single repository, check the token's scope directly. Most of them also refuse tokens that are restricted to repositories, with `403 this token is limited to repositories and can't change account settings`. Browser sessions can do all of them. | Action | Token needs | Restricted tokens | | --- | --- | --- | | Create a repository (`POST /api/v3/user/repos`, `POST /api/v3/orgs/{org}/repos`, `diffinery repo create`) | `write` or `admin` | Refused | | Mark notifications read (`PUT /api/v3/notifications`), change notification settings, send yourself a test email | `write` or `admin` | Refused | | Watch or unwatch a repository (`PUT`/`DELETE /api/v3/repos/{owner}/{repo}/subscription`) | `write` or `admin` | Allowed for the listed repositories | | Upload or remove a profile picture (`/api/v3/user/avatar`, `/api/v3/orgs/{org}/avatar`) | `write` or `admin` | Refused | | Change an organization's display name or base permission (`PATCH /api/v3/orgs/{org}`, owners only) | `admin` | Refused | | Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | No token can: a browser session only | Refused | | Revoke a token (`DELETE /api/v3/diffinery/tokens/{id}`, `diffinery token revoke`) | `admin` | Refused | | Create an organization (`POST /api/v3/user/orgs`) | `admin` | Refused | | Invite people (`POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations`, `diffinery invite`) | `admin` | Refused | | Accept or decline an invitation you received (`PATCH`/`DELETE /api/v3/user/repository_invitations/{id}`, `PATCH /api/v3/user/memberships/orgs/{org}`) | `write` or `admin` | Refused | | List, set and delete organization secrets (`/api/v3/orgs/{org}/actions/secrets`, `diffinery secret ... --org`) | `admin` | Refused | | Create an agent or a token for an agent (`POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens`, `diffinery agent create`) | No token can: a browser session only | Refused | | Disable an agent (`DELETE /api/v3/diffinery/agents/{login}`) | `admin` | Refused | | Send every email template (site administrators) | `admin` | Refused | | Manage passkeys and browser sessions; change your username, display name or appearance | Not possible with a token; use the browser | | A token whose scope is too low gets `403 this token needs the write scope` or `403 this token needs the admin scope`. Watching a repository with a `read` token gets `403 this needs a write or admin token`, and changing a profile picture with the wrong token gets `403 changing a profile picture needs a write or admin token that is not limited to repositories`. See [Permissions](https://diffinery.com/docs/accounts/permissions) for what each role allows. ## Create a token Creating a token needs a sign-in from the last 2 hours. If yours is older, Diffinery asks you to [Confirm it's you](https://diffinery.com/docs/security/overview#confirming-its-you) with your passkey first. ### In the web UI 1. Go to **Settings → Access tokens** (`/settings/tokens`). 2. Under **Create a token**, fill in: - **Name**: what the token is for, for example `laptop`, `ci` or `claude-code` (up to 80 characters). - **Expires in (days)**: 1 to 366. The default is 90. - **Scope**: **Read**, **Write** (default) or **Admin**. - **Restrict to repositories** (optional): pick one or more repositories. Leave it empty for every repository you can access. 3. Click **Create token**. The page shows **Your new token** with a copy button. Copy it now: it is never shown again. Diffinery also emails you "A personal access token was added to your account". Your first token has to be created in the web UI; after that you can create more from the CLI or API with an `admin` token. ### With the CLI You don't need to create a token for the CLI itself: `diffinery login` signs it in through your browser and creates its own token (see [Install and sign in to the CLI](https://diffinery.com/docs/accounts/cli-sign-in)). Install it with: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login ``` No token can create another token, so on a deployed instance `diffinery token create` gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`: create tokens in the web UI. The command works against a local development server (see [Deploy](https://diffinery.com/docs/self-hosting/deploy)): ```sh # A write token for CI, valid for 30 days, only for acme/website diffinery token create --name ci --scope write --expires-days 30 -R acme/website ``` | Option | Description | | --- | --- | | `-n, --name NAME` | Token name. Required. | | `-s, --scope SCOPE` | `read`, `write` (default) or `admin`. Repeatable, or comma-separated. | | `-e, --expires-days DAYS` | Days until it expires, 1 to 366 (default 90). | | `-R, --repo OWNER/NAME` | Restrict the token to this repository. Repeatable. | | `--json` | Print `token`, `id` and `expiresAt` as JSON. | The token is printed on standard output (so you can pipe it), and the ID and expiry date on standard error. ### With the API `POST /api/v3/diffinery/tokens`, from a browser session (the session cookie you get by signing in with a passkey). No token can create a token, whatever its scope, so a leaked or limited token can never turn itself into a wider or longer-lived one: ```sh curl -X POST https://diffinery.com/api/v3/diffinery/tokens \ -H "Cookie: __Host-dfy_session=..." \ -H "Content-Type: application/json" \ -d '{"name": "ci", "scopes": ["write"], "repositories": ["acme/website"], "expires_in_days": 30}' ``` | Field | Type | Description | | --- | --- | --- | | `name` | string | Token name | | `scopes` | array of strings | One or more of `read`, `write`, `admin`. Omit it (or send `null`) for the default `["write"]`. An empty list, a value that isn't a list, or an unknown name returns `422 scopes must be a non-empty list of: read, write, admin`. Repeated names are stored once. | | `repositories` | array of strings | `owner/name` of each repository to restrict to. Omit for every repository. You can only list repositories your account can see: a repository that doesn't exist, or a private one you have no access to, returns `422 unknown repository ` (the same message for both, so the answer doesn't reveal private repositories). | | `expires_in_days` | number | 1 to 366 (values outside are clamped). Default 90. | Response (`201 Created`): ```json { "token": "dfy_...", "id": "9b2f0c1d4e5a6b7c8d9e0f1a2b3c4d5e", "expiresAt": "2027-01-11T09:30:00.000Z" } ``` Any request authenticated with a token (personal, agent, job or sandbox) gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. The CLI checks scope names before sending and exits with `unknown scope "" (read, write or admin)`. ## Restrict a token to repositories A restricted token only works on the repositories you picked. Any other repository answers `404 repository not found`, as if it didn't exist, even if you could normally read it. Inside the listed repositories the usual role and scope cap apply. Repository lists (`GET /api/v3/user/repos`, `/users/{login}/repos`, `/orgs/{org}/repos` and the MCP `list_repos` tool) show only the listed repositories, and reading notifications, which cover every repository, answers `403 this token is limited to repositories and can't read notifications`. Other account-level reads (such as `GET /api/v3/user` or listing your tokens) still work, but account-level changes don't: creating repositories or organizations, inviting people, managing agents, organization secrets, notification settings and profile pictures, and creating or revoking tokens all answer `403 this token is limited to repositories and can't change account settings` (profile pictures have their own message). See [Account-level actions](#account-level-actions). Restrict tokens you hand to CI systems, agents and third-party tools to the repositories they need. ## List your tokens The **Active tokens** table on **Settings → Access tokens** shows each token's **Name** and prefix, **Scopes**, **Repositories** (or "all"), **Expires** date and **Last used** time ("never" if it hasn't been used). Revoked tokens aren't listed. ```sh diffinery token list ``` ```sh curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/diffinery/tokens ``` The API returns an array of objects with `id`, `name`, `prefix`, `scopes`, `repoIds` (`null` when unrestricted), `expiresAt`, `lastUsedAt` and `createdAt`. ## Expiry and reminder emails Every token expires; you can't create one that doesn't. After the expiry date, requests with it fail with `401 invalid, expired or revoked token`. About a week before a token expires, Diffinery emails you "Your token "" expires on ", with the token's prefix and a **Create a new token** button. The reminder is sent once per token, and only for tokens that were created to last more than 8 days. To replace a token, create a new one, swap it in wherever the old one is used (Git credentials, CI secrets, MCP client settings), then revoke the old one. ## Revoke a token - **Web UI:** click **Revoke** next to the token on **Settings → Access tokens** and confirm. You see "Token revoked." - **CLI:** `diffinery token revoke ` (the ID is in `diffinery token list`). - **API:** `DELETE /api/v3/diffinery/tokens/{id}`, which returns `204`. Revoking through the API or CLI needs an `admin` token that isn't restricted to repositories. A revoked token stops working immediately and can't be restored. Anything still using it gets `401 invalid, expired or revoked token`. ## Use a token ### Git over HTTPS Use any username and the token as the password: ```sh git clone https://diffinery.com/acme/website.git # Username: diffy (anything works) # Password: dfy_... ``` To avoid typing it, let the CLI answer Git's credential prompts for `diffinery.com`: ```sh diffinery login # paste the token once; saved in ~/.config/diffinery/config.json (mode 0600) diffinery auth setup-git # git uses the saved token for https://diffinery.com ``` `diffinery auth setup-git` configures `credential.https://diffinery.com.helper` in your global Git config. You can also use Git's own helpers, for example `git config --global credential.helper store`, or your operating system's keychain helper. See [Git](https://diffinery.com/docs/repositories/git). ### curl and other HTTP clients Send the token as a bearer token: ```sh curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/user ``` Diffinery accepts these `Authorization` forms: | Form | Example | | --- | --- | | Bearer | `Authorization: Bearer dfy_...` | | Token (GitHub style) | `Authorization: token dfy_...` | | Basic | `Authorization: Basic base64(username:dfy_...)`. The password is used; the username can be anything. If the password is empty, the username is used as the token. | Git uses Basic authentication; the CLI, MCP clients and most scripts use Bearer. They are equivalent. A `Basic` value that isn't valid base64 is refused with `401 malformed basic credentials`. If a request carries the `Authorization` header twice (Git LFS does this when you set `http.extraHeader` and it also sends its own header), Diffinery uses the first one. ### The CLI `diffinery login` signs the CLI in through your browser and saves the server URL and the token it gets in `~/.config/diffinery/config.json` (or under `$XDG_CONFIG_HOME`). Those tokens are listed here with a **CLI sign-in** badge and named after the computer; see [Install and sign in to the CLI](https://diffinery.com/docs/accounts/cli-sign-in). `diffinery login --token dfy_...` saves a token you created instead. The environment variables `DIFFINERY_TOKEN` and `DIFFINERY_URL` override the saved values, which is handy in CI: ```sh export DIFFINERY_TOKEN=dfy_... diffinery whoami ``` `diffinery logout` removes the saved token, and revokes it when a CLI sign-in created it (a token you pasted may be in use elsewhere, so it's left alone). See [CLI reference](https://diffinery.com/docs/reference/cli). ### MCP clients Point the client at `https://diffinery.com/mcp` with the token in an `Authorization` header. `diffinery mcp-config` prints a ready-made snippet: ```json { "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" } } } } ``` By default the snippet refers to `${DIFFINERY_TOKEN}` from the environment; `diffinery mcp-config --show-token` embeds the saved token instead. For an AI agent, create an agent account with its own token rather than giving it yours. See [MCP](https://diffinery.com/docs/agents/mcp) and [Agents](https://diffinery.com/docs/agents/overview). ## Agent tokens Agents have their own `dfy_` tokens, created by their owner under **Settings → Agents** (default lifetime 30 days there), with `diffinery agent create`, or with `POST /api/v3/diffinery/agents/{login}/tokens`. Creating them through the API or CLI needs an `admin` token that isn't restricted to repositories; the `scopes` field follows the same rules as for personal tokens (default `["write"]`, `422` for an empty list or unknown name). They work like personal tokens but act as the agent, within its policy. Their creation and expiry emails go to the agent's owner (or, for an organization agent, the organization's owners and admins). See [Agents](https://diffinery.com/docs/agents/overview). ## Job tokens and sandbox tokens Two other kinds of token exist. You don't create them yourself: - **Job tokens** (`djt_`) are given to each workflow job as `GITHUB_TOKEN` and `DIFFINERY_TOKEN`. They act as `diffinery-actions[bot]` on the repository the job runs in, with what the workflow's `permissions:` grant, as on GitHub: each API endpoint needs its permission (`contents`, `issues`, `pull-requests`, `statuses`, `checks` and so on), and a request without it gets `403 Resource not accessible by integration`. Without `permissions:` a job gets the repository's default, which is read-only (`contents` and `packages` read) for new repositories; an admin changes it with the repository's workflow permissions setting (`PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow`). A job token's role never goes above write, so branch protection applies to it, and it never changes repository settings, secrets, variables, webhooks, runners or branch protection, or creates or changes workflow files. A run whose code came from an agent acts as an agent, so it can't push or merge to the default or protected branches. The token also reads other public repositories (not other private ones), uses the container registry according to `permissions: packages`, and stops working when the job finishes. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions) and [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). - **Sandbox tokens** (`dst_`) are given to each agent sandbox. They act as whoever started the sandbox, capped at write, on that repository, can also read other public repositories, and stop working when the sandbox closes. See [Sandboxes](https://diffinery.com/docs/agents/sandboxes). Self-hosted runners have tokens of their own too. A repository admin creates a **registration token** (`drg_`, valid for an hour), and each runner trades it for a **runner token** (`drn_`) that can claim that repository's jobs and nothing else: it can't read the repository, push or call any other API. Removing the runner ends its token. See [Manage runners](https://diffinery.com/docs/actions/runners#manage-runners). [Push protection](https://diffinery.com/docs/security/push-protection) blocks a push or commit that contains a job, sandbox or [agent task](https://diffinery.com/docs/agents/tasks) model token (`dmt_`), as it does for `dfy_` tokens, so one printed into a file or a commit message by a job or an agent isn't stored in the repository. ## Security emails about tokens | Email | Sent to | When | | --- | --- | --- | | A personal access token was added to your account | You | Every time a token is created for you, from the web UI, the CLI or the API | | A token was created for the agent | The agent's owner, or its organization's owners and admins | A token is created for an agent | | Your token "" expires on | The same people | About 7 days before expiry (once per token, for tokens with a lifetime over 8 days) | These can't be turned off. If you get one for a token you didn't create, revoke it right away. ## How tokens differ from GitHub - Diffinery has one kind of personal token with three scopes, plus an optional repository list. GitHub has classic tokens with many scopes and fine-grained tokens with per-permission settings. - Every token expires, after at most 366 days. - Tokens can only be created with the API or CLI when you already hold an `admin`-scoped token that isn't restricted to repositories; the first one comes from the web UI. - Git accepts a token as the password with any username, as on GitHub. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `401 invalid, expired or revoked token` | The token is unknown, expired or revoked, or your account is disabled or not yet active. Create a new token. | | `401 unrecognised token; create one under Settings → Access tokens` | The credential doesn't start with `dfy_` (or `djt_`, `dst_`, `drn_`), for example a GitHub token or a password. Use a Diffinery token from **Settings → Access tokens**. | | `401 authentication required` | No token was sent to a private repository. Configure Git credentials or add the `Authorization` header. | | `404 repository not found` | You have no access, or the token is restricted to other repositories. A `read` token that tries to change a private repository it can't see also gets this. | | `401 malformed basic credentials` | The `Authorization: Basic` value isn't valid base64. Encode `username:token` with base64, or send `Authorization: Bearer dfy_...` instead. | | `422 unknown repository ` when creating a token | The repository doesn't exist, or it is private and your account can't see it. Check the `owner/name` spelling and your access. | | `401 authentication required` on a public repository | You sent no token for a change (opening an issue, commenting, reacting, watching). Anonymous callers can only read. Send a `write` token. | | `403 write access required` (or another role) | Your role, or the token's scope, is too low. For `admin` actions, use an `admin` token. | | `403 this token needs the write scope` | A `read` token tried to change something. Use a `write` or `admin` token. | | `403 this token needs the admin scope` | Account-level actions such as inviting, creating organizations or agents, organization secrets, and creating or revoking tokens need an `admin` token. | | `403 this token is limited to repositories and can't change account settings` | The token has a repository list. Use a token without one, or the web UI. | | `422 scopes must be a non-empty list of: read, write, admin` | The `scopes` field was empty, not a list, or had an unknown name. | | `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens` | Tokens can't create tokens. Create it in the web UI. | | `403 this needs a write or admin token` | Watching or unwatching a repository needs a `write` or `admin` token. | | `403 changing a profile picture needs a write or admin token that is not limited to repositories` | Use a `write` or `admin` token without a repository restriction, or the web UI. | | The CLI adds "token invalid or expired; run `diffinery login`" | Run `diffinery login` with a new token. If `DIFFINERY_TOKEN` is set, it overrides the saved token. | | Git keeps asking for a password | Your credential helper has an old token. Run `diffinery auth setup-git`, or update the stored credential. | --- # Install and sign in to the CLI > Install the diffinery CLI with one command, then set up sign-in, git and gh with diffinery setup, through your browser or with a code on machines without one. No tokens to copy. Source: https://diffinery.com/docs/accounts/cli-sign-in The `diffinery` command-line client installs with one command and signs in through your browser: you click **Authorize** on diffinery.com and the CLI is signed in, with no token to copy or paste. For every command, see the [CLI reference](https://diffinery.com/docs/reference/cli). The short version, on Linux and macOS: ```sh curl -fsSL https://diffinery.com/install | bash diffinery setup ``` `diffinery setup` signs you in through your browser, lets git use the sign-in, signs in the GitHub CLI (`gh`) when you have it, and adds Diffinery's plugins to Claude Code when you have it ([CI status in Claude](https://diffinery.com/docs/connect/ci-status-in-claude)). The rest of this page explains each step. ## Install On Linux and macOS: ```sh curl -fsSL https://diffinery.com/install | bash ``` On Windows, in PowerShell: ```powershell powershell -c "irm https://diffinery.com/install.ps1 | iex" ``` What the script does: - Puts the CLI in `~/.diffinery` (`%USERPROFILE%\.diffinery` on Windows): the CLI itself in `lib/diffinery.mjs` and the `diffinery` command in `bin/`. - Adds `~/.diffinery/bin` to your `PATH` for new terminals, in the startup files of your shell and of any other shell you've set up: `~/.zshrc` for zsh; `~/.bashrc` and the login file bash reads (`~/.bash_profile`, `~/.bash_login` or `~/.profile`, whichever you have; a new `~/.bash_profile` on macOS or `~/.profile` on Linux when you have none) for bash; `~/.config/fish/conf.d/diffinery.fish` for fish; `~/.profile` for other shells. Each file gets one `# Diffinery CLI` line, once, however often you run the script. On Windows, it adds the folder to your user `PATH`. - Makes `diffinery` work right away, in the terminal you ran it in: when `~/.local/bin` (or `~/bin`) is already on your `PATH`, as it is for most people who installed Claude Code, it adds a `diffinery` link there. It never replaces a different file of that name. Without such a folder, the script prints an `export PATH=...` line to run, or open a new terminal. On Windows, the PowerShell window you ran it in is updated too. - In a workflow job, it adds `~/.diffinery/bin` to `GITHUB_PATH`, so the job's later steps can run `diffinery`. - Runs the CLI on your Node.js when it's version 22 or later. Otherwise it downloads the current Node.js LTS from `nodejs.org` for the CLI's own use, checks it against the SHA-256 that nodejs.org publishes, and keeps it in `~/.diffinery/node`. Your own Node.js setup isn't touched. On Alpine and other musl-based systems, install Node.js with the package manager (`apk add nodejs`) first. - Prints the version it installed and what to run next. | Variable | Effect | | --- | --- | | `DIFFINERY_INSTALL` | Install somewhere other than `~/.diffinery`. | | `DIFFINERY_NO_MODIFY_PATH=1` | Don't change any startup file or your `PATH`, and don't add the link. | The script downloads the CLI from the server it came from, so on your own Diffinery instance use `curl -fsSL https:///install | bash`. You can read the script first: open `https://diffinery.com/install` (or `/install.ps1`) in a browser. To update, run `diffinery upgrade`, or run the install command again. To uninstall, delete `~/.diffinery`, the `diffinery` link in `~/.local/bin` or `~/bin` if the script made one, and the `# Diffinery CLI` lines from your shells' startup files. The CLI is one JavaScript file, so you can also install it by hand next to any Node.js 22 or later: download `https://diffinery.com/-/cli/diffinery.mjs` and run it with `node diffinery.mjs`. ## Set up in one go ```sh diffinery setup ``` ```text ✓ Logged in to https://diffinery.com as diffy with write access until Jan 12, 2027 ✓ git will use diffinery for https://diffinery.com ✓ gh is signed in to diffinery.com Connect Claude Code, Codex, Cursor and other AI apps: https://diffinery.com/docs/connect ``` It does three things, each explained below: 1. Signs you in through your browser, as `diffinery login` does, unless the CLI is signed in already and its token still works. On a machine without a browser it shows a code instead, as `diffinery login --device` does. It never reads a token from standard input, so an AI agent can run it for you: you click **Authorize** (or enter the code it relays) yourself. 2. Lets git use the sign-in for `git clone`, `fetch` and `push`, as `diffinery auth setup-git` does. 3. Signs in the [GitHub CLI](https://diffinery.com/docs/agents/github-cli), as `diffinery auth setup-gh` does, when `gh` is installed. Without it, setup says so and finishes; run `diffinery auth setup-gh` after you install gh. Run it again whenever you like: it keeps a sign-in that works and changes nothing that's already set up. It takes the same `--url`, `--scope`, `--web` and `--device` options as `diffinery login`. When signing in gh fails, everything else is still set up and it exits with status 1. ## Sign in ```sh diffinery login ``` 1. The CLI opens your browser on **Authorize the Diffinery CLI** at diffinery.com. If you aren't signed in, sign in with your passkey first. If your last sign-in was more than 2 hours ago, Diffinery asks you to confirm it's you with your passkey, as it does before creating any token. 2. The page shows the computer asking (its name, operating system and CLI version) and the access it asks for. Check that it's the computer you're on, and pick the **Access**: | Access | Allows | | --- | --- | | **Read** | Clone, and view repositories, issues, pull requests and runs. | | **Write** (default) | Also push, open and merge pull requests, comment, and run workflows (up to the maintain role). | | **Admin** | Everything you can do, including settings, secrets and variables. | 3. Click **Authorize**. The browser says "You're signed in", and the terminal prints `✓ Logged in to https://diffinery.com as diffy with write access until Jan 12, 2027`. Click **Cancel** instead and the CLI stops with `error: the sign-in was cancelled in the browser`. To ask for admin access up front (for `diffinery secret set`, for example), run `diffinery login --scope admin`. The page still preselects **Write** and says the CLI asked for Admin: choose **Admin** yourself. A command that needs more access than you signed in with says which `diffinery login --scope ...` to run. Then let Git use the same sign-in for `git clone`, `fetch` and `push` to diffinery.com: ```sh diffinery auth setup-git ``` ### On a machine without a browser Over SSH, in a container or on any machine without a display, sign in with a code: ```sh diffinery login --device ``` ```text Your one-time code: WDJB-MJHT Open https://diffinery.com/login/device in a browser on any device, sign in, and enter the code. Waiting for you to approve (Ctrl+C to cancel)… ``` Open `https://diffinery.com/login/device` on your laptop or phone, enter the code, check the details (the computer's name, and where and when the code was asked for), and click **Authorize**. When the code was asked for from another country than the one your browser is in, the page says so in red; from another network, it notes that too. The CLI finishes on its own within a few seconds. The code works for 10 minutes. The CLI uses this way by itself when it sees no display (no `DISPLAY` or `WAYLAND_DISPLAY` on Linux, or an SSH session); `--web` forces the browser way. ### Scripts and CI For scripts and CI, use a [personal access token](https://diffinery.com/docs/accounts/tokens) instead: ```sh export DIFFINERY_TOKEN=dfy_... # used by every command, nothing saved diffinery login --token dfy_... # or save it in the config file echo "$TOKEN" | diffinery login --with-token ``` ## Your CLI sign-ins Each sign-in gives the CLI its own token, named after the computer (for example **Diffinery CLI on diffys-laptop**). It's listed under **Settings → Access tokens** with a **CLI sign-in** badge, and Diffinery emails you about it like any new token. - It lasts 90 days. Diffinery emails you before it expires; run `diffinery login` again to get a new one. - `diffinery logout` revokes it on the server and removes it from the computer. Signing in again on the same computer revokes the token it replaces. - Revoke it under **Settings → Access tokens** to sign a lost or old computer out. The CLI saves the server URL and the token in `~/.config/diffinery/config.json` (or under `$XDG_CONFIG_HOME`), readable only by you: the folder is set to `0700` and the file to `0600` each time it is saved. `DIFFINERY_TOKEN` and `DIFFINERY_URL` override the saved values. The server URL must be `https://`, since the token goes with every request; plain `http://` works only for this computer (`localhost`, `127.0.0.1`, `[::1]`), such as a local development server, and anything else fails with `refusing http://: tokens would be sent unencrypted`. ## How it stays safe - **No secret in a link.** The browser sign-in uses a one-time code that Diffinery sends only to the CLI on your own computer (`http://127.0.0.1:`), and it works only together with a secret the CLI never lets out of its process (PKCE, as in OAuth for native apps). A sign-in link someone sends you can't sign their computer in as you: the code would go to your own computer. - **Codes are typed, never linked.** A device code is never part of a URL, so nobody can send you a link that approves their code. Only approve a code that your own terminal shows. If someone asks you to enter a code for them, don't. The page warns when the code came from another country or network, and never preselects **Admin**, whatever the CLI asked for. - **Short-lived and single-use.** Browser codes expire after 2 minutes and device codes after 10, each works once, and Diffinery stores only their hashes. The token is created when the CLI collects it and is never shown in the browser. - **The same rules as tokens.** Approving needs a passkey sign-in from the last 2 hours, sign-ins are in the audit log (`token.create`), and a CLI token is limited by the access you chose like any token. ## Troubleshooting | Problem | What to do | | --- | --- | | `command not found: diffinery` after installing | Open a new terminal, or run the `export PATH=...` line the installer printed. Run the install command again to check: it says which startup files put `~/.diffinery/bin` on your `PATH`. | | The browser doesn't open | Copy the link the CLI prints into a browser on the same computer, or use `diffinery login --device`. | | "This sign-in link is incomplete" | The page was opened without the CLI's details. Run `diffinery login` again and use the page it opens. | | "That code isn't valid or has expired" | Check the code in your terminal. Codes last 10 minutes; run `diffinery login --device` again for a new one. | | `error: diffinery needs Node.js 22 or later` | The `node` on your `PATH` is older. Run the install command again: it installs a Node.js for the CLI. | | `403 this token needs the admin scope` | Run `diffinery login --scope admin` and choose **Admin** on the authorize page. | --- # Notifications > In-app notifications and email from Diffinery: every email group and template, settings, watching, thread subscriptions, unsubscribe links, headers and the API. Source: https://diffinery.com/docs/accounts/notifications Diffinery tells you about activity in two places: the notifications list in the web UI, and email. Both follow GitHub's rules: you hear about issues and pull requests you take part in, everything in repositories you watch, and workflow runs you started. This page explains what you get, how to change it, and every email Diffinery can send. ## The notifications list and notification settings Two pages share the name, and they do different things: | Page | Where | What it is for | | --- | --- | --- | | **Notifications** (the list) | The bell in the header, or `/notifications` | Reading what happened: your recent notifications, marking them read | | **Notification settings** | **Settings → Notifications** (`/settings/notifications`), or the gear button on the list | Choosing what you hear about, in the list and by email; watched repositories; your recent emails | The list only shows what your settings let through. To get fewer (or more) entries in the list, change the **On Diffinery** column in the settings; to change your email, change the **Email** column. ## The notifications list The bell in the header opens **Notifications** (`/notifications`). It has two views, **Unread** and **All**, and shows your 100 most recent notifications. Each entry has an icon for its kind (a pull request, an issue, a comment, a failed run in red, an organization, a repository or an invitation), a title such as `diffy commented on acme/website#42: Cache build artifacts`, its kind (for example `issue comment`, `pull request review` or `run failed`) and when it happened. - Click an entry to open what it is about; that marks it read. - Click the check mark (**Mark as read**) next to an unread entry to mark only that one read. - Click **Mark all as read** to clear the list. - Click the gear button (**Notification settings**) to open **Settings → Notifications**. Your dashboard also shows up to 8 unread notifications under **Unread notifications**. You get an in-app notification for: - issue and pull request activity you take part in, when **Participating → On Diffinery** is on, - activity in repositories you watch with **All activity**, when **Watching → On Diffinery** is on, - failed workflow runs you started, when **Actions → On Diffinery** is on, - a run waiting for your approval to deploy to an environment where you are a [required reviewer](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers) ("Deployment to production in acme/website is waiting for your review"), when **Actions → On Diffinery** is on, - someone inviting you by username to an organization or a repository (kind `invitation`, for example " invited you to acme as member"); it opens **Settings → Invitations** (see [Invitations you receive](https://diffinery.com/docs/accounts/invitations#invitations-you-receive)), - someone giving you a repository role at once, without an invitation, because you are a member of the organization that owns the repository or already a collaborator on it (kind `repo access`, for example " gave you write access to acme/website"). This comes from **Add people**, the role menu on **Settings → Collaborators** or the API. The last two are always sent, whatever your settings. The invitation email belongs to the **Account** group, which can't be turned off; the access email belongs to the **Repositories** group. Your own actions never create a notification for you. Other repository and organization events (organization role changes, removals, repositories created or deleted), and security and account events, are sent by email only. ## Email groups Every email belongs to one group. Security and account emails always send; you can turn the others off. | Group | Emails | Can be turned off | Default | | --- | --- | --- | --- | | Security | New sign-in from a browser or device not seen before (and every email-recovery sign-in), passkey added or removed, access token created or about to expire, agent added, model API key added, push blocked or push protection bypassed, account disabled or enabled, and for site administrators a new security report | No | On | | Account | Invitations, welcome, sign-in links, sign-up codes, test email, agent review's daily limit reached | No | On | | Participating | Issues and pull requests you opened, commented on, reviewed, were assigned, were asked to review, were @mentioned in or subscribed to: new issues and pull requests, comments, reviews, pushes, merges, closes, reopens, assignments and review requests | Yes | On | | Watching | Everything else in repositories you watch with **All activity** | Yes | On | | Actions | Workflow runs you started (failed runs only, unless you turn off **Only failed runs**), and scheduled runs in repositories you watch | Yes | On, failures only | | Repositories | Repositories created or deleted in your organizations (for owners and admins), your own new repositories, and repository access granted, changed or removed | Yes | On | | Organizations | Your organization role added, changed or removed (with who did it), invitations you sent being accepted, and access requests (for site administrators) | Yes | On | | Morning digest | [Today](https://diffinery.com/docs/agents/today) by email, once a day at the hour you choose | Yes | Off | ## Notification settings Open **Settings → Notifications** (`/settings/notifications`). The top of the page shows the address emails go to (your account's email address) and the address they come from (`notifications@diffinery.com`). The **Notify me about** table has a row per group with an **On Diffinery** checkbox (the notifications list) and an **Email** checkbox: | Row | On Diffinery | Email | Extra option | | --- | --- | --- | --- | | **Participating** | Yes | Yes | | | **Watching** | Yes | Yes | | | **Actions** | Yes | Yes | **Only failed runs** (on by default) | | **Repositories** | No | Yes | | | **Organizations** | No | Yes | | | **Security and account** | No | Always on (locked) | | Below the table: - **Email me about my own comments and changes** (off by default). When on, you also get emails about comments, reviews and changes you made yourself, like GitHub's "Include your own updates". - **Watch repositories I create (All activity)** (on by default). New repositories you create, in the web UI or through the API, are watched with **All activity**. Under **Morning digest**: - **Email me a morning digest** (off by default). - **Hour** and **Time zone**: when it goes out, in your time zone. Until you save one, the time zone is your browser's. - **On Saturdays and Sundays too** (off by default). When the digest is on, the page says when the next one goes out. See [The morning digest](https://diffinery.com/docs/agents/today#the-morning-digest). Click **Save notification settings**. You see "Notification settings saved." The same page lists **Watched repositories** (every repository you watch or ignore, with a menu to change the level) and **Recent emails to you** (the last 20, with their status), and has **Send me a test email** and **Send me my digest now** buttons. ## Who gets notified about issues and pull requests For each event on an issue or pull request, Diffinery works out who is taking part: | You are notified because | Reason (`X-Diffinery-Reason`) | | --- | --- | | Your review was requested on the pull request | `review_requested` | | You are assigned | `assign` | | You were @mentioned in the text of this event (the description, comment or review) | `mention` | | You opened the issue or pull request | `author` | | You commented on it or reviewed it before | `comment` | | You clicked **Subscribe** on it | `manual` | | You watch the repository with **All activity** | `subscribed` | When several apply, the email gives the first one in this order. Mentions use `@username` outside code blocks and inline code. What sends: | Event | Who hears about it | | --- | --- | | Issue or pull request opened | Participants (assignees, requested reviewers, people mentioned) and watchers | | Comment | Participants and watchers | | Review submitted | Participants and watchers | | Closed, merged or reopened | Participants and watchers | | New commits pushed to a pull request | Participants only | | Someone assigned | The new assignees and other participants, not watchers | | Review requested | The requested reviewers and other participants, not watchers | | Title or description edited, merge queue changes | Nobody | Rules that apply to every event: - Your own actions don't notify you, unless you turn on **Email me about my own comments and changes** (email only; such emails carry the reason `your_activity`). - A repository set to **Ignore** sends you nothing about its issues, pull requests and workflow runs. - A muted thread (you clicked **Unsubscribe**) sends you nothing, except when that event @mentions you, assigns you or asks for your review. - You must still be able to read the repository. People who lost access stop hearing about it. - Agents don't receive email. ## Watch a repository The **Watch** menu at the top of every repository sets how much you hear about it: | Menu item | Level | Effect | | --- | --- | --- | | **Watch: participating** | Participating and @mentions | The default: only threads you take part in | | **Watch: all activity** | All activity | Every new issue, pull request, comment, review and state change, plus scheduled workflow runs | | **Watch: ignore** | Ignore | Nothing about this repository's issues, pull requests and workflow runs, even runs you started | Choosing a level saves it right away ("Watching acme/website: All activity."). You can also change levels from the **Watched repositories** list in **Settings → Notifications**. With the API (GitHub's repository subscription endpoints): ```sh # Watch all activity curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"subscribed": true}' # Ignore the repository curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"ignored": true}' # Back to participating only curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/subscription \ -H "Authorization: Bearer dfy_..." ``` Watching, ignoring and subscribing to issues also work on archived repositories, which otherwise refuse every change. `GET /api/v3/repos/{owner}/{repo}/subscription` returns `{"subscribed": true|false, "ignored": true|false, ...}`, or `404 not subscribed` when you are on the default participating level (as GitHub does). `PUT` with `{"subscribed": false}` also returns you to participating. Changing the level needs a `write` or `admin` token. ## Subscribe to or mute an issue or pull request The sidebar of every issue and pull request has a **Notifications** section with one button: - **Subscribe** follows the thread even if you aren't taking part: "Subscribed. You'll be notified about all activity." - **Unsubscribe** mutes it: "Unsubscribed. You'll still be notified if you're @mentioned, assigned or asked to review." The text under the button says why you are or aren't receiving notifications, for example "You're receiving notifications because you commented." or "You muted this pull request." ## Workflow run notifications When a workflow run finishes: | Run started by | Who is notified | | --- | --- | | A person (a push, a pull request, a manual dispatch) | That person | | An agent | People watching the repository with **All activity** (the agent's owner isn't notified directly yet) | | Diffinery itself (for example a schedule) | People watching the repository with **All activity** | Failed and timed-out runs send an email and an in-app notification. Successful runs send an email only to people who turned off **Only failed runs**. Cancelled runs send nothing. The email lists every job with its result and the first failure message, and links to the run. Set a repository to **Ignore** to hear nothing about its runs, for example a test repository whose CI you expect to fail. ## Unsubscribe links Every email in a group you can turn off has unsubscribe links in its footer, and `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers (RFC 8058), so mail apps show their own Unsubscribe button. | Footer link | Where | What it does | | --- | --- | --- | | **Mute this thread** | Issue and pull request emails | Mutes that issue or pull request, like **Unsubscribe** on the page | | **Stop watching acme/website** | Emails you got because you watch the repository | Sets the repository back to participating | | **Unsubscribe from emails like this** | Every email you can turn off | Turns off email for that group (for example Watching) | The mail app's one-click button (`List-Unsubscribe`) turns off email for the group the email belongs to, the same as **Unsubscribe from emails like this**: for an issue or pull request email, that's every Participating email (or every Watching one, when you got it because you watch the repository). To mute just one thread, use **Mute this thread** in the footer instead. You can turn the group back on in notification settings. The links are signed, so they work without signing in and can't be altered to change someone else's settings. Each link works for a year after the email was sent (links from before links carried an expiry work until October 16, 2027); after that, change what you get in notification settings. Opening one shows a page naming the change (for example "Mute acme/website#42") and the account's username, though not its email address, with a button to confirm; after confirming you can click **Undo**. Security and account emails have no unsubscribe links. ## Email headers Every email has an HTML and a plain-text version and these headers, which you can use in mail filters: | Header | Value | | --- | --- | | `From` | `notifications@diffinery.com`. The display name is the person who acted, for issue and pull request emails (`diffy (Diffinery)`), otherwise `Diffinery`. | | `Subject` | Issue and pull request emails: `[acme/website] (PR #42)` or `(Issue #37)`, with `Re: ` for every email after the first. Others start with `[acme/website]` or `[Diffinery]`. | | `X-Diffinery-Reason` | Why you got the email (see below), like GitHub's `X-GitHub-Reason` | | `X-Diffinery-Kind` | The email template, for example `thread`, `run` or `signin_new` (see [Every email Diffinery sends](#every-email-diffinery-sends)) | | `X-Diffinery-Recipient` | Your username | | `X-Diffinery-Repository` | `owner/repo` (or the organization) the email is about, when there is one | | `List-Id` | For repository emails, `acme/website <website.acme.diffinery.com>` | | `References`, `In-Reply-To` | All emails about one issue or pull request share `References: <acme/website/pull/42@diffinery.com>`; every email after the first also has it as `In-Reply-To`, so mail apps group them into one conversation | | `List-Unsubscribe`, `List-Unsubscribe-Post` | On emails you can turn off | | `Precedence: list` | On emails you can turn off | | `Auto-Submitted: auto-generated` | On every email | ### X-Diffinery-Reason values | Value | Meaning (as written in the email footer) | | --- | --- | | `author` | You opened this thread. | | `comment` | You commented (or reviewed). | | `mention` | You were mentioned. | | `assign` | You were assigned. | | `review_requested` | Your review was requested. | | `manual` | You subscribed to this thread. | | `subscribed` | You are watching this repository. | | `your_activity` | Something you did on Diffinery (your own updates, a repository you created). | | `ci_activity` | A workflow run triggered by you, or in a repository you watch. | | `security_alert` | A security notice about your account. These can't be turned off. | | `invitation` | Someone invited this email address or your account to Diffinery, an organization or a repository. | | `admin` | You are an administrator (of the organization, repository or instance). | | `account` | You have a Diffinery account (account and membership changes). | | `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). | | `digest` | You turned on the morning digest in your notification settings. | ## When Diffinery stops emailing you When email to your address bounces for good (the mailbox doesn't exist or the server refuses it), or you mark an email from Diffinery as spam, Diffinery puts your address on its suppression list and sends nothing to it any more: no notifications, and no security alerts, sign-in links or account recovery emails either. A site administrator can also suppress an address by hand. **Settings → Email** (`/settings/email`) shows whether your address is suppressed, since when and why, and the notifications settings page shows a warning too. To get email again after a bounce or a spam report: 1. Make sure the inbox works (and, after a spam report, that you want Diffinery's email). 2. On **Settings → Email**, click **Send a verification email**. It is the one email that goes to a suppressed address, and you can ask for 3 an hour. 3. Open the link in it within 24 hours, signed in to the same account, and click **Confirm and get email again**. If a site administrator suppressed your address, only an administrator can lift it. If the verification email itself fails with `E_RECIPIENT_SUPPRESSED`, Cloudflare's own list still holds the address: ask a site administrator. With the API: `GET /api/v3/diffinery/email` returns your address and `suppressed` (null, or `reason`, `detail`, `created_at` and `owner_can_lift`), and `POST /api/v3/diffinery/email/verification` sends the verification email. ## Every email Diffinery sends The **Kind** column is the `X-Diffinery-Kind` header. Site administrators can preview every one of these under **Settings → Email** (see [Site administration](https://diffinery.com/docs/admin/site-administration#email)). | Kind | Subject (example) | Group | Reason | Sent when | | --- | --- | --- | --- | --- | | `thread` | `[acme/website] Cache build artifacts (PR #42)` | Participating or Watching | `author`, `comment`, `mention`, `assign`, `review_requested`, `manual`, `subscribed` or `your_activity` | Activity on an issue or pull request (see the next table) | | `run` | `[acme/website] Run failed: CI - main (3f9c2a1)` | Actions | `ci_activity` | A workflow run you started failed or timed out (or succeeded, if you asked for all runs) | | `deployment_review_requested` | `[acme/website] Deployment to production is waiting for your review` | Actions | `review_requested` | A run waits for a required reviewer of an environment, and you are one of them (not when you started the run) | | `signin_new` | `[Diffinery] New sign-in to your account` | Security | `security_alert` | A passkey sign-in from a browser and passkey combination not seen before, or any email-recovery sign-in | | `passkey_added` | `[Diffinery] A passkey was added to your account` | Security | `security_alert` | You added a passkey in settings or with a sign-in link | | `passkey_removed` | `[Diffinery] A passkey was removed from your account` | Security | `security_alert` | You removed a passkey | | `token_created` | `[Diffinery] A personal access token was added to your account` (or `A token was created for the agent review-bot`) | Security | `security_alert` | A token was created for you or your agent, from the web UI, CLI or API | | `token_expiring` | `[Diffinery] Your token "ci-deploy" expires on <date>` | Security | `security_alert` | About 7 days before a token expires (once per token, for tokens that last more than 8 days) | | `agent_created` | `[Diffinery] A new agent was added: review-bot` | Security | `security_alert` | You created an agent | | `model_key_added` | `[Diffinery] Your Anthropic API key was added to Diffinery` (or `OpenAI`, `OpenRouter`) | Security | `security_alert` | You saved a model API key for [agent tasks](https://diffinery.com/docs/agents/tasks) | | `push_blocked` | `[acme/website] Your push was blocked: possible secrets` | Security | `security_alert` | Push protection rejected your `git push`, or a commit made in the web editor, through the REST API or over MCP (subject `Your commit was blocked`). For an agent, its owner is told. | | `push_bypassed` | `[acme/website] Push protection was bypassed` | Security | `security_alert` | A repository admin pushed past push protection, or committed a flagged file through the commits API with `allow_secrets`; sent to that admin and the repository's admins | | `account_status` | `[Diffinery] Your account was disabled` or `Your account was enabled` | Security | `account` | A site administrator disabled your account, or enabled it again | | `security_report` | `[Diffinery] New security report` | Security | `admin` | Someone sent a private report through `/security/report` (sent to every active site administrator). It holds only the report's summary and a link to it in [Site administration](https://diffinery.com/docs/admin/site-administration#security-reports). | | `early_access_confirm` | `[Diffinery] Confirm your early access request` | Account | `early_access` | Someone asked for [early access](https://diffinery.com/docs/accounts/early-access) with this address: the link that confirms the request. Sent to the address typed, not to an account. | | `early_access_known` | `[Diffinery] We already have your early access request`, `You're already invited` or `You already have a Diffinery account` | Account | `early_access` | Someone asked for early access again with an address whose request is already confirmed, declined or invited, or that belongs to an active account | | `early_access_remove` | `[Diffinery] Remove your early access request` | Account | `early_access` | Someone asked to remove the early access request for this address: the link that deletes it | | `invite` | `@diffy invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance), or your username (organization or repository; the button is **View invitation** and opens **Settings → Invitations**) | | `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up | | `signup_code` | `[Diffinery] Your sign-up code` | Account | `signup` | With [open sign-up](https://diffinery.com/docs/accounts/passkeys-and-sign-in#open-sign-up), someone asked for a code for an address without an account. The code is in the message, not the subject. | | `signup_attempt` | `[Diffinery] Someone tried to sign up with your email address` | Account | `account` | With open sign-up, someone asked for a code for your address, which already has an account (no code is sent) | | `email_verify` | `[Diffinery] Confirm your email address to get email again` | Account | `account` | You asked for it on **Settings → Email** while your address was suppressed. It is the only email sent to a suppressed address. See [When Diffinery stops emailing you](#when-diffinery-stops-emailing-you). | | `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you | | `review_limit_reached` | `[acme/website] Agent review reached its daily limit` | Account | `your_activity` | [Agent review](https://diffinery.com/docs/agents/review) on a repository whose reviews use your model key reached its daily limit (at most once a day per repository) | | `test` | `[Diffinery] Test email` | Account | `account` | You clicked **Send me a test email** (or used the API) | | `digest` | `[Diffinery] Today: 2 need you, 3 merged` | Morning digest | `digest` | At the hour you chose, when something needs you or happened since the last one; or when you click **Send me my digest now** | | `invite_accepted` | `[Diffinery] @diffy accepted your invitation` | Organizations | `account` | Someone accepted an invite you created | | `access_request` | `[Diffinery] newcomer@example.com asked for access` | Organizations | `admin` | A person waiting for an invite requested access (sent to site administrators) | | `org_membership` | `[Diffinery] You're now a member of acme` | Organizations | `account` | Your organization role was added, changed or removed by an owner or admin. The email names who added you ("Added by") or who changed your role ("Changed by"). | | `repo_access` | `[acme/website] You now have write access to acme/website` | Repositories | `account` | Your collaborator role on a repository was added, changed or removed | | `repo_created` | `[acme/website] New private repository` | Repositories | `your_activity` or `admin` | You created a repository in the web UI (not when a token or script creates it), or someone created one in an organization where you are an owner or admin | | `repo_deleted` | `[acme/website] Repository deleted` | Repositories | `admin` | Someone else deleted a repository you own or that belongs to an organization where you are an owner or admin | | `repo_transferred` | `[acme-labs/website] Repository transferred from acme/website` | Repositories | `admin` | Someone else transferred a repository away from or to you, or an organization where you are an owner or admin | Issue and pull request emails (`thread`) come in these variants: | Event | Title in the email | | --- | --- | | Opened | `@diffy wants to merge cache-artifacts into main` (pull request) or `@diffy opened acme/website#37` (issue); includes the description, the pull request's intent and recent commits | | Comment | `@diffy commented on acme/website#42`, with the comment | | Review | `@diffy approved`, `requested changes on` or `reviewed acme/website#42`, with the review and its line comments | | Closed | `@diffy closed acme/website#37 as completed` (or `as not planned`) | | Merged | `@diffy merged acme/website#42 into main` | | Reopened | `@diffy reopened acme/website#37` | | Assigned | `@diffy assigned you to acme/website#37` | | Review requested | `@diffy requested your review on acme/website#42` | | Pushed | `@diffy pushed 2 commits to cache-artifacts`, with the commits | ## Send a test email Click **Send me a test email** on **Settings → Notifications**. Diffinery sends it right away (not through the queue) and shows "Test email sent to <address>." or the reason it wasn't sent, for example "The test email was skipped: ...". You can send at most 5 test emails an hour; more answer `429 you can send at most 5 test emails an hour; try again later`. With the API (needs a `write` or `admin` token that isn't limited to repositories): ```sh curl -X POST https://diffinery.com/api/v3/diffinery/emails/test -H "Authorization: Bearer dfy_..." ``` ```json { "status": "sent", "messageId": "..." } ``` `status` is `sent`, `skipped` or `failed`, with a `detail` when it wasn't sent. ## Notifications API | Method and path | Description | Token scope | | --- | --- | --- | | `GET /api/v3/notifications` | Unread notifications (add `?all=true` for read ones too): `id`, `kind`, `title`, `url`, `createdAt`, `readAt` | any | | `PUT /api/v3/notifications` | Mark all as read; returns `205` | `write` or `admin`, all repositories | | `GET /api/v3/diffinery/notification-settings` | Your settings (see below) | any | | `PUT /api/v3/diffinery/notification-settings` | Change settings; send only the keys to change | `write` or `admin`, all repositories | | `GET /api/v3/repos/{owner}/{repo}/subscription` | Your watch level | any | | `PUT /api/v3/repos/{owner}/{repo}/subscription` | Watch (`{"subscribed": true}`) or ignore (`{"ignored": true}`) | `write` or `admin` | | `DELETE /api/v3/repos/{owner}/{repo}/subscription` | Back to participating | `write` or `admin` | | `GET /api/v3/diffinery/emails` | Your recent emails: `kind`, `subject`, `status` (`sent`, `failed` or `skipped`), `detail`, `message_id`, `created_at`. Query: `kind`, `since` (ISO time), `per_page` (default 50, up to 500) | any | | `POST /api/v3/diffinery/emails/test` | Send yourself a test email now | `write` or `admin`, all repositories | | `POST /api/v3/diffinery/emails/digest` | Send yourself the morning digest now, even when it is off or has nothing in it (3 an hour) | `write` or `admin`, all repositories | | `GET /api/v3/diffinery/email` | Your email address and whether it is suppressed | any | | `POST /api/v3/diffinery/email/verification` | Send the email that lifts a bounce or complaint on your address | `write` or `admin`, all repositories | The settings object, with its defaults: ```json { "participating": { "web": true, "email": true }, "watching": { "web": true, "email": true }, "actions": { "web": true, "email": true, "failuresOnly": true }, "repos": { "email": true }, "orgs": { "email": true }, "ownActivity": false, "autoWatch": true, "digest": { "email": false, "hour": 8, "timeZone": "UTC", "weekends": false } } ``` `digest.hour` is 0 to 23 and `digest.timeZone` an IANA time zone, such as `Europe/Istanbul`; anything else returns `422`. To get the digest at 7:00 in Istanbul: ```sh curl -X PUT https://diffinery.com/api/v3/diffinery/notification-settings \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"digest": {"email": true, "hour": 7, "timeZone": "Europe/Istanbul"}}' ``` For example, to stop watching emails and get every workflow run result: ```sh curl -X PUT https://diffinery.com/api/v3/diffinery/notification-settings \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"watching": {"email": false}, "actions": {"failuresOnly": false}}' ``` The response is the full settings object. An unknown key returns `422 unknown setting <key>`. Only people have notification settings; agent tokens get `403 only people have notification settings`. ## Delivery Emails are queued and sent through Cloudflare Email Sending from `notifications@diffinery.com`. A failed send is retried with increasing delays. Every email Diffinery tries to send is logged for 90 days with its status, which you can see under **Recent emails to you** or with `GET /api/v3/diffinery/emails`. A **skipped** email has a reason: | Detail | Meaning | | --- | --- | | `turned off <group> emails` | You turned off that group (for example `turned off watching emails`). | | `no email address` | Your account has no email address. | | `account disabled` | Disabled accounts only get the email saying they were disabled or enabled. | | `email sending is not configured (no EMAIL binding)` | The instance can't send email; ask a site administrator. | | `address is suppressed: ...` | Email to your address bounced or was reported as spam, or a site administrator suppressed it. See [When Diffinery stops emailing you](#when-diffinery-stops-emailing-you). | ## How notifications differ from GitHub - The participating, watching and ignore rules, `@mention` handling, thread subscriptions, `X-Diffinery-Reason` (for `X-GitHub-Reason`) and the `/repos/{owner}/{repo}/subscription` API follow GitHub. - Diffinery has no custom watch level (such as releases or discussions only). - Settings are per group, for the notifications list and email separately. There are no per-organization email routes; every email goes to your account's address. - Security and account emails can't be turned off. - The notifications list shows your 100 most recent notifications and has no per-thread done or saved states. - Being added to an organization or a repository with **Add people** always creates an in-app notification, with no setting to turn it off. --- # Blocking and reporting > Block a person from your or your organization's repositories, and report abusive people, repositories, issues, pull requests and comments to the site administrators. Source: https://diffinery.com/docs/accounts/blocking-and-reporting When someone is abusive, you can block them, which keeps them out of your repositories, and report them, which asks the site administrators to act. Both work as on GitHub. ## Block a person Block someone from their profile: open the **…** menu next to their name and click **Block user**, then confirm. Or go to **Settings → Blocked users** (`/settings/blocked-users`), enter their username and click **Block user**. In your repositories, a person you blocked (and their agents) can't: - open issues or pull requests, - comment, review or react, - be added as a collaborator: blocking removes them from every repository you own, and adding them again fails with "olive blocked diffy; unblock them first". Also: - pending invitations between the two of you are cancelled, and neither of you can invite the other, - you get no notifications from them, including when they @mention you anywhere. They can still read your public repositories, and what they wrote before stays. They aren't told they were blocked, but a refused request says so: `403 you can't open issues or pull requests, comment, review or react in olive's repositories: olive blocked you`. Only people can be blocked, not organizations or agents. To unblock, click **Unblock** next to them on **Settings → Blocked users**, or **Unblock user** in the **…** menu on their profile. They aren't added back to anything. ### Block a person from an organization An organization's owners block people for the whole organization on its **Settings** tab, under **Blocked users**. The effect is the same in every repository the organization owns. An organization can't block its own members; remove them from the organization first. ### Blocks through the API These are GitHub's endpoints. Reading needs any token; blocking and unblocking need a `write`-scoped token (`admin` for an organization). | Method and path | Does | | --- | --- | | `GET /api/v3/user/blocks` | The people you blocked | | `GET /api/v3/user/blocks/{username}` | `204` if you blocked them, `404` if not | | `PUT /api/v3/user/blocks/{username}` | Block them (`204`) | | `DELETE /api/v3/user/blocks/{username}` | Unblock them (`204`) | | `GET`, `PUT`, `DELETE /api/v3/orgs/{org}/blocks[/{username}]` | The same for an organization; owners only | Blocking and unblocking are audited as `user.block`, `user.unblock`, `org.block` and `org.unblock`. ## Report content Report a person, a repository, an issue, a pull request or a comment to the site administrators of your instance: - a person: **Report user** in the **…** menu on their profile, - a repository: **Report repository** in the **…** menu next to **Watch**, - an issue or pull request: **Report content** in the **…** menu next to its title, - a comment: **Report content** in the comment's **…** menu. Pick what is wrong (spam, harassment or bullying, hateful content, violence or threats, sexual content, malware or phishing, impersonation, or something else), add a note if it helps (up to 2,000 characters; needed for "Something else") and click **Send report**. You see "Thanks for reporting it. The site administrators will look at it." The person you reported isn't told who reported them. You can file 20 reports a day, and one open report about the same thing. Site administrators see what was reported as it was at the time, so editing or deleting it afterwards changes nothing. They resolve the report and may hide the content, suspend the account or do nothing; see [Site administration](https://diffinery.com/docs/admin/site-administration#reports). ### Report through the API `POST /api/v3/diffinery/reports` with a `write`-scoped token. Send `kind` and what identifies the thing: | `kind` | Also send | | --- | --- | | `user` | `login` | | `repository` | `repository` (`owner/name`) | | `issue`, `pull_request` | `repository` and `number` | | `comment` | `repository` and `comment_id` | with `reason` (`spam`, `harassment`, `hate`, `violence`, `sexual`, `malware`, `impersonation` or `other`) and an optional `body`: ```sh echo '{"kind": "comment", "repository": "acme/website", "comment_id": "1234", "reason": "spam"}' | diffinery api POST /diffinery/reports --input - ``` The answer is `201` with the report's `id`, `kind`, `target`, `html_url`, `reason`, `body`, `state` and `created_at`. Agents can't file reports. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `403 ... olive blocked you` | The repository's owner blocked you. | | `422 olive blocked diffy; unblock them first` | You (or your organization) blocked the person you are adding. | | `422 you can't invite diffy` | That person blocked you. | | `422 diffy is a member of acme; remove them from the organization before blocking them` | Organizations can't block their members. | | `422 you already reported this` | Your earlier report about it is still open. | | `429 you can file at most 20 reports in 24 hours` | Try again tomorrow. | | `422 say what is wrong in the note` | "Something else" needs a note. | --- # Profile and preferences > Change your username, display name and profile picture, choose light, dark or system appearance, and link your GitHub account for imports. Source: https://diffinery.com/docs/accounts/profile-and-preferences Your profile is how other people see you on Diffinery: your username, display name and profile picture. This page also covers the **Appearance** setting (light, dark or following your system), **Linked accounts** (your GitHub account, for imports) and points to every other per-user setting under **Settings**. ## Where your settings are Open **Settings** by clicking your picture and username at the top right of any page, or go to `https://diffinery.com/settings`. The sidebar has these pages: | Page | Path | What it holds | | --- | --- | --- | | **Profile** | `/settings` | Profile picture, username, display name, and read-only account details | | **Account** | `/settings/account` | Exporting your data and deleting your account. See [Exporting and deleting your account](https://diffinery.com/docs/accounts/account-data). | | **Appearance** | `/settings/appearance` | Light, dark or system theme | | **Pull requests** | `/settings/pull-requests` | **Turn on auto-merge for pull requests I open**, and its merge method. See [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green#your-own-pull-requests). | | **Passkeys and sessions** | `/settings/security` | Passkeys and signed-in browsers. See [Passkeys and sign-in](https://diffinery.com/docs/accounts/passkeys-and-sign-in). | | **Access tokens** | `/settings/tokens` | Personal access tokens. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). | | **Agents** | `/settings/agents` | Agent accounts you or your organizations own. See [Agents](https://diffinery.com/docs/agents/overview). | | **Organizations** | `/settings/orgs` | Organizations you belong to, and creating new ones. See [Organizations](https://diffinery.com/docs/accounts/organizations). | | **Notifications** | `/settings/notifications` | What you hear about in the notifications list and by email. See [Notifications](https://diffinery.com/docs/accounts/notifications). | | **Linked accounts** | `/settings/linked-accounts` | GitHub accounts linked to yours, so imported work is credited to you. See [Linked accounts](#linked-accounts). | | **Imports** | `/imports` | The GitHub imports you started, and **Import from GitHub**. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). | Site administrators also see a **Site administration** group with **People and invitations**, **Email** and **Audit log** (see [Site administration](https://diffinery.com/docs/admin/site-administration)). Every settings page needs you to be signed in in the browser. Profile, appearance and passkey changes can't be made with an access token, except the profile picture, which has an API (see [Profile picture API](#profile-picture-api)). ## Your profile **Settings → Profile** (`/settings`) has two boxes: **Profile picture** (see [Profile pictures](#profile-pictures)) and a form with: | Field | Editable | Notes | | --- | --- | --- | | **Username** | Yes | Your login, the owner part of your repository URLs (`https://diffinery.com/diffy/...`) and your `@mention` handle | | **Display name** | Yes | Optional. Shown on your profile page and in the settings sidebar, with your username under it, and returned as `name` by the API. If you leave it blank (or enter only spaces), Diffinery shows your username in its place and the API returns `"name": null`. | | **Email** | No | Your account's email address. Notification emails go here. It can't be changed in Settings. | | **Account type** | No | `user` for a person | | **Joined** | No | When the account was created | | **Site admin** | No | `yes` for site administrators, otherwise `no` | Change the username or display name and click **Save profile**. You see "Profile saved." Your public profile page is `https://diffinery.com/diffy`. It shows your picture, display name, username, your organizations and the repositories the viewer can see. On your own profile page there is an **Edit profile** button that opens **Settings → Profile**. Profile pages need the viewer to be signed in. ### Change your username A username starts with a letter or digit, then uses letters, digits, `-` and `_`, up to 100 characters. Dots aren't allowed, and the name can't be reserved (for example `api`, `settings`, `login`, `admin`, `docs`) or already used by a person, organization or agent. The **Username** field checks the characters in your browser before you save, with the same rules as the server; the organization and agent name fields work the same way. Renaming changes the URL of every repository you own. The old URLs keep working (see [Old URLs after a transfer or rename](https://diffinery.com/docs/repositories/overview#old-urls-after-a-transfer-or-rename) for how each kind behaves), but update existing clones: ```sh git remote set-url origin https://diffinery.com/new-name/website.git ``` After a rename, `https://diffinery.com/old-name` and your repositories' old web and API URLs redirect to the new name, and Git and container images keep being served under the old name, until someone else takes the old name, which is free for them to take. Your picture moves with you: Diffinery forgets cached copies of the picture under the old name. | Message | What to do | | --- | --- | | "invalid or reserved login" | The name is reserved, or breaks the rules above. Pick another. | | "login already taken" | Another person, organization or agent uses it. Pick another. | There is no API for changing your username or display name; use the web UI. `GET /api/v3/user` returns them as `login` and `name`. ## Profile pictures People and organizations can upload a profile picture. Without one, Diffinery draws a picture from the first letter or digit of the name (see [Initials when there is no picture](#initials-when-there-is-no-picture)). Pictures appear next to your name everywhere in the web UI: the header, comments, pull requests, member and collaborator lists, and the user and organization pages. ### Upload your picture in the web UI 1. Go to **Settings → Profile** (`/settings`). 2. In the **Profile picture** box, under **Upload a new picture**, choose a file. The hint reads "JPEG, PNG or WebP, up to 5 MB. It is cropped to a centred square." 3. The current picture is replaced by a preview of the cropped square, with "Preview. Save to use this picture." If your browser can't open the file, you see "No preview for this file; it will be checked when you save." 4. Click **Save picture**. The status shows "Uploading…", then the page reloads with "Profile picture updated." Your browser does part of the work before uploading: it cuts out the centred square, shrinks it to at most 512 by 512 pixels and converts it to WebP (or PNG, if the browser can't produce WebP). The server then checks and processes that upload like any other. If the browser can't decode the file, it uploads the original file and the server's checks decide. ### What Diffinery does with a picture | Step | Detail | | --- | --- | | Format check | The format is detected from the file's first bytes, not its name or `Content-Type`. JPEG, PNG and WebP are accepted. Anything else (GIF, HEIC, SVG, ...) is refused with "Use a JPEG, PNG or WebP image." | | Size check | At most 5 MB (5,242,880 bytes) and at most 3072 by 3072 pixels | | Crop | The centred square of the picture is kept; the rest of a non-square picture is cut off. There is no manual crop control. | | Orientation | The EXIF orientation of a JPEG (as written by phone cameras) is applied, so the picture isn't sideways | | Resize | The square is resized to 64, 128 and 256 pixels | | Encode | Each size is saved as WebP (quality 80). The original upload isn't kept. | | Replace | The previous picture's files are deleted | Transparent areas of a PNG or WebP stay transparent. ### Limits for profile pictures | Limit | Value | | --- | --- | | Formats | JPEG, PNG, WebP | | File size | 5 MB | | Dimensions | 3072 by 3072 pixels | | Stored sizes | 64, 128 and 256 pixels square, WebP | ### Remove your picture When you have a picture, the **Profile picture** box has a **Remove picture** button ("Shows initials instead."). Click it and confirm "Remove your profile picture?". You see "Profile picture removed." and your initials come back everywhere. ### Initials when there is no picture Without an uploaded picture, Diffinery serves a generated SVG picture: the first letter or digit of the name in white, upper case, on a colored background. The color is computed from the name, so it stays the same for that name. A name with no letters or digits shows `?`. In the web UI, people and organizations appear as circles and agents as rounded squares. ### Organization pictures An organization's picture is managed on its **Settings** tab (`https://diffinery.com/acme?tab=settings`), which only organization owners (and site administrators) can open. It has the same **Profile picture** box: 1. Open the organization, click **Settings**. 2. Under **Upload a new picture**, choose a file and click **Save picture**. You see "Organization picture updated." 3. To go back to initials, click **Remove picture** and confirm "Remove the picture of acme?". You see "Organization picture removed." Through the API, organization owners (and site administrators) can change the picture; see the next section. Organization admins and members can't change it. ### Agent pictures The web UI has no upload control for agents, so they show initials until a picture is uploaded. An agent can set its own picture through the API with one of its own tokens, the same way a person does (`PUT /api/v3/user/avatar`). ### Profile picture API These endpoints are a Diffinery extension (GitHub has no API for uploading a profile picture). The request body is the raw image file, not a form or JSON. | Method and path | Who | Response | | --- | --- | --- | | `PUT /api/v3/user/avatar` | You (the token's account) | `200` with `{"avatar_url": "..."}` | | `DELETE /api/v3/user/avatar` | You | `200` with `{"avatar_url": "..."}` (the initials URL) | | `PUT /api/v3/orgs/{org}/avatar` | Owners of the organization, site administrators | `200` with `{"avatar_url": "..."}` | | `DELETE /api/v3/orgs/{org}/avatar` | Owners of the organization, site administrators | `200` with `{"avatar_url": "..."}` | The token needs the `write` or `admin` scope and must not be restricted to repositories. A browser session works too. Upload a picture: ```sh curl -X PUT https://diffinery.com/api/v3/user/avatar \ -H "Authorization: Bearer dfy_..." \ --data-binary @me.jpg ``` ```json { "avatar_url": "https://diffinery.com/api/avatars/diffy?v=1760350000" } ``` The `v` value is the new picture's version. Upload an organization's picture: ```sh curl -X PUT https://diffinery.com/api/v3/orgs/acme/avatar \ -H "Authorization: Bearer dfy_..." \ --data-binary @logo.png ``` Remove it again: ```sh curl -X DELETE https://diffinery.com/api/v3/user/avatar -H "Authorization: Bearer dfy_..." ``` ```json { "avatar_url": "https://diffinery.com/api/avatars/diffy" } ``` The `diffinery api` CLI command sends text bodies only, so use `curl` (or another HTTP client) to upload a picture. Errors: | Status | Message | Cause | | --- | --- | --- | | 401 | `sign in required` | No token was sent | | 403 | `changing a profile picture needs a write or admin token that is not limited to repositories` | The token has only the `read` scope, or is restricted to repositories | | 403 | `only organization owners can change its picture` | You are an admin or member of the organization, but not an owner | | 404 | `not found` | The organization doesn't exist, or you aren't a member | | 413 | `That picture is larger than 5 MB. Use a smaller file.` | The body is over 5 MB | | 422 | `Send the picture as the request body.` | The request had no body | | 422 | `Use a JPEG, PNG or WebP image.` | The body isn't JPEG, PNG or WebP | | 422 | `That picture could not be read. Use a JPEG, PNG or WebP image.` | The file is damaged or its header can't be read | | 422 | `That picture is 4000×3000 pixels; the limit is 3072×3072.` | The picture is larger than 3072 by 3072 pixels | Error bodies are JSON with a `message` field, like every API error. Every change is recorded in the audit log as `avatar.update` or `avatar.remove`, with the account kind and whether it came from the web UI or the API. ### The avatar URL Every person, organization and agent has a picture at: ```text https://diffinery.com/api/avatars/{login} ``` This is the `avatar_url` in every user and organization object the REST API returns (for example `GET /api/v3/user`, the `user` of an issue, or `owner` of a repository), as on GitHub. | Query parameter | Values | Default | | --- | --- | --- | | `s` | Size in pixels. Diffinery serves the smallest stored size at least this big: `64`, `128` or `256`. Larger values get `256`. | `128` | | `v` | A picture version, as returned by the upload API. Pins the URL to that picture so it can be cached for a long time. | none | ```sh # The 64-pixel picture of diffy curl -o diffy.webp "https://diffinery.com/api/avatars/diffy?s=64" ``` The response is `image/webp` for an uploaded picture, or `image/svg+xml` for initials. The URL needs no sign-in or token, so you can use it in `<img>` tags, README files and other tools. A name that doesn't exist also gets an initials picture, so the URL doesn't reveal which accounts exist. ### Caching | URL | `Cache-Control` | When a new picture shows up | | --- | --- | --- | | With the current `v` | `public, max-age=86400, immutable` | Never changes; a new picture gets a new `v` | | Without `v` (or with an old `v`) | `public, max-age=600, stale-while-revalidate=86400` | Within about 10 minutes | The header, the settings pages, profile and organization pages and member lists use the versioned URL, so your new picture shows there right away after you save. Other places use the plain URL (for example issue and pull request conversations in the web UI, API clients and other sites), so they can show the old picture for up to about 10 minutes, and browsers may show a cached copy a little longer while they check for a new one. Diffinery also keeps a copy at Cloudflare's edge for up to 10 minutes; uploading or removing a picture clears that copy in the data center that handled the change, and other data centers pick up the change when their copy expires. ## Appearance **Settings → Appearance** (`/settings/appearance`) sets the color theme of the web UI. The page says "Choose how Diffinery looks to you. The choice follows your account to every browser you sign in to." | Theme option | Description in the page | Behaviour | | --- | --- | --- | | **Sync with system** | "Light or dark, following your device's setting." | The default. Follows your operating system or browser's light or dark mode, and switches when it does. | | **Light** | "Always light." | Always the light theme | | **Dark** | "Always dark." | Always the dark theme | To change it: 1. Go to **Settings → Appearance**. 2. Under **Theme**, pick **Sync with system**, **Light** or **Dark**. 3. Click **Save appearance**. You see "Appearance saved." ### How the appearance setting is stored and applied - The choice is saved on your account, so every browser you sign in to uses it from the next page you open. There is no need to set it per device. - The page is rendered with the theme already applied, so there is no flash of the wrong theme while it loads, and it works with JavaScript turned off. - Saving **Light** or **Dark** also stores the choice in a cookie in the browser you saved it from (`dfy_theme`, kept for 400 days). Pages you open there while signed out, such as the sign-in page, invite pages and these docs, use it too. Saving **Sync with system** removes the cookie. - In other browsers, pages you open while signed out follow the system setting until you sign in. - The theme applies to every page of the web UI, including syntax-highlighted code and diffs, which use the GitHub Light Default and GitHub Dark Default color themes. - Emails aren't affected: they follow the light or dark mode of your mail app. There is no API or CLI command for the appearance setting. ## Linked accounts **Settings → Linked accounts** (`/settings/linked-accounts`) is the **Linked GitHub accounts** page. When someone [imports from GitHub](https://diffinery.com/docs/migrate-from-github), issues, pull requests, comments and reviews written by a linked GitHub account are credited to your Diffinery account instead of a placeholder ("mannequin") for the GitHub user. Link your account before the import runs: work that was already imported stays with the mannequin. ### Link with a public gist No GitHub token is needed: 1. Under **Step 1 · get a code**, leave **GitHub Enterprise API URL** blank for github.com, or enter your GitHub Enterprise API URL (for example `https://ghe.example.com/api/v3`), and click **Get my code**. 2. Copy the code (it starts with `diffinery-link-`). On GitHub, create a **public** gist whose description or one of whose files contains it. 3. Under **Step 2 · verify your gist**, enter **Your GitHub username on github.com** (or on your Enterprise host) and click **Verify and link**. You see "GitHub account linked." Diffinery reads your public profile and gists without a token, from that one GitHub host, and stores GitHub's numeric user id with the link, so a later rename of your GitHub account doesn't break it. You can delete the gist afterwards. ### Link with GitHub OAuth If the site administrator configured a GitHub OAuth app, the page also has **Link with GitHub**: click **Continue to GitHub**, approve read-only access to your profile, and you come back with the account linked. OAuth links github.com accounts only; use a gist for GitHub Enterprise. ### Manage your links - Each link shows the GitHub login, the host (`on github.com`) and when it was linked. Click **Unlink** and confirm "Unlink diffy?" to remove it; you see "Account unlinked." - You can link several GitHub accounts (for example one on github.com and one on an Enterprise host). A GitHub account can be linked to only one Diffinery account. - Only people can link accounts; agents can't. - Linking, unlinking and a site administrator clearing a link are recorded in the audit log (`account.link_github`, `account.unlink_github`, `account.link_cleared`). - The same actions are available in the REST API under `/api/v3/diffinery/user/linked-accounts` with a personal token that has the `admin` scope and isn't restricted to repositories. See [Link your GitHub account](https://diffinery.com/docs/migrate-from-github#link-your-github-account). | Message | What to do | | --- | --- | | "create a PUBLIC gist whose description or a file contains diffinery-link-..., then try again" | Make sure the gist is public (not secret) and contains the exact code, then click **Verify and link** again. | | "GitHub user diffy was not found" | Check the username and, for Enterprise, the API URL. | | "GitHub user diffy is linked to another Diffinery account" | Unlink it from that account first, or ask a site administrator to clear the link. | ## Notification preferences Your notification preferences (which groups you hear about in the notifications list and by email, watching new repositories, emails about your own activity) are on **Settings → Notifications**, and can also be read and changed with `GET` and `PUT /api/v3/diffinery/notification-settings`. See [Notifications](https://diffinery.com/docs/accounts/notifications#notification-settings). ## How profiles differ from GitHub - There is no bio, location, website, company, pronouns or status field. The profile has a username, a display name and a picture. - Your email address comes from your invitation and isn't editable in Settings. Diffinery has no list of secondary email addresses. - Profile pictures are uploaded to Diffinery. There is no Gravatar support (`gravatar_id` is always empty in API responses). - Diffinery has an API to upload and remove profile pictures for users and organizations; GitHub doesn't. - GitHub's theme settings have more options (for example separate day and night themes and high-contrast themes). Diffinery has light, dark and sync with system. - After a username change, Diffinery redirects your profile and your repositories' old web, API and Git URLs until someone takes the old name; GitHub redirects the repositories only. ## Troubleshooting | Message or symptom | What to do | | --- | --- | | "That picture is larger than 5 MB. Use a smaller file." | Use a file of 5 MB or less. In the web UI, files the browser can open are shrunk before uploading, so this mostly happens with the API or with files the browser can't preview. | | "Use a JPEG, PNG or WebP image." | Convert the picture to JPEG, PNG or WebP. | | "That picture is ... pixels; the limit is 3072×3072." | Resize the picture to at most 3072 by 3072 pixels, or upload it in the web UI, which shrinks it first. | | "Choose a picture to upload." | Pick a file before clicking **Save picture**. | | The old picture still shows in comments or in another tool | Those places use the unversioned avatar URL, which can be cached for about 10 minutes. Wait, or in your own tools use the URL with `?v=` from the upload response. | | `403 changing a profile picture needs a write or admin token...` | Create a token with the `write` or `admin` scope and no repository restriction, under **Settings → Access tokens**. | | The theme doesn't change on the sign-in page | Signed-out pages use the cookie of the browser where you last saved **Light** or **Dark**, or the system setting. Sign in, or save the setting again in this browser. | | "login already taken" or "invalid or reserved login" | See [Change your username](#change-your-username). | --- # Exporting and deleting your account > Download an archive of your account, with your repositories as Git bundles and the issues you wrote, or delete your account and everything it owns. Source: https://diffinery.com/docs/accounts/account-data **Settings → Account** (`/settings/account`) is where you download a copy of your data and where you delete your account. Both ask you to have signed in within the last 2 hours; if you signed in earlier, Diffinery asks you to sign in again first and then brings you back. ## Exporting your data Click **Start export**. Diffinery builds the archive in the background, which takes from a few seconds to a while for large repositories, and emails you when it's ready (when the instance sends email). The **Account** page shows it too: **Preparing your export…** while it is built, then **Export ready** with its size and a **Download** button. You can download it as often as you like for 7 days; after that it is deleted. Starting a new export replaces the old one. Only one export can be built at a time. Only you can download your export. Site administrators can't. Asking for an export and downloading it are both recorded in the [audit log](https://diffinery.com/docs/security/audit-log). ### What's in the archive The download is a tar file named `diffinery-export-<username>-<date>.tar`. Unpack it with `tar -xf`. Everything is in one folder, `diffinery-export-<username>/`: | File | What it holds | | --- | --- | | `manifest.json` | The format version (`format`, now `1`), the instance's address, your username, when the archive was made, and every file in it with its size in bytes. | | `account.json` | Your profile (username, display name, email address, when you joined, whether you are a site administrator, your profile picture's address), your settings (appearance, notification settings, watched repositories), your organizations and role in each, your agents, your access tokens and passkeys, your linked accounts and your model provider keys. | | `repositories/<name>.json` | Each repository you own: description, visibility, default branch, topics, homepage, when it was created and last pushed, its clone URL, and the name of its bundle. | | `repositories/<name>.bundle` | The repository's branches and tags as a Git bundle. | | `issues/<owner>/<repository>.json` | For each repository you can see where you wrote something: the issues and pull requests you opened (`issues`, with `kind` saying which), the comments you wrote (`comments`, review comments with their file and line) and the reviews you submitted (`reviews`). | Access tokens, passkeys and model keys are listed by name and date only. Nothing in the archive can sign in as you or use your keys. Secrets of your repositories and Actions are not exported. To get a repository back from its bundle: ```bash git clone diffinery-export-alice/repositories/notes.bundle notes ``` A repository with no branches or tags has no bundle, and one larger than 4 GiB isn't bundled; its JSON file has a `note` saying so, and `bundle` is `null`. Clone it from `clone_url` while your account exists. Issues, comments and reviews are exported up to 5,000 of each per repository. ## Deleting your account The **Delete your account** box at the bottom of the **Account** page deletes your account at once. Type your username and click **Delete your account**. You are signed out and sent to the sign-in page. You can't delete your account while: - you are the only owner of an organization. The page names the organization. Make someone else an owner of it, or delete the organization, first (see [Organizations](https://diffinery.com/docs/accounts/organizations)). - you are the only site administrator. Make someone else a site administrator first (see [Site administration](https://diffinery.com/docs/admin/site-administration#site-administrators)). - a GitHub import you started, or one into your account, is still running. Wait for it to finish or cancel it. ### What goes and what stays Deleted at once: - every repository you own, with its history, issues, pull requests, workflow runs, secrets, webhooks and releases. Their names, and any old names that redirected to them, stop working straight away; their storage is wiped in the background. - your container images and other packages. - your access tokens, passkeys and every browser session. - your agents, with their tokens. - your organization memberships, repository collaborator access and pending invitations. - your notifications, watches, notification settings, linked accounts, model keys, saved terminal setups, quotas and usage, sign-in links and email history. - your profile and profile picture. `https://diffinery.com/<username>` no longer exists. What stays: - issues, pull requests, comments, reviews and reactions you wrote in other people's repositories. They are shown as written by **ghost**, a placeholder for people who deleted their accounts. This happens in the background, so for a few minutes after the deletion some may still show your old username. - commits you pushed to other people's repositories. They are part of those repositories' Git history and keep the name and email address in the commit. - the [audit log](https://diffinery.com/docs/security/audit-log), which keeps what you did, under your old username. Diffinery emails you to say the account was deleted. ### Your username Nobody, you included, can use your username for 90 days after the deletion: creating a person, organization or agent with it, or renaming one to it, is refused with `This username belonged to an account that was deleted recently; it can be used again from <date>` (`This name ...` when renaming). The same goes for the usernames of your agents. ### Deletion by a site administrator Site administrators can delete anyone's account from **Site administration** (see [Deleting an account](https://diffinery.com/docs/admin/site-administration#deleting-an-account)). The same rules apply, and the person gets an email saying a site administrator deleted their account. --- # Repositories > Create, configure, share, archive, rename, transfer and delete Diffinery repositories, and use topics, READMEs, languages and source archives. Source: https://diffinery.com/docs/repositories/overview A repository holds a project's Git history, issues, pull requests, workflows, secrets and webhooks. This page covers creating repositories, naming and visibility, the settings pages, collaborators and roles, archiving, renaming, transferring, deleting and watching, plus topics, the README and languages endpoints, and source code archives (zipball and tarball). For pushing and cloning, see [Git over HTTPS](https://diffinery.com/docs/repositories/git). Every repository lives at `https://diffinery.com/<owner>/<name>`, for example `https://diffinery.com/acme/website`, and clones from `https://diffinery.com/acme/website.git`. The owner is either a person (a user) or an organization. ## User and organization repositories | Owner | Who can create repositories there | Who gets access automatically | | --- | --- | --- | | Your user account (`diffy/notes`) | Only you (and site administrators) | You are **admin**. Nobody else, unless you add collaborators or make it public. | | An organization (`acme/website`) | Any member of the organization | Organization **members** get the organization's base permission (**read** for new organizations; owners can set **none**, **read**, **write** or **admin**). Organization **owners** and **admins** get **admin**. A member who creates the repository gets **admin** on it. | You can add collaborators to either kind for extra access (see [Collaborators and roles](#collaborators-and-roles)). Organizations have no teams: access in an organization comes from the member's organization role plus any collaborator role on the repository, and a collaborator role can raise a member above the base permission but not lower it. See [Base permission](https://diffinery.com/docs/accounts/organizations#base-permission). An agent that creates a repository with `POST /api/v3/user/repos` creates it for the account that owns the agent. See [Agents](https://diffinery.com/docs/agents/overview). ## Create a repository You need a signed-in account (web) or a token (CLI and API). To create a repository in an organization you must be a member of it. ### In the web UI 1. Click the **+** (New repository) icon in the header, or open `https://diffinery.com/new`. To preselect an organization, open `https://diffinery.com/new?owner=acme`. 2. On **Create a new repository**, fill in: | Field | Notes | | --- | --- | | **Owner** | You or one of your organizations. | | **Repository name** | See [Repository names](#repository-names). | | **Description** (optional) | Up to 350 characters. | | **Visibility** | **Private** (default) or **Public**. See [Visibility](#visibility). | | **Default branch** | Defaults to `main`. | 3. Click **Create repository**. Diffinery takes you to the new, empty repository. Its **Quick setup** box shows the clone URL and the commands to push a new or existing project, and, if you have write access, a link to create a README in the browser. ### With the CLI Install the CLI and sign in with `diffinery login`: ```sh curl -fsSL https://diffinery.com/install | bash ``` Then: ```sh # In your own account, private by default diffinery repo create notes # In an organization, public, with a description and a different default branch diffinery repo create website --org acme --public -d "Marketing site" --default-branch trunk ``` | Option | Meaning | | --- | --- | | `--org ORG` | Create it in this organization instead of your account. | | `--private` | Only people with access can see it (the default). | | `--public` | Anyone can see and clone it. | | `-d, --description TEXT` | Description. | | `--default-branch BRANCH` | Default branch name (default `main`). | | `--json` | Print the created repository as JSON. | The command prints the repository URL and the `git remote add origin ...` line to use. See [CLI reference](https://diffinery.com/docs/reference/cli) for every command. ### With the REST API Send a token in the `Authorization` header. It needs the `write` or `admin` scope (`write` is the default scope for new tokens) and must not be restricted to some repositories. A `read` token gets `403 this token needs the write scope`, and a token restricted to repositories gets `403 this token is limited to repositories and can't change account settings`. ```sh # In your own account curl -X POST https://diffinery.com/api/v3/user/repos \ -H "Authorization: Bearer dfy_..." \ -H "content-type: application/json" \ -d '{"name": "notes", "description": "Personal notes"}' # In an organization, public curl -X POST https://diffinery.com/api/v3/orgs/acme/repos \ -H "Authorization: Bearer dfy_..." \ -H "content-type: application/json" \ -d '{"name": "website", "description": "Marketing site", "visibility": "public", "default_branch": "main"}' ``` | Field | Type | Default | Notes | | --- | --- | --- | --- | | `name` | string | (required) | See [Repository names](#repository-names). | | `description` | string | `""` | | | `visibility` | `"public"` or `"private"` | `"private"` | Any other value fails with `visibility must be public or private` (HTTP 422), with `errors` naming the `visibility` field. | | `private` | boolean | | As on GitHub: `false` creates a public repository, `true` a private one. When you send both, `visibility` wins. | | `default_branch` | string | `"main"` | | The response is `201 Created` with the repository in GitHub's shape: a numeric `id`, `node_id`, `name`, `full_name`, `owner`, `private`, `visibility`, `description`, `default_branch`, `archived`, `topics`, `html_url`, `clone_url`, the GitHub URL templates (`contents_url`, `releases_url`, `languages_url` and so on), `created_at`, `updated_at` and `pushed_at`. Fields for features Diffinery doesn't have are filled with fixed values, for example `fork: false`, `forks_count: 0` and `has_wiki: false`. Organization owners and admins get an email when someone else creates a repository in their organization (see [Notifications](https://diffinery.com/docs/accounts/notifications)). ## Repository names - Start with a letter or digit. - Then letters, digits, `.`, `-` and `_`, up to 100 characters in total. - Must not end in `.git`, and must not be `.` or `..`. - Names are unique per owner and case-insensitive: `acme/Website` and `acme/website` are the same repository. Opening the repository home page with a different capitalisation (or with `.git` on the end) redirects to the repository's own spelling. If the name is invalid you get `invalid repository name: use letters, numbers, '.', '-' and '_'` (HTTP 422). If the owner already has a repository with that name you get `a repository with this name already exists` (HTTP 422). ## Visibility | Visibility | Who can see and clone it | | --- | --- | | **Private** (default) | The owner, the organization's members (for organization repositories, unless the organization's base permission is **none**), collaborators, and site administrators. | | **Public** | Anyone, including people who are not signed in. Signed-out visitors can browse the code, and `git clone` works without credentials. Every signed-in user gets **read** access. | Writing to a public repository still needs a signed-in account (or a token) with a role that allows it. People who aren't signed in can read a public repository but can't change anything in it: opening an issue, commenting, reacting, watching and pushing all need an account, and the API answers such requests with `401 authentication required`. An anonymous `git push` gets a `401` with a `WWW-Authenticate` challenge, so Git asks for your credentials (or uses its credential helper) and tries again with them. A `read`-scoped token can read too, but every write with it gets `403 this token needs the write scope`. Only public repositories are indexed by search engines; private pages carry a `noindex` tag. A public repository's page is listed in the sitemap, and search engines are told about it through IndexNow within the hour after a push (see [Search engines](https://diffinery.com/docs/admin/site-administration#search-engines)); a private one never is. A public repository's home page also describes it to search engines and AI assistants as schema.org `SoftwareSourceCode` structured data: its name, description, topics, license file and last push. For people without access, a private repository looks like it does not exist: the web UI, Git and the API answer `404` (`repository not found`) rather than `403`, so the name is not revealed. Signed-out requests can't tell a private repository from a missing one either: Git, Git LFS and the web UI answer both with `401` (`authentication required`), so Git asks for credentials and the web UI offers sign-in, and the REST API answers both with `404`, as GitHub does. A signed-out write gets `401` whether or not the repository exists. Change visibility on the **General** settings page or with `PATCH /api/v3/repos/{owner}/{repo}` and `{"visibility": "public"}`. As on GitHub, `{"private": false}` also makes a repository public and `{"private": true}` makes it private. `visibility` accepts only `public` and `private` here too: any other value gets the same `422 visibility must be public or private` as when creating a repository. ## Repository settings The **Settings** tab appears only for people with the **admin** role on the repository. It has five pages: | Page | Path | What it controls | | --- | --- | --- | | **General** | `/acme/website/settings` | Name, description, default branch, visibility, archive, transfer, delete. | | **Collaborators** | `/acme/website/settings/access` | Direct access for people and agents, and email invitations, from one **Add people** form. | | **Branch protection** | `/acme/website/settings/branches` | Rules for matching branches. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection). | | **Secrets and variables** | `/acme/website/settings/secrets` | Actions secrets and variables. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). | | **Webhooks** | `/acme/website/settings/webhooks` | HTTPS endpoints that receive events. See [Webhooks](https://diffinery.com/docs/repositories/webhooks). | Every change on these pages is written to the audit log (for example `repo.update`, `repo.collaborator.set`, `repo.protection.set`, `repo.secret.set`, `repo.webhook.create`). The same changes made through the REST API are recorded too, with `via: "api"` in the entry's detail. See [Audit log](https://diffinery.com/docs/security/audit-log#repositories). Some repository features have no settings page: - **Releases** live at `/acme/website/releases`, and anyone with write access manages them there. See [Releases](https://diffinery.com/docs/collaboration/releases). - **Deployment environments** (with their own secrets, variables and branch policies) are managed with the REST API. See [Environments](https://diffinery.com/docs/actions/environments). - **Workflow permissions** (what a workflow's `GITHUB_TOKEN` may do by default, and whether workflows may create and approve pull requests) are set on the **Actions** tab or with the REST API. See [Workflow permissions](#workflow-permissions). ### General The **General** page has these fields, saved together with **Save changes**: - **Repository name**: renames the repository. See [Rename a repository](#rename-a-repository). - **Description**: up to 350 characters, shown under the repository name and in the **About** box. It is also the page's description in search results and link previews. - **Website**: the project's site, an `http` or `https` URL of up to 500 characters, linked from the **About** box. GitHub calls it `homepage`; `PATCH /api/v3/repos/{owner}/{repo}` with `{"homepage": "https://example.com"}` sets it too, and `""` clears it. - **Topics**: up to 20, separated by spaces or commas. See [Topics](#topics). - **Default branch**: a list of the repository's branches (a text field while the repository is empty). The default branch is what the repository page shows, what clones check out, the base for new pull requests, and it is always protected from agents. - **Visibility**: **Private** or **Public**. Below it, the **Danger zone** has **Archive this repository**, **Transfer ownership** (see [Transfer a repository](#transfer-a-repository)), **Purge unreachable objects** (see [Removing a secret that was pushed](https://diffinery.com/docs/security/push-protection#removing-a-secret-that-was-pushed)), **Delete closed pull requests** (see [Start over with fresh history](#start-over-with-fresh-history)) and **Delete this repository**. ### Secrets and variables The page lists **Repository secrets** (names and when they were updated; values are never shown again) and **Variables** (names and values). Workflows read them as `${{ secrets.NAME }}` and `${{ vars.NAME }}`. - Names use letters, digits and `_` and don't start with a digit. They are stored in upper case. - Secret names can't start with `GITHUB_`. - Saving an existing name replaces its value. - Only people manage them. Agents get `403 agents cannot manage secrets` or `403 agents cannot manage variables` when they try to add, change or delete one, even with the admin role. Organization secrets, available to every repository in the organization, are managed with the API or `diffinery secret set NAME --org acme`. Details are in [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). ### Workflow permissions Each repository has GitHub's **Workflow permissions** setting for Actions. It decides the permissions a job's `GITHUB_TOKEN` gets when the workflow doesn't set `permissions:`, and whether workflows may open pull requests and approve or request changes on them. | Field | Values | Default | | --- | --- | --- | | `default_workflow_permissions` | `read`: `contents` and `packages` read only. `write`: read and write on every permission except `id-token`. | `read` for repositories created now; `write` for repositories created before the setting existed | | `can_approve_pull_request_reviews` | `true` or `false` | `false` | In the web UI, open the repository's **Settings → Actions** tab (`/{owner}/{repo}/settings/actions`). Under **Workflow permissions**, pick **Read repository contents and packages permissions** or **Read and write permissions**, check or clear **Allow GitHub Actions to create and approve pull requests**, and click **Save**. You see "Actions settings saved." The tab needs the **admin** role on the repository. With the REST API (admin role, so an `admin`-scoped token): ```sh # Read the setting curl https://diffinery.com/api/v3/repos/acme/website/actions/permissions/workflow \ -H "Authorization: Bearer dfy_..." # Give workflows read and write by default, and let them create and approve pull requests curl -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/permissions/workflow \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"default_workflow_permissions": "write", "can_approve_pull_request_reviews": true}' ``` `GET` returns `{"default_workflow_permissions": "read", "can_approve_pull_request_reviews": false}`, and `PUT` returns `204`. Fields you leave out keep their value. Only people can change it (agents get `403 only people can change what workflows are allowed to do`), an invalid value gets `422 default_workflow_permissions must be read or write` or `422 can_approve_pull_request_reviews must be a boolean`, and each change is recorded in the audit log as `repo.actions_workflow_settings`. Even when workflows may approve, their approvals never count toward required approvals. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions) for how the token's permissions are worked out. ### Webhooks Add a webhook with a **Payload URL** (must start with `https://`), the **Events** to send (**Everything**, or any of `push`, `pull_request`, `pull_request_review`, `issues`, `issue_comment`, `workflow_run`, `workflow_dispatch`; **Everything** also covers `release`, `status`, `deployment`, `deployment_status` and `repository_dispatch`) and an optional **Secret** (generated when left empty, and shown once). Diffinery signs each delivery with an HMAC-SHA256 of the body in `X-Hub-Signature-256`, like GitHub. A delivery that fails with a network error, a timeout, `408`, `429` or a `5xx` status is retried up to five times (1 minute after the first failure, then 5 minutes, 30 minutes, 1 hour and 3 hours after each further failure), with the same `X-GitHub-Delivery` ID. The list shows each webhook's last delivery status. See [Webhooks](https://diffinery.com/docs/repositories/webhooks). ## Collaborators and roles A role decides what someone can do in a repository. Roles are the same as GitHub's: | Role | Can | | --- | --- | | **read** | View and clone. | | **triage** | Also manage issues and labels. | | **write** | Also push, open and merge pull requests, run workflows, create branches and tags. | | **maintain** | Also move and delete tags. | | **admin** | Also change settings, secrets, branch protection, webhooks and access, archive and delete the repository. | A person's role is the highest of: owning the repository (admin), their organization role (members get the organization's base permission, owners and admins admin), a collaborator role, and **read** on public repositories. Site administrators are admin everywhere. A token can lower this further (a `read` token acts as read and can't make any change, a `write` token acts as at most maintain). Agents have their own limits on top: for example, whatever their role, they can't add, change or delete secrets or variables, change branch protection or delete the repository. See [Permissions](https://diffinery.com/docs/accounts/permissions) for the full matrix. ### Add a collaborator in the web UI Open **Settings → Collaborators** and use the **Add people** form: 1. In **Username or email**, enter a person's or agent's username (for example `diffy` or `review-bot`, with or without a leading `@`) or an email address. 2. Pick a **Role** (**Read**, **Triage**, **Write**, **Maintain** or **Admin**; the default is **Write**). 3. Click **Add**. What happens depends on what you entered: | You entered | Result | | --- | --- | | The username of a person | Diffinery sends them an invitation, as on GitHub: "Invited diffy with write access. They get access once they accept." They get a notification on Diffinery and an email, and get the role when they accept it under **Settings → Invitations**. The invitation expires after 14 days. If they already have a pending invitation to this repository, it offers the role you chose instead: "diffy already had a pending invitation; it now offers admin access." | | The username of an agent, of a person who already is a direct collaborator, of a member of the organization that owns the repository, or your own | They get the role right away, with no invitation. People get a notification on Diffinery and an email. The page confirms with "diffy now has write access. We let them know." | | An email address | Diffinery emails an invitation, whether or not the address has an account, and shows the link once ("Invitation sent to ..."), so you can also send it yourself. The link works once and expires in 14 days. Opening it gives the person the role on this repository, creating their account and passkey first if they don't have one. See [Invitations](https://diffinery.com/docs/accounts/invitations). | Errors you may see: | Message | Meaning | | --- | --- | | `no one has the username <name>; to invite someone new, enter their email address` | No account has that username. Check the spelling, or enter the person's email address to invite them. | | `<name> is an organization; add its people instead` | Organizations can't be collaborators. Add the people one by one. | | `<name>'s account is disabled` | A site administrator disabled that account. | | `enter a valid email address` | The text contains `@` but isn't a valid address. | | `<email> already has a pending invitation; revoke it first to send a new one` | That address already has an open invitation to this repository (letter case doesn't matter). Revoke it under **Pending invitations**, then add the address again. | The table lists direct collaborators. Change someone's role with the role list and **Save**, or remove them with **Remove**. People get an email when their access to a repository is granted or changed. Invitations that haven't been accepted yet are listed under **Pending invitations**, with who sent them, when, the expiry date and the role. An invitation sent to a username shows as `@diffy`, never the person's email address. Click **Revoke** to withdraw one; the person isn't told. See [Invitations](https://diffinery.com/docs/accounts/invitations#revoke-an-invitation). Organization members don't appear in this table: they get their access from the organization's base permission. The exception is a member who created the repository, who is listed with **Admin**. ### Manage collaborators with the API These need an admin role on the repository. ```sh # List collaborators (any reader can list) curl https://diffinery.com/api/v3/repos/acme/website/collaborators -H "Authorization: Bearer dfy_..." # Invite a collaborator, or change a collaborator's role curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"permission": "maintain"}' # Remove a collaborator curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy -H "Authorization: Bearer dfy_..." ``` `permission` accepts `read`, `triage`, `write`, `maintain` or `admin`, and GitHub's `pull` (read) and `push` (write). It defaults to `write`. The list response gives each account's `role_name`. As on GitHub, `PUT` invites a person who doesn't have access yet: it answers `201 Created` with the invitation (`id`, `repository`, `invitee`, `inviter`, `permissions`, `created_at`, `expires_at`, `html_url`), and they get the role once they accept it. For an agent, someone who already is a direct collaborator, a member of the organization that owns the repository, or yourself, the role changes at once and `PUT` answers `204 No Content`. `GET /api/v3/repos/{owner}/{repo}/invitations` lists pending invitations and `DELETE /api/v3/repos/{owner}/{repo}/invitations/{id}` revokes one. See [Invitations](https://diffinery.com/docs/accounts/invitations#repository-invite-by-username). ## Archive a repository Archiving makes a repository read-only, as on GitHub: nobody can push, open or change issues and pull requests, comment, review, create or edit releases, start runs or change settings, and scheduled workflows stop. Everything stays readable, cloneable and downloadable as an archive. - **Web UI**: **Settings → General → Danger zone → Archive**, then confirm. **Unarchive** reverses it. - **API**: `PATCH /api/v3/repos/acme/website` with `{"archived": true}` (or `false`). Archived repositories show an **archived** badge. Every request that would write to one fails with `repository is archived (read-only)` (HTTP 403), whatever role it needs and whoever sends it, admins included: pushes, issues, comments, reactions, reviews, pull requests, releases, statuses, deployments, topics, collaborators, branch protection, secrets, variables, environments, webhooks and MCP tools that change something. A `git push` fails with the same message. Unarchive the repository first. Only these still work on an archived repository: | Action | How | | --- | --- | | Unarchive it | **Settings → General → Unarchive**, or `PATCH /api/v3/repos/acme/website` with `{"archived": false}` | | Delete it | **Settings → General → Delete this repository**, or `DELETE /api/v3/repos/acme/website` | | Transfer it | **Settings → General → Transfer ownership**, or `POST /api/v3/repos/acme/website/transfer`. See [Transfer a repository](#transfer-a-repository). | | Watch or unwatch it | The **Watch** menu, or `PUT`/`DELETE /api/v3/repos/acme/website/subscription` | | Link a container image to it | The package's settings, or the packages API. See [Container registry](https://diffinery.com/docs/packages/container-registry). | Archiving and unarchiving are recorded in the audit log as `repo.archive` and `repo.unarchive`, from the web UI and the API alike. ## Rename a repository Admins can rename a repository on **Settings → General** (change **Repository name** and click **Save changes**) or with: ```sh curl -X PATCH https://diffinery.com/api/v3/repos/acme/website \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"name": "site"}' ``` Renaming changes the repository's URL and clone URL. The old name keeps working, exactly as after a transfer (see [Old URLs after a transfer or rename](#old-urls-after-a-transfer-or-rename)), until a repository is created with that name. Update your remotes anyway: `git remote set-url origin https://diffinery.com/acme/site.git`, and anything else that uses the old name, such as CI scripts and webhook consumers. A name the owner already uses is refused (`a repository with this name already exists`, HTTP 422). ## Transfer a repository A transfer moves a repository to another owner, like GitHub's transfer: from your account to an organization, from an organization to your account, or between organizations. Everything in the repository moves with it, and its old URLs keep working. ### Who can transfer - You must be a person with the **admin** role on the repository. Agents, workflow tokens (`GITHUB_TOKEN`) and sandbox tokens can't transfer repositories. - A personal repository can only be transferred by its owner (or a site administrator). - The new owner must be your own account, or an organization where you are an **owner** or **admin**. You can't transfer a repository to another person's account, or to an organization where you are only a member. Because of this, there is no acceptance step: the transfer happens at once. - Archived repositories can be transferred without unarchiving them first. - Through the API, you need a token with the `admin` scope that isn't limited to repositories. ### Transfer in the web UI 1. Open the repository's **Settings** tab (the **General** page) and scroll to the **Danger zone**. 2. Under **Transfer ownership**, pick the **New owner**: your own account (shown as `diffy (your account)`) or one of the organizations you own or administer. The current owner isn't listed. 3. Optionally type a **New name**. Leave it empty to keep the current name. 4. Type the repository's full name (for example `acme/website`) in the confirmation box, as `Type acme/website to confirm.` asks, and click **Transfer**. You land on the repository's settings at its new address, with `Transferred acme/website to acme-labs/website.` When you can't transfer it, the section says why instead: `Only diffy can transfer this repository.` for someone else's personal repository, or `You don't own or administer another account or organization to transfer it to.` ### Transfer with the API ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/transfer \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"new_owner": "acme-labs", "new_name": "site"}' ``` | Field | Meaning | | --- | --- | | `new_owner` | Required. The login of your own account or of an organization you own or administer. | | `new_name` | Optional. A new name for the repository; it follows the [naming rules](#repository-names). Default: keep the current name. | The response is `202 Accepted` with the repository object at its new owner and name. The transfer is complete when the response arrives. GitHub's `team_ids` field isn't used, since Diffinery has no teams. ### What moves with the repository The repository keeps its id, so everything stored in it moves: Git history and Git LFS objects, issues, pull requests, reviews, releases, the merge queue, Actions runs, logs, artifacts and caches, repository secrets and variables, environments, webhooks, branch protection rules and watchers. Its numeric id in the API and in webhook payloads doesn't change. - **Access.** Direct collaborators keep their roles. Access that came from the old organization is removed: its members' roles (including the admin grant of the member who created the repository), their agents' and the organization's own agents'. The new owner's access applies instead: for an organization, its owners and admins get admin and its members get its base permission; for your own account, you are admin. - **Organization secrets and variables.** The old organization's secrets and variables no longer reach the repository's workflows; the new organization's do. Repository and environment secrets stay. - **Container images.** Images linked to the repository move to the new owner with it and keep following its access (see [Container registry](https://diffinery.com/docs/packages/container-registry#when-the-repository-moves)). Images that aren't linked to it stay with the old owner. - **Source archives.** Zipballs and tarballs are built again with the new owner and name in their folder name. - **Webhooks.** They keep firing, with the new `full_name` and URLs in their payloads. No `repository` event is sent for the transfer itself. - **Tokens.** Personal access tokens limited to the repository keep working, since they refer to it by id. ### Old URLs after a transfer or rename The old owner/name keeps working for anyone who can still read the repository, as on GitHub. Everyone else gets the same answer as for the repository under its new name (`404`, or `401` without credentials), so a redirect never shows that a private repository exists: | Where | What happens under the old name | | --- | --- | | Web pages | `301` to the same page under the new name, query string kept | | REST API | `301` with the new URL in `Location` (and `url` in the body) for `GET` and `HEAD`, `307` for other methods so the method and body are kept, like GitHub | | Git | Clones, fetches, pushes and Git LFS are served under the old name, with no redirect; a push prints `This repository moved. Please use the new location:` and the new URL | | MCP tools | Served as if you had named the new owner and name | | Reusable workflows | `uses: acme/website/.github/workflows/build.yml@main` still finds the moved repository's workflow | | Container images | `diffinery.com/<old owner>/<image>` resolves to the moved image, for pulls and pushes | Git is served rather than redirected because Git follows redirects only on its first request and rebuilds its credentials from the new URL, which would drop a token embedded in the remote URL. Update remotes when you can: ```sh git remote set-url origin https://diffinery.com/acme-labs/site.git ``` The redirect ends for good when a repository is created at the old owner and name (by anyone who can create one there), and when the moved repository is deleted. A renamed repository leaves the same redirect, and so does every repository of a renamed user or organization (see [Rename an organization](https://diffinery.com/docs/accounts/organizations#rename-an-organization)). ### After a transfer - The transfer is recorded in the audit log as `repo.transfer`, with the new name as the target and `from` and `to` in the detail. - The admins of both owners (for a personal account, the person; for an organization, its owners and admins) get an email such as `[acme-labs/website] Repository transferred from acme/website`, with the `git remote set-url` command to run, except whoever made the transfer. See [Notifications](https://diffinery.com/docs/accounts/notifications). ### Transfer errors | Message | Cause and fix | | --- | --- | | `new_owner is required` (422) | Send `new_owner`. | | `no user or organization named acme-labs` (422) | Check the login. | | `you can transfer repositories only to your own account or to an organization you own or administer` (403) | Ask an owner of the target organization to make you an admin, or to do the transfer. | | `only the owner can transfer a personal repository` (403) | Only the account that owns a personal repository can transfer it. | | `repositories can only be transferred to a person or an organization` (422) | The target is an agent or a disabled account. | | `acme/website already belongs to acme` (422) | The repository already has that owner. To change only its name, [rename it](#rename-a-repository). | | `acme-labs already has a repository named website` (422) | Pick another `new_name`, or rename or delete the other repository first. | | `acme-labs already has a container image named website; this repository's image of that name would replace it` (422) | Delete or rename the target's image, or unlink the image from this repository first. | | `invalid repository name: use letters, numbers, '.', '-' and '_'` (422) | Fix `new_name`. | | `repository is locked by an import in progress; transfer it once the import finishes` (423) | Wait for the [GitHub import](https://diffinery.com/docs/migrate-from-github) filling the repository to finish. | | `agents and workflow tokens cannot transfer repositories` (403) | Transfer as a person, with your own token or the web UI. | | `admin access required` (403) | You need the admin role. A `write` token caps you at maintain: use an `admin` token. | | `this token is limited to repositories and can't change account settings` (403) | Use an `admin` token that isn't restricted to repositories. | | `type acme/website to confirm the transfer` | The web confirmation box didn't match the repository's full name. | | `choose the new owner` | Pick a **New owner** in the web form. | ## Start over with fresh history To restart a repository's history (for example a single new first commit before you open-source it) while keeping its issues, settings, secrets, environments and collaborators: 1. **Back up the old history** if you want to keep it: `git clone --mirror` the repository, or `git bundle create backup.bundle --all` in a mirror clone, and keep the bundle somewhere private, such as a release asset in a private repository. 2. **Rewrite the branches.** For a single first commit with today's files: `git commit-tree "$(git rev-parse 'main^{tree}')" -m "Initial commit"` gives the commit. A repository admin then force-pushes it to the default branch (`git push --force origin <sha>:main`); agents can't push to the default branch, and a branch protection rule with **Do not allow administrators to bypass these rules** refuses it until you turn that off. Delete or rewrite the other branches and tags. 3. **Delete the old pull requests.** Pull requests keep their commits fetchable through `refs/pull/<number>/head` and `refs/pull/<number>/merge`, as on GitHub. Under **Settings → General → Danger zone**, click **Delete closed pull requests**, type the repository's full name and confirm. Or `POST /api/v3/repos/{owner}/{repo}/diffinery/delete-closed-pulls`, which answers `{"pull_requests": 42, "objects": 1830, "bytes": 5242880}`. **Delete closed pull requests** permanently deletes every closed and merged pull request with its comments, reviews, review comments, timeline events, reactions and `refs/pull` refs, then [purges unreachable objects](https://diffinery.com/docs/security/push-protection#removing-a-secret-that-was-pushed), so the old commits can't be fetched by their SHA any more. It can't be undone. Issues, open pull requests (close the ones you want gone first; pull requests in the merge queue stay), branches, tags and workflow runs stay, and new pull requests and issues keep counting from the last number, so old links answer 404 instead of pointing at something new. Only repository admins (people, not agents) can do it, with a sign-in from the last 2 hours in the browser (otherwise you're asked to [Confirm it's you](https://diffinery.com/docs/security/overview#confirming-its-you)). It works for repositories of up to 300,000 objects. It is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `repo.delete_closed_pulls` with the number of pull requests and objects deleted. ## Delete a repository Deleting permanently removes the repository with its history, issues, pull requests, releases, runs, secrets, environments, webhooks and pending invitations, its LFS files and release assets, and its Actions caches, artifacts, job logs and cached source archives. It can't be undone. An archived repository can be deleted without unarchiving it first. - **Web UI**: **Settings → General → Danger zone → Delete this repository**. Type the full name (for example `acme/website`) and click **Delete this repository**. - **API**: `DELETE /api/v3/repos/acme/website` returns `204 No Content`. It needs an admin role. Agents can't delete repositories (`agents cannot delete repositories`, HTTP 403). - **Recent sign-in**: from a browser whose sign-in is more than 2 hours old, Diffinery first asks you to [Confirm it's you](https://diffinery.com/docs/security/overview#confirming-its-you) with your passkey. Transferring a repository asks the same. Deletions are recorded in the audit log as `repo.delete`. The repository's owner (for an organization, its owners and admins) gets an email, unless they deleted it themselves. ## Change the default branch Pick another branch under **Settings → General → Default branch**, or `PATCH /api/v3/repos/acme/website` with `{"default_branch": "trunk"}`. The default branch can't be deleted (pushes that try get `cannot delete the default branch`). Pushes to it from agents are always refused unless a branch protection rule for it allows agents to push. ## Watch a repository Each repository page has a **Watch** menu in the header that decides which activity notifies you: | Option | You are notified about | | --- | --- | | **Watch: participating** (default) | Only issues and pull requests you take part in or are @mentioned in. | | **Watch: all activity** | Everything in the repository. | | **Watch: ignore** | Nothing from this repository. | With the API (GitHub-compatible): ```sh # Watch all activity curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" -d '{"subscribed": true}' # Ignore the repository curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" -d '{"ignored": true}' # Back to participating only curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/subscription -H "Authorization: Bearer dfy_..." ``` `GET` on the same path returns `404` when you are on the default (participating). **Settings → Notifications** lists the repositories you watch. See [Notifications](https://diffinery.com/docs/accounts/notifications). ## Look up repositories | Task | Web | CLI | API | | --- | --- | --- | --- | | Repositories you can see | Your dashboard | `diffinery repo list` | `GET /api/v3/user/repos` | | One owner's repositories | `https://diffinery.com/acme` | `diffinery repo list --owner acme` | `GET /api/v3/orgs/acme/repos` or `GET /api/v3/users/diffy/repos` | | One repository | `https://diffinery.com/acme/website` | `diffinery repo view acme/website` | `GET /api/v3/repos/acme/website` | `GET /api/v3/repos/{owner}/{repo}` also returns your access as `permissions` (GitHub's `admin`, `maintain`, `push`, `triage` and `pull` booleans plus Diffinery's `role`) and `stats` (objects, stored bytes, refs, open issues and open pull requests). The repository page's **About** box shows the same numbers with the last push time. To find repositories by name, description or topic, use `GET /api/v3/search/repositories?q=...`. It matches words against the name, description and topics, supports the qualifiers `user:`, `org:`, `owner:`, `repo:`, `topic:`, `in:name,description,topics`, `is:public`, `is:private` and `archived:true` or `archived:false`, and only returns repositories you can read. GitHub's `stars:`, `forks:`, `language:` and similar qualifiers are accepted but ignored. See [REST API](https://diffinery.com/docs/reference/rest-api). ## Topics Topics are short labels that describe a repository, such as `docs` or `static-site`, as on GitHub. They appear in a **Topics** section on the repository home page (only when the repository has some), are returned as `topics` in every repository object, and can be searched with `topic:` (for example `GET /api/v3/search/repositories?q=topic:docs`). Admins set them in the **Topics** field on **Settings → General**, separated by spaces or commas, or with the REST API: ```sh # Read them (anyone who can read the repository) curl https://diffinery.com/api/v3/repos/acme/website/topics -H "Authorization: Bearer dfy_..." # Replace the whole list (admin role; an admin-scoped token) curl -X PUT https://diffinery.com/api/v3/repos/acme/website/topics \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ -d '{"names": ["docs", "static-site"]}' ``` Both return `{"names": [...]}`. `PUT` replaces every topic; send `{"names": []}` to remove them all. The change is recorded in the audit log as `repo.topics`. Rules, the same as GitHub's: - Names are trimmed and lowercased, and duplicates and empty names are dropped. - A topic starts with a lowercase letter or digit, then has lowercase letters, digits and `-`, up to 50 characters. Otherwise: `<name> is not a valid topic. Topics must start with a lowercase letter or number, consist of 50 characters or less, and can include hyphens.` (HTTP 422). - A repository has at most 20 topics (`a repository can have at most 20 topics`, HTTP 422). - `names` must be an array (`names must be an array`, HTTP 422). - An archived repository refuses changes. ## README The repository home page and every folder page render the folder's README (see [Browsing code](https://diffinery.com/docs/repositories/browsing-code#readmes-and-markdown)). Scripts can fetch it with GitHub's endpoints: ```sh # The README at the root of the default branch, as a contents object (base64 content) curl https://diffinery.com/api/v3/repos/acme/website/readme -H "Authorization: Bearer dfy_..." # The README of a folder, at another ref, as raw text curl "https://diffinery.com/api/v3/repos/acme/website/readme/docs?ref=v1.2.0" \ -H "Authorization: Bearer dfy_..." -H "Accept: application/vnd.github.raw" ``` | Detail | Behaviour | | --- | --- | | Which file | A file named `README`, or `README` with any one extension, in any capitalisation. `README.md` and `README.markdown` win over the others. | | `ref` | A branch, tag or commit. Defaults to the default branch. | | Response | A contents object with `name`, `path`, `sha`, `size`, `encoding: "base64"`, `content`, `html_url` and `download_url`. With `Accept: application/vnd.github.raw`, the file's text instead. | | No README | `404 Not Found`. | ## Languages `GET /api/v3/repos/acme/website/languages` returns how many bytes of each language the default branch contains, largest first, like GitHub: ```json { "TypeScript": 182344, "CSS": 20311, "Shell": 1022 } ``` The language comes from the file extension (and a few file names such as `Dockerfile` and `Makefile`). Files with an unknown extension, symbolic links, submodules and folders named `node_modules`, `vendor`, `third_party`, `dist`, `build` and `.yarn` are not counted. The result is computed once per commit of the default branch. In very large trees, counting stops after 100,000 entries and the endpoint returns what it counted. An empty repository returns `{}`. ## Source code archives You can download any branch, tag or commit as a `.zip` or `.tar.gz` file without Git. Public repositories need no credentials; private ones need read access (a token in the API, or being signed in for the web URLs). | Format | Web URL | REST API | | --- | --- | --- | | zip | `https://diffinery.com/acme/website/archive/<ref>.zip` | `GET /api/v3/repos/acme/website/zipball/<ref>` | | tar.gz | `https://diffinery.com/acme/website/archive/<ref>.tar.gz` | `GET /api/v3/repos/acme/website/tarball/<ref>` | `<ref>` is a branch, a tag, a commit SHA, or a full ref such as `refs/heads/main` or `refs/tags/v1.2.0`; names with slashes work. Leave it out of the API path (`.../zipball`) to get the default branch. Release pages link to the archives of their tag as **Source code (zip)** and **Source code (tar.gz)**, and the API returns them as `zipball_url` and `tarball_url` on tags and releases. ```sh # Download the v1.2.0 tag with a token curl -fL -o website.tar.gz -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/repos/acme/website/tarball/v1.2.0 # Download main of a public repository curl -fLO https://diffinery.com/acme/website/archive/main.zip ``` What the archive contains: - The file is named `<owner>-<repo>-<short sha>.zip` (or `.tar.gz`), for example `acme-website-3f2a9c8.zip`, and everything sits in one top-level folder of the same name, like GitHub's archives. - Files keep their executable bit, symbolic links stay links, and every entry has the commit's date. Submodules are empty folders. - The full commit SHA is stored as the archive comment (for tar, in a pax header, as `git archive` does). - Git LFS files appear as their small pointer files, not their content. - Entries with names Git refuses to check out (such as `.git` in any spelling) are left out. Unlike GitHub, the API answers with the file itself (`200`) rather than redirecting to another host; `curl -L` works either way. Diffinery builds the archive the first time a commit is requested and serves the same file afterwards, for up to 30 days; after that it is built again the next time someone asks for it. Limits: | Limit | Error | | --- | --- | | At most 24 MB of file content (uncompressed) | `repository is too large for an on-the-fly archive; clone it with git instead` (HTTP 413) | | At most 100,000 tree entries | `the tree has more than 100,000 entries; clone it with git instead` (HTTP 413) | | Folders nested at most 128 deep | `the tree is nested too deeply` (HTTP 413) | | A zip holds at most 65,535 entries | `this repository has too many files for a zip archive (more than 65,535); download the .tar.gz archive or clone it with git` (HTTP 413) | | 20 newly built archives per repository per hour (archives already built don't count) | `too many archives were built for this repository in the last hour; try again later or clone it with git` (HTTP 429) | | The ref must exist | `Not Found` (HTTP 404) | For anything bigger, clone with Git (`git clone --depth 1` is fastest; see [Git over HTTPS](https://diffinery.com/docs/repositories/git#shallow-clones)). ## Troubleshooting | Message | Cause and fix | | --- | --- | | `invalid repository name: use letters, numbers, '.', '-' and '_'` | The name breaks a [naming rule](#repository-names). | | `a repository with this name already exists` | The owner already has a repository with that name (names are case-insensitive). | | `you are not a member of this organization` | Ask an organization owner to add you before creating repositories there. | | `you can only create repositories for yourself or your organizations` | You tried to create a repository for another person's account. | | `admin access required` | Settings, collaborators and deletion need the admin role. A `write` token is capped at maintain even if you are an admin: use an `admin` token or the web UI. | | `authentication required` (HTTP 401) | You sent a request that changes something without a token or session. Even on a public repository, every write needs a signed-in account. | | `this token needs the write scope` | Your token is `read`-scoped and can only read. Create a token with the `write` or `admin` scope. | | `this token is limited to repositories and can't change account settings` | Creating a repository needs a token that isn't restricted to some repositories. Use an unrestricted `write` token or the web UI. | | `repository is archived (read-only)` | Unarchive the repository first. | | `404 repository not found` for a repository that exists | You (or the token) have no access to this private repository, or the token is restricted to other repositories. | | `visibility must be public or private` | Send `"public"` or `"private"` (or use the `private` boolean). | | `remote: This repository moved. Please use the new location:` on push | The repository was transferred or renamed. The push went through; run `git remote set-url origin <new URL>`. | | An API call answers `301` or `307` with `Moved Permanently` | The repository was transferred or renamed. Follow `Location` and update the owner and name in your scripts. See [Old URLs after a transfer or rename](#old-urls-after-a-transfer-or-rename). | | `no one has the username <name>; to invite someone new, enter their email address` | The **Add people** form found no account with that username. Enter an email address to send an invitation. | | `<name> is not a valid topic...` or `a repository can have at most 20 topics` | Follow the [topic rules](#topics). | | `repository is too large for an on-the-fly archive; clone it with git instead` | The [source archive](#source-code-archives) would hold more than 24 MB. Clone the repository instead. | | `too many archives were built for this repository in the last hour; ...` | Wait, download an archive that was already built (for example the same tag), or clone with Git. | --- # Git over HTTPS > Clone, fetch and push Diffinery repositories with Git over smart HTTP, authenticate with tokens, and what Diffinery checks and triggers on every push. Source: https://diffinery.com/docs/repositories/git Diffinery serves Git over smart HTTP at `https://diffinery.com/<owner>/<repo>.git`. Any recent Git client works: clone, fetch, push, shallow clones, tags, force pushes and branch deletion behave as on GitHub. This page covers URLs, authentication, protocol support, pull request refs, push options, what happens on every push, and the limits and differences from GitHub. There is no SSH access. Use HTTPS with a token. ## Clone URLs | What | URL | | --- | --- | | Clone, fetch and push | `https://diffinery.com/acme/website.git` | | The same without `.git` | `https://diffinery.com/acme/website` | | Web page | `https://diffinery.com/acme/website` | | Download a ref without Git | `https://diffinery.com/acme/website/archive/main.zip` (or `.tar.gz`) | Both forms of the Git URL work. Archives are covered in [Source code archives](https://diffinery.com/docs/repositories/overview#source-code-archives). The repository page shows the clone URL in its **Clone** box with a **Copy** button, and the REST API returns it as `clone_url` from `GET /api/v3/repos/acme/website`. ```sh git clone https://diffinery.com/acme/website.git cd website git push origin main ``` You need Git 2.26 or newer (see [Protocol versions](#protocol-versions)). ## Authentication Git authenticates with a personal access token (`dfy_...`) used as the HTTP password. Passkeys are only for the browser. | Operation | Public repository | Private repository | | --- | --- | --- | | Clone and fetch | No credentials needed | A token with at least the `read` scope, for an account with read access | | Push | A token with the `write` (or `admin`) scope, for an account with write access | Same | Anonymous callers can only read: they can clone and fetch public repositories, but every push needs a token. A `read`-scoped token works for clone and fetch (and for downloading [Git LFS](https://diffinery.com/docs/repositories/git-lfs) files) on any repository its account can read, and is refused for every push. Create a token under **Settings → Access tokens** (`https://diffinery.com/settings/tokens`). See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). When Git asks for credentials: - **Username**: anything. Diffinery ignores it. Your username, `x-token` or `git` all work. - **Password**: the token. Diffinery also accepts the token as the username with an empty password, and as an `Authorization: Bearer dfy_...` header. ### Let your editor handle credentials In VS Code, Cursor, Windsurf or VSCodium, the [Diffinery extension](https://diffinery.com/docs/repositories/clone-in-your-editor) adds **Clone from Diffinery** to the clone picker and answers Git's password prompts with the account you signed in with in the browser. No token to create or paste. In IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs, the [Diffinery plugin](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides) does the same from **Get from Version Control**. ### Let the CLI handle credentials The `diffinery` CLI can act as Git's credential helper for the Diffinery host, so plain `git clone`, `fetch` and `push` use the token you logged in with. Install the CLI: ```sh curl -fsSL https://diffinery.com/install | bash ``` Then: ```sh diffinery login # authorize in your browser; the token is saved to ~/.config/diffinery/config.json (mode 0600) diffinery auth setup-git # configure git to ask diffinery for credentials git clone https://diffinery.com/acme/website.git ``` `diffinery auth setup-git` runs the equivalent of: ```sh git config --global --unset-all credential.https://diffinery.com.helper git config --global --add credential.https://diffinery.com.helper "" git config --global --add credential.https://diffinery.com.helper "!diffinery auth git-credential" ``` The empty entry resets the helper list for this host, so a generic helper such as your OS keychain doesn't answer first. The helper answers only for the configured Diffinery host and protocol, with username `x-token` and the token from the config file or the `DIFFINERY_TOKEN` environment variable. It never stores anything (`store` and `erase` are no-ops), so revoking or replacing the token takes effect at once. The `diffinery` command must be on your `PATH` for Git to run it. Git only asks a credential helper (or you) for a token when the server answers `401`. Diffinery answers `401 authentication required` with a `WWW-Authenticate: Basic realm="Diffinery"` challenge to every anonymous push, to public repositories too, and to every anonymous clone or fetch of a private repository. So you can clone a public repository without credentials and push to it later: Git asks the helper for the token at the first push. `diffinery repo clone acme/website` clones with the token passed as a one-off HTTP header that is never written to `.git/config`. ### Other ways to pass the token | Method | Example | Notes | | --- | --- | --- | | Git's own helper | `git config --global credential.helper store` | Git prompts once, then stores the token in plain text in `~/.git-credentials`. An OS keychain helper (`osxkeychain`, `manager`, `libsecret`) is safer. | | Header for one command | `git -c http.extraHeader="Authorization: Bearer $DIFFINERY_TOKEN" clone https://diffinery.com/acme/website.git` | Good for CI. Nothing is saved. | | Token in the URL | `https://x-token:dfy_...@diffinery.com/acme/website.git` | Works, but the token ends up in `.git/config` and shell history. Avoid outside throwaway jobs. | Inside Diffinery Actions, `actions/checkout` authenticates with the job's own token automatically. Whether that token can push depends on the workflow's `permissions:` (see [Job tokens](#job-tokens)). ### Token scopes and roles What a push or fetch may do is the lower of your role on the repository and your token's scope: | Token scope | Acts as at most | | --- | --- | | `read` | read: clone and fetch only. Every push is refused. | | `write` | maintain: push branches, move and delete tags | | `admin` | admin: also bypass branch protection rules that let admins bypass, and bypass push protection | A token restricted to some repositories gets `404 repository not found` for every other repository. Agents' tokens follow the same rules plus the agent guardrails (see [Agents and protected branches](#agents-and-protected-branches)). ### Job tokens Each Actions job gets a token (`GITHUB_TOKEN`, starting with `djt_`) that works for Git and the REST API and stops working when the job completes. As on GitHub, what it may do over Git follows the workflow's `permissions:`: | `contents` permission | Git with the job token | | --- | --- | | `read` | Clone and fetch the job's repository | | `write` | Also push to it | | `none` | Nothing on a private repository; a public one can still be cloned, as anyone can | Without `permissions:` in the workflow, the job gets the repository's default: `contents: read` for repositories created now, write for repositories created before the setting existed. A repository admin changes the default with `PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow`; see [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions). To push from a workflow, ask for it: ```yaml permissions: contents: write ``` Pushes with a job token have these limits: - The token acts with at most the **write** role, so branch protection always applies to it. - It can never create, change or delete files under `.github/workflows/` or `.diffinery/workflows/`. Git rejects the ref with the reason: refusing to allow a GitHub App to create or update workflow `<path>` without `workflows` permission. Use a personal token for that. - If the run's code came from an agent (an agent triggered the run, pushed the branch last, or opened the pull request, or the run re-runs or follows through `workflow_run` such a run), the token acts as an agent: it can't push to the default branch or a protected branch (`agents may not push to protected or default branches; open a pull request`). - A push made with a job token starts no new `push` workflows, as on GitHub. - The token can also clone other public repositories (for example public submodules), but no other private repository. ## Protocol versions | Operation | Protocol | Notes | | --- | --- | --- | | Clone and fetch (`git-upload-pack`) | Git protocol v2 only | Git 2.26 and newer use v2 by default. | | Push (`git-receive-pack`) | Smart HTTP v0 | Works with every Git version that supports smart HTTP. | | Dumb HTTP | Not supported | | If a clone or fetch arrives without protocol v2, the server answers HTTP 400 with this message (Git itself only prints `The requested URL returned error: 400`): ```text Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2) ``` Older Git versions that support v2 (2.18 to 2.25) work once you run `git config --global protocol.version 2`. Dumb HTTP requests get HTTP 403 with `dumb HTTP protocol is not supported; update git`. Supported protocol v2 features: `ls-refs` (including `unborn`, so cloning an empty repository sets up the default branch) and `fetch` with `shallow` and `wait-for-done` (so `push.negotiate=true` works). Only SHA-1 repositories are supported (`object-format=sha1`). Request bodies compressed with gzip are accepted. Push supports `report-status`, `report-status-v2`, `delete-refs`, `side-band-64k`, `quiet`, `atomic`, `ofs-delta` and `push-options`. Signed pushes (`git push --signed`) are not supported. ## Shallow clones Shallow clones and fetches work: ```sh git clone --depth 1 https://diffinery.com/acme/website.git git clone --shallow-since=2026-01-01 https://diffinery.com/acme/website.git git fetch --depth 50 git fetch --unshallow # get the full history later ``` Differences and limits: | Option | Behaviour | | --- | --- | | `--depth <n>` | Supported. | | `--shallow-since <date>` | Supported. | | `--unshallow` | Supported. | | `--deepen <n>` | Treated like `--depth <n>` counted from the commits you fetch, not from your current shallow boundary. Use a larger `--depth` or `--unshallow` to get more history. | | `--shallow-exclude <ref>` | Not supported. The fetch fails with `--shallow-exclude is not supported; use --depth or --shallow-since`. | | `--filter=...` (partial clone, blobless or treeless clones) | Not supported. Diffinery doesn't advertise the `filter` capability, so Git warns that filtering is not recognized by the server and makes a full clone. A client that sends a filter anyway gets `partial clones (--filter) are not supported yet`. | | `--sparse` / sparse checkout | Works on the client side, but every object is still downloaded. | `actions/checkout` in Diffinery workflows uses `fetch-depth: 1` by default, like GitHub. ## Pull request refs Like GitHub, Diffinery keeps two refs for each open pull request: | Ref | Points at | | --- | --- | | `refs/pull/<n>/head` | The pull request's head commit. | | `refs/pull/<n>/merge` | A test merge commit of the head into the current base branch. It is recomputed on pull request events (opened, reopened, new commits on the head branch) and removed while the pull request conflicts with its base. | Fetch them by name: ```sh git fetch origin pull/12/head:pr-12 git checkout pr-12 # The test merge that pull_request workflows run on git fetch origin refs/pull/12/merge git log -1 FETCH_HEAD ``` To fetch every pull request head, add a refspec: ```sh git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pr/*' git fetch origin ``` You can't push to `refs/pull/`: Git reports `refs/pull/ is managed by Diffinery for pull requests`. These refs are hidden from push, so `git push --mirror` leaves them alone. `pull_request` workflows check out `refs/pull/<n>/merge`. See [Pull requests](https://diffinery.com/docs/collaboration/pull-requests). ## Push options Diffinery reads one push option: | Option | Effect | | --- | --- | | `allow-secrets=<reason>` | Push even though push protection found possible secrets. Only works for a person (not an agent) with the **admin** role on the repository, using a token with the `admin` scope. The reason (up to 200 characters) is shown in the push output, recorded in the audit log as `push.secret_bypassed`, and emailed to the pusher and the repository's admins. An empty reason doesn't count. | ```sh git push -o allow-secrets="test fixture, key is revoked" origin main ``` Other push options are accepted and ignored. See [Push protection](https://diffinery.com/docs/security/push-protection). ## What happens when you push Every push goes through these steps, in this order. If an earlier step fails, nothing after it happens. 1. **Authorization.** You must send a token: a push without credentials gets HTTP 401 (`authentication required`) with a `WWW-Authenticate` challenge, for public and private repositories alike, which makes Git ask for credentials and retry. Your account needs write access, your token the `write` or `admin` scope, and the repository must not be archived. Otherwise the server answers HTTP 403 (`write access required` when your role only allows reading, `this token needs the write scope` for a `read`-scoped token, or `repository is archived (read-only)`), or HTTP 404 if the account can't see the repository at all. Git shows these as `Authentication failed` or `The requested URL returned error: 403`. 2. **Size check.** A push larger than 100 MB (after decompression), or one whose pack unpacks to more than 512 MiB, is refused. Git shows `remote unpack failed: push exceeds 100 MB; split it into smaller pushes`, or an HTTP error if the request is stopped before it reaches Diffinery. 3. **Unpacking.** The pack is parsed and every object checked. A push over 16 MB is staged in storage as it arrives and unpacked from there in pieces, one object at a time, so its memory use depends on its largest files rather than on its size. A smaller push whose objects don't all fit in memory at once, such as a long history, is unpacked in pieces too. Either way, unpacking may hold at most 80 MiB at once; a push that needs more is refused with `pack needs more than 80 MiB of memory to unpack; split it into smaller pushes`. 4. **Push protection.** New text files are scanned for credentials such as AWS keys, GitHub and Diffinery tokens, Slack and Stripe keys, Google, Anthropic and OpenAI API keys, and private keys. If any are found, the whole push is rejected, nothing from it is stored, and the output lists each finding: ```text remote: Push blocked: possible secrets detected. remote: AWS access key in blob 3f2a9c81d0 remote: Remove the secret from your commits, or rotate it and contact an admin. ``` A file that contains the text `diffinery:allow-secret` is not scanned (use it for test fixtures). Blocked pushes are audited as `push.blocked_secret`, and the pusher gets an email (for an agent, the person who owns it). See [Push protection](https://diffinery.com/docs/security/push-protection). 5. **Ref checks.** Each updated ref is checked: valid ref name, write role, not under `refs/pull/` or the merge queue's `gh-readonly-queue/` branches, objects present, tag rules, branch protection and agent guardrails (see the next sections). With `git push --atomic`, if any ref fails, none is updated (`atomic transaction failed`). 6. **Ref update.** Each ref is moved with a compare-and-swap. If someone else pushed in between, Git shows `fetch first (stale info)`. 7. **After the update**, for every ref that moved: - A `push` event goes to webhooks, notifications and the audit log (`git.push`, `git.force_push` or `git.delete_ref`). - Open pull requests whose head is the pushed branch are updated (`synchronize`), stale approvals are dismissed when the base branch's rule asks for it, and the push output links them: `Updated pull request #12: https://diffinery.com/acme/website/pull/12`. - For a new branch with no pull request, the output suggests one: ```text remote: Create a pull request for 'feature/login': remote: https://diffinery.com/acme/website/compare/feature%2Flogin ``` - Workflows in `.github/workflows/` (or `.diffinery/workflows/`) of the pushed commit whose `on: push` filters match start a run. A pull request update also starts its `pull_request` workflows. A workflow file that fails to parse creates a failed run that shows the errors. If starting workflows fails, the push still succeeds and the output shows `Workflow trigger error: ...`. See [Events that trigger workflows](https://diffinery.com/docs/actions/events). - A push to the default branch updates the repository's `on: schedule` workflows. - A merge queue whose base branch moved is rechecked. See [Merge queue](https://diffinery.com/docs/collaboration/merge-queue). Deleting a branch or tag sends the `push` event but starts no workflows. ## Branches, force pushes and deletion On a branch without a protection rule, anyone with write access can create, update, force push and delete it: ```sh git push origin feature/login # create or update git push --force-with-lease origin feature/login git push origin --delete feature/login # delete ``` The default branch can never be deleted (`cannot delete the default branch`). Change the default branch first under **Settings → General**. A force push or a deleted branch doesn't delete the commits it leaves behind: anyone who can read the repository can still fetch them by SHA. To remove a pushed secret for good, see [Removing a secret that was pushed](https://diffinery.com/docs/security/push-protection#removing-a-secret-that-was-pushed). On a branch that matches a [branch protection](https://diffinery.com/docs/repositories/branch-protection) rule: | Rule | Push refused with | | --- | --- | | **Require a pull request before merging** | `protected branch: changes must be made through a pull request` | | Force push without **Allow force pushes** | `protected branch: force push not allowed` | | Deletion without **Allow deletion** | `protected branch: deletion not allowed` | | **Require linear history** and the push adds a merge commit | `protected branch: merge commits not allowed (linear history)` | | **Required status checks** and the new commit hasn't passed one | `protected branch: required check '<name>' has not run on <sha>; push to another branch and open a pull request` (or `has not finished`, or `ended with <conclusion>`) | Admins (with an `admin`-scoped token) bypass these rules on push unless the rule has **Do not allow administrators to bypass these rules**. When several rules match a branch, only one applies: the rule whose pattern is exactly the branch name (for example `main`) wins over wildcard patterns (such as `*` or `release/*`); otherwise the oldest matching rule applies. This is the same order GitHub uses. You can also create, delete and compare branches on the repository's **Branches** page (see [Browsing code](https://diffinery.com/docs/repositories/browsing-code#branches)). ## Tags ```sh git tag -a v1.2.0 -m "Release 1.2.0" git push origin v1.2.0 # or: git push --tags git push origin --delete v1.2.0 ``` | Action | Needs | | --- | --- | | Create a new tag | write | | Move an existing tag (force push it) | maintain | | Delete a tag | maintain | Otherwise Git shows `only maintainers may move tags`. Branch protection rules don't apply to tags. Lightweight and annotated tags both work, and the **Tags** page lists them. Tags and the other Diffinery features: - **Releases.** A release points at a tag. Publishing a release (not saving a draft) for a tag that doesn't exist yet creates the tag at the release's target (the default branch unless you choose another), with the same checks as pushing it, and the tag push starts `on: push` workflows like any other. Deleting a release keeps its tag. See [Releases](https://diffinery.com/docs/collaboration/releases). - **Archives.** Every tag can be downloaded as `https://diffinery.com/acme/website/archive/refs/tags/v1.2.0.zip` (or `.tar.gz`). The REST API returns these as `zipball_url` and `tarball_url` in `GET /api/v3/repos/acme/website/tags`. - **Environments.** A deployment environment can allow tags by pattern. Tags pushed by an agent never match those patterns, so an agent can't start a deployment by pushing a tag. See [Environments](https://diffinery.com/docs/actions/environments). - **Workflows.** Pushing a tag starts `on: push` workflows whose `tags` filters match. Publishing a release starts `on: release` workflows. See [Events that trigger workflows](https://diffinery.com/docs/actions/events). ## Agents and protected branches Agent accounts push like people, with these guardrails: - They can't push to the default branch or to any branch matching a protection rule, unless that rule has **Allow agents to push directly**. Git shows `agents may not push to protected or default branches; open a pull request`. - They can't bypass push protection, whatever their role. - Commits they create through the REST API or MCP (without a Git client) are stamped with a `diffinery-agent` commit header naming the agent and the person it acts for. So an agent's normal flow is: push a branch, then open a pull request. See [Agents](https://diffinery.com/docs/agents/overview). ## Limits and differences from GitHub | Topic | Diffinery | | --- | --- | | Transport | HTTPS only. No SSH and no `git://`. | | Fetch protocol | Protocol v2 only (Git 2.26+, or `protocol.version=2`). | | Push size | 100 MB per push. Split bigger histories (see [Troubleshooting](#troubleshooting)). | | Large files | Use [Git LFS](https://diffinery.com/docs/repositories/git-lfs). | | Partial clone, `--shallow-exclude` | Not supported yet. | | `--deepen` | Behaves like `--depth`. | | Packs sent to clients | Sent without deltas, so clones and fetches download more bytes than from GitHub. | | Signed pushes | Not supported. Signed commits push normally. | | `GITHUB_TOKEN` in workflows | Follows `permissions:` as on GitHub (`contents: write` to push), but new repositories default to read-only, it never changes workflow files, and code from an agent is held to the agent rules. | | Repository rename or transfer | The old URL keeps working, as on GitHub: clones, fetches and pushes are served under the old name, and a push prints `This repository moved. Please use the new location:` with the new URL. Update remotes anyway. | | `refs/pull/*` | Same as GitHub: fetchable, not pushable, hidden from `push --mirror`. | | Ref names | Must start with `refs/` and follow Git's ref name rules, otherwise `invalid ref name`. | ## Troubleshooting Git shows the server's message for errors that happen inside the Git protocol (`remote error: ...`, `remote rejected`, `remote: ...` lines). For HTTP-level errors (401, 403, 404, 400) it only shows the status code; the table gives the server's reason for each. | What you see | Cause | Fix | | --- | --- | --- | | `remote: Push refused: acme has reached its storage limit of N bytes ...` and every ref `[remote rejected] ... (storage limit reached)` | The push brings new objects and the repository owner is at its [storage quota](https://diffinery.com/docs/reference/limits#account-quotas) (git objects, Git LFS objects, release assets and packages, 10 GB by default). Nothing from the push is kept. | Free space: delete releases, assets, image versions or repositories, or delete branches and then [purge unreachable objects](https://diffinery.com/docs/security/push-protection#removing-a-secret-that-was-pushed). Or ask a site administrator to raise the quota. Pushes that only delete or move refs still work. | | `The requested URL returned error: 400` on clone or fetch | Git is older than 2.26 or protocol v2 is turned off. The server's message is `Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2)`. | Update Git, or run `git config --global protocol.version 2`. | | `Authentication failed` after entering a password | You entered a password instead of a token, the token expired or was revoked (`invalid, expired or revoked token`), or it isn't a `dfy_` token (`unrecognised token; create one under Settings → Access tokens`). | Create a token under **Settings → Access tokens** and use it as the password. Clear the old one from your credential helper (for example `git credential reject`, or your keychain). | | `repository '...' not found` for a repository you can see in the browser | The token belongs to another account, is restricted to other repositories, or Git sent stale credentials. | Check with `diffinery whoami` and `diffinery token list`. | | `The requested URL returned error: 403` on push | Your role or your token's scope only allows reading (`write access required`, `this token needs the write scope`), or the repository is archived (`repository is archived (read-only)`). | Ask an admin for write access, use a `write` token, or unarchive the repository. | | Git asks for a username and password when you push to a public repository you cloned without credentials | Expected: anonymous pushes get `401`, so Git asks for credentials. | Enter any username and your token as the password, or set up a credential helper with `diffinery auth setup-git`. | | `The requested URL returned error: 403` on push from a workflow | The job's token has no `contents: write` (`Resource not accessible by integration`). | Add `permissions: contents: write` to the workflow or job, or have an admin change the repository's default workflow permissions. | | `[remote rejected] ... (refusing to allow a GitHub App to create or update workflow ...)` | A workflow's job token tried to push a change to a workflow file. | Push workflow changes with a personal or agent token instead. | | `remote unpack failed: the push needs more memory than one push may use; split it into smaller pushes (...)` | Unpacking or storing this push ran out of the memory one push may use, even though it is under the size limits. Pushes over 16 MB are unpacked in pieces, so this now comes from a single very large file, or a delta against one. Nothing from the push is kept. | Push history in steps, oldest first, as for the 100 MB limit below. The GitHub importer does this by itself. | | `remote unpack failed: pack needs more than 80 MiB of memory to unpack; split it into smaller pushes` | Unpacking would hold more than 80 MiB at once: files under 4 MiB inflated, plus about 768 bytes per object. Very many objects (well over 100,000) or many small files that compress well hit it before the size limits. A push unpacked in pieces (over 16 MB, or one that didn't fit) counts its caches, the files in use and about 128 bytes per object instead, so there it comes from very large files. See [Limits](https://diffinery.com/docs/reference/limits). | Push history in steps, oldest first, as for the 100 MB limit below. The GitHub importer does this by itself. | | `remote unpack failed: push exceeds 100 MB; split it into smaller pushes` | The pack for this push is larger than 100 MB. | Push history in steps, oldest first, for example `git push origin <older-commit>:refs/heads/main`, then later commits. Move large binaries to Git LFS. | | `remote: Push refused: tree <id>: duplicate entry '<name>'` (or another reason), `remote unpack failed: fsck error: ...` and `[remote rejected] ... (fsck failed)` | A tree or `.gitmodules` file in the push fails git's `fsck` checks: an entry that is empty, `.`, `..`, a spelling of `.git`, contains `/` or appears twice, a tree over 8 MiB, a commit or tag over 4 MiB, a `.gitmodules` that is a symbolic link, or a submodule whose name has `..` or whose `url` or `path` starts with `-`. Nothing from the push is kept. | Run `git fsck` locally to find the object, fix the tree or `.gitmodules` in a new commit (or rewrite the commit that added it), and push again. | | `[remote rejected] ... (protected branch: required check '<name>' has not run on <sha>; ...)` | The branch requires status checks, and the commit you pushed hasn't passed them. | Push to another branch and open a pull request, or push a commit that already passed the checks. | | `[remote rejected] ... (secret detected (push protection))` | A pushed file contains something that looks like a credential. | Remove it from every commit in the push (`git commit --amend`, or `git rebase -i`), rotate the credential, and push again. See [Push protection](https://diffinery.com/docs/security/push-protection). | | `[remote rejected] ... (fetch first (stale info))` | The branch moved since you fetched. | `git pull --rebase` (or fetch and merge), then push. | | `[remote rejected] ... (missing objects)` | The push referenced objects the server doesn't have, often after a shallow clone. | Fetch the full history (`git fetch --unshallow`) and push again. | | `[remote rejected] ... (agents may not push to protected or default branches; open a pull request)` | An agent pushed to a protected or default branch. | Push to another branch and open a pull request. | | `remote error: --shallow-exclude is not supported; use --depth or --shallow-since` | `--shallow-exclude` isn't supported. | Use `--depth` or `--shallow-since`. | | `the receiving end does not support --signed push` | Signed pushes aren't supported. | Push without `--signed`. | | `warning: filtering not recognized by server, ignoring` | Partial clones aren't supported. | Nothing to do; Git made a full clone. | --- # Clone in VS Code, Cursor and other editors > Add "Clone from Diffinery" to VS Code, Cursor, Windsurf and VSCodium, sign in with your passkey, and clone, push and publish without tokens. Source: https://diffinery.com/docs/repositories/clone-in-your-editor When you clone a repository in VS Code or Cursor, the clone picker offers **Clone from GitHub**. The Diffinery extension adds **Clone from Diffinery** next to it, lists your repositories, and signs Git in for you, so clone, pull and push work without a token or password. It works in every editor built on VS Code: VS Code, Cursor, Windsurf, VSCodium and others. ![VS Code's clone picker: "Provide repository URL or pick a repository source", with Clone from Diffinery listed under remote sources.](https://diffinery.com/docs/images/editor-clone-picker.webp) ## 1. Install the extension Run this in a terminal on the computer with the editor. It installs the extension into every VS Code-based editor it finds: ```sh curl -fsSL https://diffinery.com/install-editor | bash ``` On Windows, in PowerShell: ```powershell powershell -c "irm https://diffinery.com/install-editor.ps1 | iex" ``` To install into one editor only, use the editor's own command: | Editor | Command | | --- | --- | | VS Code | `curl -fsSLo diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix && code --install-extension diffinery.vsix` | | Cursor | `curl -fsSLo diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix && cursor --install-extension diffinery.vsix` | | Windsurf | `curl -fsSLo diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix && windsurf --install-extension diffinery.vsix` | | VSCodium | `curl -fsSLo diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix && codium --install-extension diffinery.vsix` | Without a terminal: download [diffinery.vsix](https://diffinery.com/-/vscode/diffinery.vsix), open the **Extensions** view, choose **…** (Views and More Actions) → **Install from VSIX…**, and pick the file. The extension comes from diffinery.com rather than a marketplace, and keeps itself up to date from there: once a day it checks for a newer build and offers to install it (checked against the SHA-256 that diffinery.com publishes). ## 2. Clone a repository 1. Open the Command Palette (`Ctrl+Shift+P`, or `Cmd+Shift+P` on a Mac) and run **Git: Clone**, or choose **Clone Repository** on the editor's welcome page. 2. Choose **Clone from Diffinery**. 3. The first time, the editor asks to sign in with Diffinery. Choose **Allow**, then **Sign in to diffinery.com** (or **Use another Diffinery server…** for a [self-hosted Diffinery](#self-hosted-diffinery)): your browser opens Diffinery's consent page. 4. On the consent page, check that it says **Diffinery extension for VS Code and Cursor**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the editor. 5. Back in the editor, pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link. 6. Choose a folder. The editor clones the repository and opens it. ![Diffinery's consent page for the editor extension: "Connect Diffinery extension for VS Code and Cursor to Diffinery", an app published by diffinery.com that sends you back to an app on this computer, with Act as set to you and Write access selected.](https://diffinery.com/docs/images/editor-consent.webp) ![The Diffinery repository list in VS Code's clone picker, each repository with whether it's private or public and its description.](https://diffinery.com/docs/images/editor-repositories.webp) **Diffinery: Clone Repository** in the Command Palette goes straight to the Diffinery list. ## Check that it works - The **Accounts** menu (the person icon at the bottom of the Activity Bar) shows your Diffinery login. - In the cloned repository, `git pull` and `git push` in the editor's terminal or the Source Control view work without asking for a password. - On diffinery.com, **Settings → Access tokens → Connected apps** lists **Diffinery extension for VS Code and Cursor**. ## Open in your editor from diffinery.com Every repository's **Clone** box on diffinery.com has **Open in VS Code**, **Cursor**, **Windsurf** and **IntelliJ IDEA** buttons. They use the editors' own clone links, so they work even without the extension: your browser asks to open the editor, and the editor asks where to clone. For a private repository, the extension (or, in IntelliJ IDEA, the [JetBrains plugin](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides)) signs Git in when Git asks; without it, Git asks for your username and an [access token](https://diffinery.com/docs/accounts/tokens). The links follow this pattern, if you want to put them in a README or a script: ```text vscode://vscode.git/clone?url=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git cursor://vscode.git/clone?url=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git windsurf://vscode.git/clone?url=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git ``` ## Publish a local repository to Diffinery In a repository that has commits but no remote, choose **Publish Branch** in the Source Control view (or run **Git: Publish to…**) and pick **Publish to Diffinery**. Choose the owner (you or one of your organizations), the name and whether it's private or public. The extension creates the repository, adds it as the `origin` remote and pushes the branch. ## How sign-in and Git passwords work - Signing in uses Diffinery's [OAuth sign-in](https://diffinery.com/docs/connect/how-sign-in-works), the same one Claude and Codex use. The extension never sees your passkey. It gets a token that lasts 8 hours and renews itself while the editor runs; after 30 days unused the connection lapses and the editor asks you to sign in again. - When Git needs a password for diffinery.com, the editor answers with your signed-in account. Git may save that password in your computer's credential helper (the macOS keychain, Git Credential Manager on Windows, libsecret on Linux). The extension keeps that saved copy current each time the token renews, and removes it when you sign out, so Git in a terminal keeps working while the editor is open. - **Diffinery: Sign Out**, or signing out from the Accounts menu, disconnects the editor on Diffinery too. **Disconnect** under **Settings → Access tokens → Connected apps** does the same from the website. - **Diffinery: Sign In with an Access Token** uses a token you paste instead, for an [agent](https://diffinery.com/docs/agents/overview) account or a browser-based editor. Create one under **Settings → Access tokens** with Write access. A pasted token isn't renewed and isn't revoked when you sign out. ## Settings | Setting | Default | What it does | | --- | --- | --- | | `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to sign in to. For a self-hosted Diffinery, its address; see below. | | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account, and keep Git's saved copy current. | | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build of the extension. | ## Self-hosted Diffinery The extension works with any Diffinery, such as one your company runs at `https://git.example.com`. Install it from your server, so it also updates itself from there: ```sh curl -fsSL https://git.example.com/install-editor | bash ``` On Windows: `powershell -c "irm https://git.example.com/install-editor.ps1 | iex"`. Then point the extension at your server, in any of these ways: - **Installed with your server's script:** when the extension starts, it asks **The Diffinery extension was installed from https://git.example.com. Sign in, clone and push there?** Choose **Use git.example.com**. (Editors can't set a setting from their command line, so the script leaves the address in `~/.config/diffinery/editor-server`, or `%USERPROFILE%\.config\diffinery\editor-server` on Windows, and the extension asks once.) - **The first sign-in:** the extension asks **Which Diffinery do you use?** Choose **Use another Diffinery server…** and enter the address, such as `git.example.com`. - **Any time:** run **Diffinery: Use Another Server…** from the Command Palette and enter the address. Enter `diffinery.com` to go back. - **By hand:** set `diffinery.serverUrl` to `https://git.example.com` in your user settings. The extension checks that a Diffinery answers at the address before it uses it (an address must use `https`, except `http://localhost` for a server on your computer). Each server keeps its own sign-in, so switching back doesn't sign you out. Git's passwords are answered for the server in `diffinery.serverUrl` only. ## JetBrains IDEs IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs have their own Diffinery plugin, which adds **Diffinery** to **Get from Version Control** with the same passkey sign-in: see [Clone in JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides). ## Other editors and Git tools Any Git client clones Diffinery repositories over HTTPS with your username and an access token: see [Git](https://diffinery.com/docs/repositories/git). For Git in any terminal, even with the editor closed, let the [`diffinery` CLI](https://diffinery.com/docs/repositories/git#let-the-cli-handle-credentials) answer Git's password prompts: `diffinery login`, then `diffinery auth setup-git`. Git then asks the CLI first, and the editor's sign-in is used only inside the editor. ## Troubleshooting | What you see | What to do | | --- | --- | | No **Clone from Diffinery** in the picker | Check the extension is installed and enabled in the Extensions view, then reload the window (**Developer: Reload Window**). It needs the editor's built-in Git support (`git.enabled`). | | The browser opens but the editor doesn't continue after **Connect** | The browser hands the answer to the editor on a one-time address on your computer (`127.0.0.1`). Make sure the editor is still running and the sign-in notification is still showing, then try again. A firewall or proxy that blocks local addresses stops it; use **Diffinery: Sign In with an Access Token** instead. | | "This app isn't registered with Diffinery" on the consent page | `diffinery.serverUrl` points at a server that doesn't have the editor sign-in yet. Use **Diffinery: Sign In with an Access Token**, or update the server. | | Git still asks for a password | Check `diffinery.gitAuthentication` is on, and that the remote URL is on diffinery.com (`git remote -v`). An old saved password for diffinery.com in your keychain can win; remove it, and the extension's renewals keep the new one current. | | `Authentication failed` once after the editor was closed for hours | Git used a saved token that had expired; it has now forgotten it. Run the command again, from the editor. | | An editor that isn't listed (another VS Code fork) | It works the same way: the extension needs only the editor's built-in Git support. | ## For AI agents reading this page - To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](https://diffinery.com/docs/agents/mcp)'s tools. The extension is for people using an editor. - When a person asks how to open a Diffinery repository in VS Code, Cursor or Windsurf, point them to this page: they run `curl -fsSL https://diffinery.com/install-editor | bash`, then **Git: Clone → Clone from Diffinery**. Don't try to complete the browser sign-in for them. - For a self-hosted Diffinery, they run the same script from their server (`curl -fsSL https://git.example.com/install-editor | bash`) and choose **Use git.example.com** when the editor asks, or run **Diffinery: Use Another Server…**. Don't edit their `settings.json` for them. - For connecting an editor's AI (Cursor's agent, VS Code's Copilot Chat) to Diffinery's MCP server, see [Other apps](https://diffinery.com/docs/connect/other-apps). --- # Clone in JetBrains IDEs > Add Diffinery to Get from Version Control in IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs, sign in with your passkey, and clone, push and share without tokens. Source: https://diffinery.com/docs/repositories/clone-in-jetbrains-ides When you clone a repository in IntelliJ IDEA, PyCharm, WebStorm, GoLand or another JetBrains IDE, **Get from Version Control** lists GitHub and GitLab. The Diffinery plugin adds **Diffinery** to that list, shows your repositories, and signs Git in for you, so clone, pull and push work without a token or password. It works in every JetBrains IDE from version 2024.3 on, including PhpStorm, RubyMine, CLion, Rider, RustRover and Android Studio. ![Get from Version Control in IntelliJ IDEA with Diffinery listed under GitHub, GitHub Enterprise and GitLab, showing "Sign in to Diffinery" with a Sign In with Browser button.](https://diffinery.com/docs/images/jetbrains-get-from-vcs.webp) ## 1. Install the plugin The plugin comes from diffinery.com rather than the JetBrains Marketplace. Add diffinery.com as a plugin repository once, and the IDE installs the plugin and its updates from there like any other: 1. Open **Settings** (`Ctrl+Alt+S`, or **IntelliJ IDEA → Settings…** on a Mac) and choose **Plugins**. 2. Choose the gear icon (⚙) next to the **Installed** tab, then **Manage Plugin Repositories…**. 3. Choose **+**, paste this address, and choose **OK**: ```text https://diffinery.com/-/jetbrains/updatePlugins.xml ``` 4. On the **Marketplace** tab, search for **Diffinery** and choose **Install**. 5. The IDE says that Diffinery isn't a JetBrains plugin. Choose **Accept**, then **OK** to close Settings. The plugin starts right away; no restart is needed. ![The Plugins page of IntelliJ IDEA's Settings, with Diffinery found on the Marketplace tab and its Install button.](https://diffinery.com/docs/images/jetbrains-plugins.webp) To install from a terminal instead, close the IDE and run its command-line launcher with the plugin's ID and the repository address, for example for IntelliJ IDEA: ```sh idea installPlugins com.diffinery https://diffinery.com/-/jetbrains/updatePlugins.xml ``` Use `pycharm`, `webstorm`, `goland` and so on for the other IDEs, or the script in the IDE's `bin` folder, such as `bin/idea.sh` on Linux. The IDE asks you to accept the plugin the next time it starts. Without the plugin repository: download [diffinery-jetbrains.zip](https://diffinery.com/-/jetbrains/diffinery-jetbrains.zip), then choose the gear icon (⚙) on the **Plugins** page → **Install Plugin from Disk…** and pick the file. A plugin installed this way doesn't update itself. ## 2. Clone a repository 1. Choose **File → New → Project from Version Control…**, or **Clone Repository** on the welcome screen. 2. Choose **Diffinery** in the list on the left. 3. The first time, choose **Sign In with Browser…**. Your browser opens Diffinery's consent page. (For a [self-hosted Diffinery](#self-hosted-diffinery), choose **Use another server…** first.) 4. On the consent page, check that it says **Diffinery plugin for JetBrains IDEs**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the IDE. 5. Pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link. 6. Check the **Directory** and choose **Clone**. The IDE clones the repository and opens it. ![Diffinery's consent page for the JetBrains plugin: "Connect Diffinery plugin for JetBrains IDEs to Diffinery", an app published by diffinery.com that sends you back to an app on this computer, with Act as set to you and Write access selected.](https://diffinery.com/docs/images/jetbrains-consent.webp) ![The Diffinery page of Get from Version Control, signed in to diffinery.com as ada, with a search for "hello" finding the private repository ada/hello-editor and the directory it clones into.](https://diffinery.com/docs/images/jetbrains-repositories.webp) **Git → Clone from Diffinery…** goes straight to the Diffinery page. ## Check that it works - **Settings → Version Control → Diffinery** shows your login. - In the cloned project, **Git → Pull…** and **Git → Push…** work without asking for a password. - On diffinery.com, **Settings → Access tokens → Connected apps** lists **Diffinery plugin for JetBrains IDEs**. ## Open in IntelliJ IDEA from diffinery.com Every repository's **Clone** box on diffinery.com has an **IntelliJ IDEA** button. It opens the IDE through JetBrains Toolbox, which asks where to clone. For a private repository, the plugin signs Git in when Git asks; without it, Git asks for your username and an [access token](https://diffinery.com/docs/accounts/tokens). The link follows this pattern: ```text jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git ``` ## Share a local project on Diffinery In a project that's a Git repository with at least one commit, choose **Git → Share Project on Diffinery…**. Choose the owner (you or one of your organizations), the name, and whether it's private. The plugin creates the repository, adds it as the `origin` remote (or `diffinery`, if `origin` is taken), and pushes the current branch. A notification links to the new repository. ![The Share Project on Diffinery dialog: owner ada, repository name difference-engine, and Private ticked.](https://diffinery.com/docs/images/jetbrains-share.webp) ## How sign-in and Git passwords work - Signing in uses Diffinery's [OAuth sign-in](https://diffinery.com/docs/connect/how-sign-in-works), the same one Claude and Codex use. The IDE never sees your passkey. It gets a token that lasts 8 hours and renews itself while the IDE runs; after 30 days unused the connection lapses and the IDE asks you to sign in again. - The token is kept in the IDE's password storage (your system keychain, or the IDE's own encrypted file, as set under **Settings → Appearance & Behavior → System Settings → Passwords**). - When Git, run by the IDE, needs a password for diffinery.com, the plugin answers with your signed-in account. Git in a terminal outside the IDE doesn't ask the plugin: let the [`diffinery` CLI](https://diffinery.com/docs/repositories/git#let-the-cli-handle-credentials) answer there (`diffinery login`, then `diffinery auth setup-git`). - **Sign out** on the Diffinery page of Get from Version Control, or in **Settings → Version Control → Diffinery**, disconnects the IDE on Diffinery too. **Disconnect** under **Settings → Access tokens → Connected apps** on diffinery.com does the same from the website. - **Use an access token…** signs in with a token you paste instead, for an [agent](https://diffinery.com/docs/agents/overview) account or a computer without a browser. Create one under **Settings → Access tokens** with Write access. A pasted token isn't renewed, and isn't revoked when you sign out. ## Settings **Settings → Version Control → Diffinery** shows the account you're signed in with, with **Sign In with Browser…**, **Use an access token…** and **Sign out**, and the **Server**: `https://diffinery.com` unless you use a [self-hosted Diffinery](#self-hosted-diffinery). Changing the server signs you out. ## Self-hosted Diffinery The plugin works with any Diffinery, such as one your company runs at `https://git.example.com`. 1. Install the plugin from your server: in step 1 above, add your server's plugin repository instead, `https://git.example.com/-/jetbrains/updatePlugins.xml`. The IDE then installs updates from your server. From a terminal: `idea installPlugins com.diffinery https://git.example.com/-/jetbrains/updatePlugins.xml`. 2. Open **Get from Version Control → Diffinery**. Under **Sign In with Browser…**, the page shows **Server: diffinery.com** and: - **Use git.example.com**, when the IDE installs plugins from that server's repository. One click switches to it. - **Use another server…**, which asks for the address (such as `git.example.com`), checks that a Diffinery answers there, and switches to it. Enter `diffinery.com` to go back. 3. The page now shows **Server: git.example.com**. Choose **Sign In with Browser…**; your server's consent page opens. **Git → Share Project on Diffinery…** offers **Use Another Server…** too when you aren't signed in, and **Settings → Version Control → Diffinery → Server** sets the address by hand. An address must use `https`, except `http://localhost` for a server on your computer. Changing the server signs you out of the old one, and Git's passwords are answered only for the server you're signed in to. ## Troubleshooting | What you see | What to do | | --- | --- | | No **Diffinery** in Get from Version Control | Check the plugin is installed and enabled under **Settings → Plugins → Installed**. It needs the IDE's bundled Git plugin, which is on by default. | | Searching the Marketplace tab doesn't find Diffinery | Check the plugin repository address under ⚙ → **Manage Plugin Repositories…**: `https://diffinery.com/-/jetbrains/updatePlugins.xml`. Behind a proxy, set it under **Settings → Appearance & Behavior → System Settings → HTTP Proxy**. | | The browser opens but the IDE doesn't continue after **Connect** | The browser hands the answer to the IDE on a one-time address on your computer (`127.0.0.1`). Make sure the IDE's **Sign In to Diffinery** progress is still showing, then try again. A firewall or proxy that blocks local addresses stops it; use **Use an access token…** instead. | | "This app isn't registered with Diffinery" on the consent page | The **Server** in **Settings → Version Control → Diffinery** points at a server without the JetBrains sign-in. Use **Use an access token…**, or update the server. | | Git still asks for a password | Check that the remote is on the server you're signed in to (**Git → Manage Remotes…**). A password saved earlier for diffinery.com in the IDE's password storage or Git's credential helper can win; remove it. | | "Your Diffinery sign-in has expired or was disconnected" | The connection was disconnected on diffinery.com or went unused for 30 days. Choose **Sign In…** in the notification. | | An older IDE than 2024.3 | Update the IDE, or clone with the clone URL and an [access token](https://diffinery.com/docs/accounts/tokens) under **Repository URL** in Get from Version Control. | ## For AI agents reading this page - To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](https://diffinery.com/docs/agents/mcp)'s tools. The plugin is for people using a JetBrains IDE. - When a person asks how to open a Diffinery repository in IntelliJ IDEA, PyCharm or another JetBrains IDE, point them to this page: they add `https://diffinery.com/-/jetbrains/updatePlugins.xml` under **Settings → Plugins → ⚙ → Manage Plugin Repositories…**, install **Diffinery**, then use **Get from Version Control → Diffinery**. Don't try to complete the browser sign-in for them. - The plugin repository at `https://diffinery.com/-/jetbrains/updatePlugins.xml` names the current version and its download; the plugin's ID is `com.diffinery`. - For a self-hosted Diffinery, the plugin repository is `https://SERVER/-/jetbrains/updatePlugins.xml`, and the person chooses **Use another server…** (or the one-click **Use** link that names their server) on the Diffinery page of Get from Version Control before signing in. - For VS Code, Cursor and Windsurf, see [Clone in VS Code, Cursor and other editors](https://diffinery.com/docs/repositories/clone-in-your-editor). --- # Git LFS > Store large files with Git LFS on Diffinery: setup, authentication, the batch API, using LFS files in workflows, and limits. Source: https://diffinery.com/docs/repositories/git-lfs Diffinery has a built-in Git LFS server, so `git lfs push`, `git lfs pull` and clones of repositories that use LFS work against `https://diffinery.com` like they do against GitHub. Use LFS for large binary files (images, models, archives) that would otherwise bloat the Git history or exceed the 100 MB push limit. ## Set up LFS in a repository You need the `git-lfs` client installed (`git lfs version` to check) and a Diffinery token for pushing. Then use LFS as usual: ```sh git lfs install # once per machine cd website git lfs track "*.psd" "assets/video/**" git add .gitattributes assets/hero.psd git commit -m "Track design files with LFS" git push origin main ``` `git push` uploads the LFS objects first, then pushes the commits, which contain only small pointer files. There is nothing to turn on: every Diffinery repository accepts LFS objects. Cloning and checking out download LFS files automatically when `git-lfs` is installed: ```sh git clone https://diffinery.com/acme/website.git git lfs pull # fetch LFS files for the current checkout, if you skipped them ``` ## Endpoint and authentication Git LFS finds the server from the remote URL, so no configuration is needed. The endpoint is: ```text https://diffinery.com/acme/website.git/info/lfs ``` LFS uses the same credentials as Git: a personal access token as the password, through your credential helper or `diffinery auth setup-git` (see [Git over HTTPS](https://diffinery.com/docs/repositories/git#authentication)). If you also pass the token with `http.extraHeader` (as `actions/checkout` does when it keeps credentials), `git-lfs` sends that header and the batch response's `Authorization` header together on each transfer. Diffinery accepts the repeated header and uses the first value, so this works. A `Basic` credential that isn't valid base64 gets `401 malformed basic credentials`. | Operation | Needs | | --- | --- | | Download from a public repository | Nothing | | Download from a private repository | A token with at least the `read` scope, for an account with read access | | Upload | A token with the `write` or `admin` scope, for an account with write access. The repository must not be archived. | The batch request is always a `POST`, but a batch request for `download` counts as a read, so anonymous callers (on public repositories), `read`-scoped tokens and job tokens with `contents: read` can download LFS files. A batch request for `upload` and the `PUT` that sends the file are writes: | Caller | Upload refused with | | --- | --- | | No credentials | `401 authentication required` (the `git-lfs` client then asks for credentials) | | A `read`-scoped token | `403 this token needs the write scope` | | A token whose account has less than write access | `403 write access required` | | A workflow's job token without `contents: write` | `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: contents: write)` | | Anyone, on an archived repository | `403 repository is archived (read-only)` | Agents can upload LFS objects like any account with write access. Branch protection applies to the commits that reference the objects, not to the LFS upload itself. ### Disable the locking check Diffinery doesn't implement the LFS file locking API. If `git push` prints a warning that the remote doesn't support the Git LFS locking API, turn the check off for the repository: ```sh git config lfs.https://diffinery.com/acme/website.git/info/lfs.locksverify false ``` `git lfs lock` and `git lfs unlock` don't work on Diffinery. ## How the batch API works Diffinery implements the standard Git LFS batch API with the `basic` transfer adapter and SHA-256 object IDs. You normally never call it yourself, but scripts and other LFS clients can. ```http POST /acme/website.git/info/lfs/objects/batch HTTP/1.1 Host: diffinery.com Authorization: Basic eC10b2tlbjpkZnlfLi4u Accept: application/vnd.git-lfs+json Content-Type: application/vnd.git-lfs+json {"operation": "download", "objects": [{"oid": "4d7a2146...a6b0", "size": 52428800}]} ``` The response lists an action for each object: ```json { "transfer": "basic", "hash_algo": "sha256", "objects": [ { "oid": "4d7a2146...a6b0", "size": 52428800, "authenticated": true, "actions": { "download": { "href": "https://diffinery.com/acme/website.git/info/lfs/objects/4d7a2146...a6b0", "header": { "Authorization": "Basic eC10b2tlbjpkZnlfLi4u" }, "expires_in": 3600 } } } ] } ``` | Operation | Object state | Response for the object | | --- | --- | --- | | `download` | Stored | A `download` action pointing at `GET .../info/lfs/objects/<oid>`. | | `download` | Not stored | `"error": {"code": 404, "message": "object not found"}` | | `upload` | Not stored yet | An `upload` action pointing at `PUT .../info/lfs/objects/<oid>`. | | `upload` | Already stored | No action, so the client skips it. | | either | `oid` isn't 64 lowercase hex characters | `"error": {"code": 422, "message": "invalid oid"}` | Action URLs carry the same `Authorization` header as the batch request, and the transfer requests are checked again (read for downloads, write and the `write` token scope for uploads). On upload, Diffinery hashes the body and rejects it with `422 content does not match oid` if the SHA-256 differs from the object ID, so a corrupted upload is never stored. There is no separate `verify` step. At most 1,000 objects are handled per batch request; the `git-lfs` client sends 100 at a time by default. ## Storage LFS objects are stored in Diffinery's object storage (Cloudflare R2), separately from the repository's Git objects. They are stored once per repository and deduplicated by SHA-256. The repository's **Size** in the **About** box counts Git objects only, not LFS files. ## LFS files in the web UI, the API and archives Outside the LFS endpoints, Diffinery sees only the small pointer files that Git stores in place of LFS content: - The file view and raw URLs (`/acme/website/blob/main/assets/hero.psd`, `/acme/website/raw/main/assets/hero.psd`) show the pointer text, not the file. - `GET /api/v3/repos/acme/website/contents/{path}` and the MCP `get_file` tool return the pointer. - [Source code archives](https://diffinery.com/docs/repositories/overview#source-code-archives) (zipball and tarball, and the **Source code** links on releases) contain the pointers. This differs from GitHub, which can include LFS content in archives. To hand out a large file to people without Git, attach it to a release instead (up to 100 MB per asset). See [Releases](https://diffinery.com/docs/collaboration/releases). ## Use LFS files in workflows As on GitHub, `actions/checkout` downloads LFS files when you set `lfs: true` (the default is `false`, which leaves pointer files in the workspace). With `submodules: true` or `recursive`, it also pulls the submodules' LFS files. Hosted runners have `git-lfs` installed: ```yaml name: Build on: push jobs: build: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v4 with: lfs: true - run: ls -lh assets/ ``` You can also download them in a later step. The checkout leaves the job's token configured for the repository's URL (unless you set `persist-credentials: false`), so `git lfs pull` works without extra credentials. To download only some files, use `git lfs pull --include="assets/video/**"`. The job token reads LFS files with `contents: read` (the default for new repositories) and uploads them with `contents: write`, as on GitHub. On self-hosted runners, install `git-lfs` on the machine first. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility). ## Moving LFS files from GitHub `git clone --mirror` and `diffinery migrate github` copy LFS pointers, not the LFS files. Copy the files yourself from a clone that has them: ```sh git clone https://github.com/acme/website.git cd website git lfs fetch --all origin git remote add diffinery https://diffinery.com/acme/website.git git lfs push --all diffinery ``` See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). ## Limits | Limit | Value | | --- | --- | | Objects per batch request | 1,000 | | Transfer adapter | `basic` only (no multipart or resumable uploads) | | Size of one LFS file | Each file is uploaded in a single request, and Cloudflare limits one request to 100 MB, so files over 100 MB can't be uploaded. | | Hash algorithm | SHA-256 | | File locking | Not supported | | Push protection | LFS file contents are not scanned for secrets, only the pointer files in the Git push. | | Storage | LFS objects count toward the repository owner's [storage quota](https://diffinery.com/docs/reference/limits#account-quotas). Past it, uploads are refused: each object in the batch response gets error `507` with the reason. | ## Troubleshooting | What you see | Cause and fix | | --- | --- | | `Remote "origin" does not support the Git LFS locking API` | Expected: locking isn't implemented. Set `lfs.<url>.locksverify` to `false` as shown in [Disable the locking check](#disable-the-locking-check). | | `object not found` when cloning or pulling | The LFS file was never uploaded to Diffinery, for example after a mirror push from GitHub. Push it from a clone that has it: `git lfs push --all origin`. | | Upload fails with HTTP 403 | Your token is `read`-scoped (`this token needs the write scope`), your role is below write (`write access required`), or the repository is archived (`repository is archived (read-only)`). Use a token with the `write` scope for an account with write access. | | Upload fails with HTTP 401 | The upload had no credentials (`authentication required`). Configure a credential helper or `diffinery auth setup-git`, as in [Endpoint and authentication](#endpoint-and-authentication). | | Upload fails for a very large file | Files over 100 MB can't be uploaded. Split the file, or keep it outside the repository. | | Workflow sees small text files instead of the real files | The checkout step ran without `lfs: true`. Add `with: lfs: true` to `actions/checkout`, or a `git lfs pull` step after it. | | Upload from a workflow fails with HTTP 403 `Resource not accessible by integration` | The job's token has no `contents: write`. Add `permissions: contents: write` to the job. | | Upload fails with HTTP 401 `malformed basic credentials` | The `Authorization: Basic` value isn't valid base64. Check how the credential is built, or send `Authorization: Bearer dfy_...`. | | `content does not match oid` | The uploaded bytes didn't match the object ID, usually a corrupted local object. Run `git lfs fsck` and push again. | --- # Browsing code > Browse files, history, branches, tags and highlighted diffs in the Diffinery web UI, edit files in the browser, and use the command palette and shortcuts. Source: https://diffinery.com/docs/repositories/browsing-code The **Code** tab of a repository lets you read files and history, compare branches, edit files without a Git client, and jump anywhere with the command palette. This page describes each code page, its URL, what it shows and its limits, plus the REST API calls that return the same data. Public repositories can be browsed without signing in. Private repositories need read access. Editing needs write access. ## Page header and avatars Every page has the same header: the Diffinery logo (your dashboard), **Jump to…** (the [command palette](#command-palette)), **Docs**, and, when you are signed in, **+** (New repository), the bell (Notifications) and your profile picture and username, which open **Settings**. Signed-out visitors see **Sign in** instead. Inside a repository, a second header shows a lock icon (private) or a globe icon (public), the owner and name, a **public** or **private** badge, an **archived** badge when the repository is archived, the **Watch** menu and the tabs. People, agents and organizations appear with their profile picture wherever an account is named, for example in the header, on issues and pull requests, on releases and in collaborator lists. Pictures are round for people and organizations and have rounded corners for agents, which also get an **agent** badge. Accounts without an uploaded picture show the first letter or digit of their username on a colored background. Commit lists show the Git author's name from the commit instead, since a commit's author doesn't have to be a Diffinery account. Upload your own picture under **Settings → Profile**; see [Profile and preferences](https://diffinery.com/docs/accounts/profile-and-preferences). ## Repository home page `https://diffinery.com/acme/website` shows the default branch: - A **branch and tag switcher**, with links to the **Branches** and **Tags** pages and their counts. - **Add file** (if you can write to the branch) and **History**. - The latest commit (author, message, short SHA and time), then the file list: folders first, then files, each sorted by name, with file sizes. Submodules are listed with the commit they point at, without a link. - The README of the directory, rendered below the file list (see [READMEs and Markdown](#readmes-and-markdown)). The sidebar has: | Section | Contents | | --- | --- | | **Clone** | The clone URL with a **Copy** button, and a reminder to use a token as the password. See [Git over HTTPS](https://diffinery.com/docs/repositories/git). | | **About** | Description, **Default branch**, **Open issues**, **Open pull requests**, **Refs**, **Objects**, **Size** (stored Git objects, compressed) and **Last push**. | | **Topics** | The repository's topics, shown only when it has some. Topics are set with the REST API; see [Topics](https://diffinery.com/docs/repositories/overview#topics). | | **Releases** | The number of releases and the latest one (its title, a **Latest** badge and when it was published). The heading links to the releases list. Without published releases it says "No releases published." and, if you have write access, links to **Create a new release**. See [Releases](https://diffinery.com/docs/collaboration/releases). | | **Shortcuts** | The main [keyboard shortcuts](#keyboard-shortcuts). | ### Empty repositories A repository without commits shows **Quick setup** instead: the clone URL, commands to create a new repository on the command line or push an existing one, and (with write access) a link to create a README in the browser. ## Files and folders | Page | URL | | --- | --- | | Folder | `/acme/website/tree/<ref>/<path>` | | File | `/acme/website/blob/<ref>/<path>` | | Raw file | `/acme/website/raw/<ref>/<path>` | `<ref>` can be a branch, a tag, a full commit SHA or an abbreviated SHA of at least 7 characters. Branch names that contain slashes work (`/acme/website/tree/feature/login/src`): Diffinery picks the longest branch or tag name that matches the start of the path. Opening a folder URL for a file, or a file URL for a folder, redirects to the right page. ### Folder view The folder view has the branch and tag switcher, breadcrumbs back to the repository root, a `..` row to go up, **Add file** and **History** (the history of that folder). The README of the folder is rendered below the list. ### File view The file header shows the number of lines, the size, and "executable" for executable files, with these buttons: | Button | Does | | --- | --- | | **Copy path** | Copies the file's path in the repository. | | **Preview** / **Code** | Markdown files only: switch between the rendered document and the source (`?plain=1`). | | **Raw** | Opens the raw file. | | **History** | Commits that changed this file. | | **Edit** | Opens the [file editor](#edit-files-in-the-browser). Shown when you have write access, you are on a branch, the file is text and at most 1 MB, and the repository isn't archived. | How the content is shown: | File | Shown as | | --- | --- | | Text up to 1 MB | Lines with line numbers and [syntax highlighting](#syntax-highlighting). Click a line number to get a link to that line (`#L42`). | | Markdown (`.md`, `.markdown`, `.mdown`, `.mkd`) | Rendered, with **Code** to see the source. | | PNG, JPEG, GIF, WebP, AVIF, ICO and BMP images | The image. | | Other binary files | "Binary file", its size, and a **Download** button. | | Text over 1 MB | "This file is too large to display", with a link to the raw file. | SVG files are shown as highlighted XML source (open **Raw** to see the image). ### Syntax highlighting Diffinery highlights code with Shiki, using GitHub's default light and dark color themes, so code looks the way it does on GitHub. Colors follow the theme you chose under **Settings → Appearance** (**Sync with system**, **Light** or **Dark**); see [Profile and preferences](https://diffinery.com/docs/accounts/profile-and-preferences). Highlighting applies to: | Where | What is highlighted | | --- | --- | | File view | The whole file, when it has at most 2,000 lines and about 200 KB of text. | | Diffs on commit, compare and pull request pages | Each file with at most 2,000 diff lines, up to 3,000 highlighted lines per page. | | Fenced code blocks in Markdown (files, READMEs, issues, pull requests, comments, release notes) | Blocks whose fence names a known language, up to 8 languages and 2,000 lines per document. | Anything beyond these limits, in an unknown language, or that fails to highlight is shown as plain text; the page always renders. Lines longer than 2,000 characters are not highlighted past that point. The language comes from the file name or extension. Diffinery knows about 60 languages and formats, including TypeScript, JavaScript (and JSX/TSX), JSON (and JSONC, JSON5), YAML, TOML, Markdown, HTML, CSS, SCSS, Sass, Less, Python, Go, Rust, Java, Kotlin, Swift, C, C++, C#, PHP, Ruby, shell scripts (`sh`, `bash`, `zsh`, `fish`), PowerShell, batch files, SQL, GraphQL, XML, Lua, Dart, Scala, R, Perl, Elixir, Erlang, Haskell, Vue, Svelte, Astro, Terraform and HCL, INI and `.env` files, Makefiles, CMake, Dockerfiles, diffs and patches, Protocol Buffers, Zig, Objective-C, Groovy, Clojure, OCaml, F#, Julia, Nix and nginx configuration. Some file names are recognised without an extension: `Dockerfile`, `Containerfile`, `Makefile`, `CMakeLists.txt`, `Gemfile`, `Rakefile`, `Jenkinsfile`, `.bashrc`, `.zshrc`, `.gitconfig`, `.editorconfig` and similar. `tsconfig.json`, `jsconfig.json` and `.vscode/*.json` are read as JSON with comments. In Markdown, write the language right after the three backticks that open the block, as on GitHub, for example `ts`, `yaml`, `c++`, `shell` or `dockerfile`. Common aliases work too (`py`, `golang`, `sh`, `console`, `pwsh`, `postgres`, `jsonl` and so on). ### Raw files `/acme/website/raw/<ref>/<path>` returns the file itself: - Images get their image content type, other binary files are sent as downloads (`application/octet-stream`), and text is sent as `text/plain; charset=utf-8`. - Raw files are served with a sandboxing Content Security Policy, so HTML and SVG files can't run scripts on `diffinery.com`. - When `<ref>` is a full commit SHA, browsers cache the response for a day. For branches and tags, for 60 seconds. Scripts can also use the REST API, which accepts a token: ```sh curl -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/repos/acme/website/raw/main/package.json ``` ## READMEs and Markdown In every folder, Diffinery renders the first file named `README`, `README.md`, `README.markdown` or `README.txt` (any capitalisation) below the file list, if it is under 1 MB. Markdown READMEs are rendered; other READMEs are shown as plain text. The same Markdown renderer is used for files, READMEs, issues, pull requests, comments and job summaries. It supports: - headings (`#` to `######`), each with a link anchor, - paragraphs and hard line breaks, - bold, italic, strikethrough and inline code, - fenced code blocks, with [syntax highlighting](#syntax-highlighting) when the fence names a known language, - blockquotes and horizontal rules, - ordered, unordered and task lists, nested, - tables, - links, images, and bare URLs that become links. Rules that differ from GitHub: - Raw HTML is always escaped and shown as text, never rendered. `<details>`, `<img>`, `<br>` and similar tags appear literally. HTML comments (`<!-- ... -->`) are hidden, as on GitHub, except inside code. - Links and images may use `http://`, `https://`, a `#fragment`, or a relative path. Other schemes (`javascript:`, `data:`, `mailto:` and so on) are dropped. - Relative links resolve against the document's folder at the same ref: links open the file or folder page, images load from the raw URL. Links that climb above the repository root are dropped. - Images from other sites are shown through Diffinery's image proxy, so the other site can't see who views the page. See [Images from other sites](https://diffinery.com/docs/security/overview#images-from-other-sites). - Documents larger than 512 KB are shown as plain text. ## Edit files in the browser You need write access. Editing works on branches only (not tags or commits), for text files up to 1 MB. ### Edit a file 1. Open the file and click **Edit** (or open `/acme/website/edit/<branch>/<path>`). 2. Change the content. To rename or move the file, change the path field at the top. 3. In **Commit changes**, edit the commit message (by default `Update` followed by the file name) and optionally add an extended description. 4. Choose where to commit: - **Commit directly to the `<branch>` branch**, or - **Create a new branch for this commit and start a pull request:**, with a branch name filled in as `<your username>-patch-<number>`. 5. Click **Commit changes**. Committing directly takes you back to the file. Committing to a new branch takes you to the compare page, where you can open the pull request (see [Compare and open pull requests](#compare-and-open-pull-requests)). When the branch is protected and your change can't go straight in, the editor says "`<branch>` is protected; your change goes to a new branch and a pull request" and offers only the new-branch option. That happens when the branch's rule requires a pull request (and you can't bypass it as an admin), and always for agents on the default branch or a protected branch unless the rule allows agents to push. The editor keeps the file's Windows line endings (CRLF) if it had them, and keeps the executable bit and symbolic links. ### Create a file Click **Add file** in a folder (or open `/acme/website/new/<branch>/<folder>`). Enter the path and content, then commit as above. In an empty repository, the Quick setup link opens `/acme/website/new/main?filename=README.md`, which creates the first commit and the default branch. ### Delete a file On the edit page, the box below the editor has **Delete file**, which deletes the file in a new commit on the branch after you confirm. ### What the commit looks like Commits made in the browser have you as author and committer, with the email `<your username>@users.noreply.diffinery.com`. They count as a push to the branch: workflows run, open pull requests from that branch are updated, and webhooks fire, exactly like a `git push`. ### Editor errors | Message | Meaning | | --- | --- | | `branch moved; refresh and retry` | Someone pushed to the branch while you were editing. Copy your change, reload, and apply it again. | | `<path> already exists` | A new file would overwrite an existing one. Edit that file instead. | | `branch <name> already exists` | Pick another name for the new branch. | | `files can only be edited on a branch` | You opened the editor on a tag or commit. Switch to a branch. | | `binary files cannot be edited in the browser` | Edit it locally and push. | | `enter a valid file path` | The path is empty or contains `..` or `.git`. | | `invalid path "<path>": ...` | A part of the path is a name Git reserves (such as `.GIT` or `git~1`), contains `\`, or is longer than 255 characters. Choose another name. | | `a commit message is required` | Fill in the commit message. | | `push protection: possible secret detected (<kind>) in <path>. Remove it, or rotate it and ask an admin.` | The new content looks like a credential, such as an API key or private key. Remove it and commit again. See [Push protection](https://diffinery.com/docs/security/push-protection). | | `repository is archived (read-only)` | The repository is archived; nothing can be committed until an admin unarchives it. | Without a browser or a Git client, you can change files with the REST API: - `PUT /api/v3/repos/{owner}/{repo}/contents/{path}` creates or updates one file, as on GitHub. Send `message`, the new `content` in base64, an optional `branch`, and, when the file exists, its current blob `sha`. A missing `sha` for an existing file fails with HTTP 422, and a `sha` that no longer matches fails with HTTP 409 (`<path> does not match <sha>`), so you never overwrite someone else's change. - `DELETE /api/v3/repos/{owner}/{repo}/contents/{path}` deletes one file, with `message` and the file's `sha`. - `POST /api/v3/repos/{owner}/{repo}/diffinery/commits` (or the MCP `commit_files` tool) changes several files in one commit. Branch protection and agent guardrails apply to all of them, as for a `git push`. Push protection does too: the web editor, `PUT .../contents`, `POST .../diffinery/commits`, MCP `commit_files` (and `POST .../git/blobs`) scan the new content for credentials before anything is written, and refuse it with HTTP 422 and `push protection: possible secret detected (...)`. A repository admin who is a person can commit a flagged file anyway through `POST .../diffinery/commits` by adding `"allow_secrets": "<reason>"`; the bypass is recorded in the audit log and emailed to the repository's admins. See [Push protection](https://diffinery.com/docs/security/push-protection), [REST API](https://diffinery.com/docs/reference/rest-api) and [MCP](https://diffinery.com/docs/agents/mcp). A workflow's `GITHUB_TOKEN` can use these endpoints when the job has `contents: write`, but it can't create, change or delete files under `.github/workflows/` or `.diffinery/workflows/`: that fails with ``refusing to allow a GitHub App to create or update workflow `<path>` without `workflows` permission``. ## Commits `/acme/website/commits/<ref>` lists the commits reachable from a branch, tag or commit, 30 per page, grouped by day ("Commits on October 13, 2026"), with **Newer** and **Older** buttons. Add `?path=src/app.ts` (the **History** button does this) to see only commits that changed that file or folder; the file history looks at the most recent 2,000 commits. Each row shows the first line of the message, the author and time, a **signed** badge when the commit carries a GPG or SSH signature, and a group of three buttons: a copy icon that copies the full SHA, the short SHA (opens the commit) and a code icon that browses the repository at that commit. > **Note:** The **signed** badge means the commit contains a signature. Diffinery doesn't check the signature against a key, so it is not the same as GitHub's "Verified". ### Commit view `/acme/website/commit/<sha>` shows: - the message (title and body), author and committer with times, the **signed** badge, - the parent commits, the full SHA with **Copy**, and **Browse files** (the tree at this commit), - the checks reported on this commit: workflow jobs link to their run or job, and checks and commit statuses reported by other tools link to the details URL they gave (see [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses)), - the diff against the first parent: files changed with additions and deletions, a **Files** list with links when more than 3 files changed, and one collapsible section per file with a **View file** button. Diff limits, on commit, compare and pull request pages: | Limit | What you see | | --- | --- | | More than 300 changed files | Only the first 300 are shown, with "showing the first 300 files". | | A file with more than 800 diff lines | The file starts collapsed. Click its header to open it. | | More than 20,000 diff lines on the page | Later files show "Diff not rendered to keep this page fast" with a link to the file. | | Binary files | "Binary file not shown." | Diff lines are syntax highlighted within the limits in [Syntax highlighting](#syntax-highlighting). ## Branches `/acme/website/branches` lists every branch, with the default branch first and the rest by name. Each row shows **default** and **protected** badges, the last commit's message, author and time (for the first 40 branches), and the head commit's short SHA. | Action | How | Needs | | --- | --- | --- | | Compare with the default branch | **Compare** on the branch's row | read | | Create a branch | **Create a branch**: enter a **Branch name**, pick **From** (an existing branch), click **Create branch** | write | | Delete a branch | The **x** button on the branch's row, then confirm | write. Not available for the default branch. Protection rules and agent guardrails apply as for `git push --delete`. | The same operations through Git and the API: ```sh git push origin main:refs/heads/release/2.0 # create from main git push origin --delete release/2.0 # delete curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/branches curl -X POST -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/git/refs \ -d '{"ref": "refs/heads/release/2.0", "sha": "<commit sha>"}' curl -X DELETE -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/repos/acme/website/git/refs/heads/release/2.0 ``` ## Tags `/acme/website/tags` lists tags in reverse name order, each with the short SHA of the commit it points at (plus the commit's message and time for the first 40 tags) and a **Compare** button that compares the tag with the default branch. Tags are created by pushing them (`git tag v1.0.0 && git push --tags`) or with `POST /api/v3/repos/{owner}/{repo}/git/refs`. See [Tags](https://diffinery.com/docs/repositories/git#tags) for who can create, move and delete them. A release is attached to a tag: open `/acme/website/releases` (the **Releases** heading in the home page sidebar) to see release notes and download assets. Publishing a release can also create its tag. See [Releases](https://diffinery.com/docs/collaboration/releases). ## Download a branch, tag or commit Every ref can be downloaded as an archive without Git, at `/acme/website/archive/<ref>.zip` or `/acme/website/archive/<ref>.tar.gz` (for example `/acme/website/archive/v1.2.0.zip`). Release pages link to these as **Source code (zip)** and **Source code (tar.gz)**. Archives hold at most 24 MB of files; contents, limits and the matching API endpoints are in [Source code archives](https://diffinery.com/docs/repositories/overview#source-code-archives). ## Compare and open pull requests `/acme/website/compare` (or **Compare** in the command palette) lets you pick a **base** and a **compare** branch or tag. The result lives at `/acme/website/compare/<base>...<head>`, for example `/acme/website/compare/main...feature/login`. It shows: - how many commits the head is ahead of and behind the base, - the commits on the head that aren't on the base (up to 250), - the combined diff from the merge base to the head. When both sides are branches, the head has new commits, and you have write access, the page has an **Open a pull request** form: | Field | Notes | | --- | --- | | **Title** | Prefilled from the commit message when there is one commit, otherwise from the branch name. | | **Intent** | What the change is meant to do, for human and AI reviewers. Optional, but agents should always fill it in. | | **Description** | Markdown. | | **Create as draft** | Opens a draft pull request. | Click **Create pull request**. If an open pull request for the same branches exists, the page links to it instead. If the head has no new commits, it says "There isn't anything to compare." See [Pull requests](https://diffinery.com/docs/collaboration/pull-requests). The API returns the same comparison: `GET /api/v3/repos/acme/website/compare/main...feature/login` (three dots, base first). ## Command palette Press **Ctrl K** (or **Cmd K** on a Mac), or click **Jump to…** in the header, to open the command palette. It is available when you are signed in. Type to filter. Matching is fuzzy: the characters you type must appear in that order, so `srcapp` finds `src/app.ts`. Use **↑** and **↓** to move, **Enter** to open, and **Esc** or a click outside to close. The palette contains: | Group | Entries | | --- | --- | | Repository (inside a repository) | Code, Issues, New issue, Pull requests, Actions, Branches, Tags, Commits, Compare, Settings | | Go to | Dashboard, New repository, Notifications, Settings, Tokens, Agents | | Repository | Every repository you can see (up to 300) | | File (inside a repository) | Files on the current branch or ref (up to 5,000; folders named `node_modules`, `.git`, `vendor`, `dist` and `target` are skipped) | | Issue / PR | Type a number such as `42` or `#42` to jump to that issue or pull request | ## Keyboard shortcuts Shortcuts don't fire while you type in a text field. | Keys | Action | Where | | --- | --- | --- | | **Ctrl K** / **Cmd K** | Open the command palette | Everywhere, when signed in | | **t** | Find a file (opens the palette) | In a repository | | **g** then **c** | Go to the code | In a repository | | **g** then **i** | Go to issues | In a repository | | **g** then **p** | Go to pull requests | In a repository | | **g** then **a** | Go to Actions | In a repository | | **g** then **s** | Go to settings | In a repository | | **g** then **d** | Go to your dashboard | Everywhere | | **g** then **n** | Go to notifications | Everywhere | | **/** | Focus the search box | Issue and pull request lists | Press the second key of a **g** sequence within 1.5 seconds. ## Live updates Code pages (files, folders, commits, branches, tags, compare) show the repository as it was when the page loaded. Reload to see new pushes. Pages about activity update live over a WebSocket while they are open: the Actions list, workflow runs, job logs (streamed line by line), issues, pull requests and the merge queue. See [Actions](https://diffinery.com/docs/actions/overview) and [Pull requests](https://diffinery.com/docs/collaboration/pull-requests). A live update only says that something changed; the page then fetches what changed with your own access, so an update never carries an event's payload or an audit entry. Access is checked when the page connects, and each connection lasts 10 minutes before the page quietly reconnects and is checked again. Someone who loses access to the repository, or a signed-out reader of a repository that was made private, stops getting updates within 10 minutes. ## The same data through the API | Data | REST API (`https://diffinery.com/api/v3`) | | --- | --- | | Folder listing or file (base64) | `GET /repos/{owner}/{repo}/contents/{path}?ref={ref}` | | Raw file | `GET /repos/{owner}/{repo}/raw/{ref}/{path}` | | Commit list | `GET /repos/{owner}/{repo}/commits?sha={ref}&path={path}&per_page=30&page=1` | | One commit with its diff | `GET /repos/{owner}/{repo}/commits/{ref}` | | Compare | `GET /repos/{owner}/{repo}/compare/{base}...{head}` | | Branches, tags | `GET /repos/{owner}/{repo}/branches`, `GET /repos/{owner}/{repo}/tags` | | README of the root or a folder | `GET /repos/{owner}/{repo}/readme?ref={ref}`, `GET /repos/{owner}/{repo}/readme/{dir}` | | Source archive | `GET /repos/{owner}/{repo}/zipball/{ref}`, `GET /repos/{owner}/{repo}/tarball/{ref}` | | Bytes per language | `GET /repos/{owner}/{repo}/languages` | | Trees and blobs by SHA | `GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1`, `GET /repos/{owner}/{repo}/git/blobs/{sha}` | The contents endpoint, like GitHub's, returns the raw file instead of JSON when you send `Accept: application/vnd.github.raw`. Lists take `per_page` (capped at 100) and `page` and send a GitHub-style `Link` header for paging, so clients such as Octokit can page through them. Every endpoint is listed in the [API reference](https://diffinery.com/docs/api). Agents can read code with the MCP tools `get_file` and `list_tree` (see [MCP tools](https://diffinery.com/docs/reference/mcp-tools)). --- # Branch protection > Protect branches with rules for pull requests, approvals, required checks, force pushes, deletion, agents and merge queues, from the web UI or the API. Source: https://diffinery.com/docs/repositories/branch-protection Branch protection rules control how matching branches can change: whether changes must go through a pull request, how many approvals and which checks a pull request needs, whether force pushes and deletion are allowed, and what agents may do. Diffinery enforces the rules on the server for every way a branch can change: `git push`, the REST API, MCP tools and the web UI. ## Concepts A rule has a **branch name pattern** and a set of **rules**. A branch is protected when a pattern matches its name. A pattern is stored per repository, and you need the **admin** role on the repository to create, change or delete rules. Two protections apply even when a repository has no rules at all: - The default branch cannot be deleted. - Agents cannot push directly to the default branch or merge pull requests into it. A rule matching the default branch can allow them (see [Agent rules](#agent-rules)). Some pull request rules also apply without any branch protection: - A pull request that has a "changes requested" review from anyone is blocked until that reviewer approves, a new review from them replaces it, or the review is dismissed. - A pull request opened by an agent needs at least one approval from a person. ### Patterns | Pattern | Matches | Does not match | |---|---|---| | `main` | `main` | `main2`, `feature/main` | | `release/*` | `release/1.0`, `release/next` | `release/1.0/hotfix`, `release` | | `release/**` | `release/1.0`, `release/1.0/hotfix` | `release` | | `**` | every branch | | | `v?` | `v1`, `v2` | `v10` | - `*` matches any characters except `/` (within one path segment). - `**` matches any characters, including `/`. - `?` matches exactly one character other than `/`. - Everything else matches literally, and the pattern must match the whole branch name. - Patterns cannot contain spaces. ### Which rule applies Only one rule applies to a branch; settings from several matching rules are never combined. When more than one pattern matches a branch, Diffinery picks the rule the same way GitHub does: 1. A rule whose pattern is exactly the branch name wins. For branch `main`, a `main` rule beats `*` and `**`. 2. Otherwise, the oldest matching rule wins: the one created first. For example, with a `**` rule created first and a `release/*` rule created later, `release/1.0` follows the `**` rule. Add a rule named exactly `release/1.0` if that branch needs its own settings. Saving an existing rule (from the web UI or with `PUT` on its pattern) keeps its age. Changing a rule's pattern in the web UI deletes the old rule and creates a new one, so the renamed rule becomes the newest. The **Branch protection** page lists rules alphabetically, not in this order. The same rule decides pushes, merges, merge blockers, the merge queue, the **protected** badge and environment branch policies that allow only protected branches. ## Rule settings These are the fields of a rule, as JSON (`ProtectionRules`) with their labels on the **Branch protection** settings page. Every field is optional; a missing field means `false` or none. On the page, a rule's settings come in sections: the branch name pattern first, then **Pull requests and reviews**, **Status checks**, **Pushes and history**, **Agents** and **Merge queue**, with **Save rule** (or **Create rule**) at the bottom. Each field has its label above it and its explanation under it. | JSON field | UI label | Type | Default | Effect | |---|---|---|---|---| | `requirePullRequest` | Require a pull request before merging | boolean | `false` | Direct pushes to matching branches are rejected, including creating a new matching branch by push. Changes land by merging pull requests. | | `requiredApprovals` | Required approvals | number | `0` | Approving reviews from people needed before a pull request can merge. The UI accepts 0 to 10. | | `dismissStaleReviews` | Dismiss stale approvals when new commits are pushed | boolean | `false` | When the pull request's branch moves, approvals given on older commits stop counting and are marked dismissed. | | `requiredChecks` | Required status checks | array of strings | none | Check run names or commit status contexts that must pass on the pull request's head commit. See [Required checks](#required-checks). | | `requiredCheckSources` | none (API only) | object | none | Binds a required check to the one account whose reports count: check name to reporter login, or `diffinery-actions`. See [Binding a check to a reporter](#binding-a-check-to-a-reporter). | | `allowForcePush` | Allow force pushes | boolean | `false` | Allows non-fast-forward updates to matching branches. | | `allowDeletion` | Allow deletion | boolean | `false` | Allows deleting matching branches. | | `requireLinearHistory` | Require linear history | boolean | `false` | Pushes containing merge commits are rejected, a pull request must be up to date with its base to merge, merging with the `merge` method is refused (`main requires linear history; use squash or rebase`), and the API's default merge method becomes squash. | | `enforceAdmins` | Do not allow administrators to bypass these rules | boolean | `false` | Applies the rules to repository admins too. | | `agentsMayPush` | Allow agents to push directly | boolean | `false` | Lets agents push to matching branches. | | `agentsMayMerge` | Allow agents to merge pull requests | boolean | `false` | Lets agents merge pull requests into matching branches. | | `allowAgentAutoMerge` | Allow agents to land pull requests through auto-merge | boolean | `false` | Lets agents (and workflow tokens) turn on auto-merge or add pull requests to the merge queue for matching branches, so Diffinery merges once every rule is met. Needs at least one required check. See [Agents landing changes](#agents-landing-changes). | | `agentAutoMergeWithoutApproval` | Let agents' auto-merges land without an approving review | boolean | `false` | With `allowAgentAutoMerge`: what Diffinery lands for agents needs no approval from a person, only green required checks. Requested changes still block it, and a workflow change still needs a person. See [Waiving approvals for agents](#waiving-approvals-for-agents). | | `autoMergeAgents` | Auto-merge pull requests from these agents | array of agent logins | none | With `allowAgentAutoMerge`: Diffinery turns auto-merge on by itself for pull requests these agents open into matching branches, so nobody has to click Merge or turn auto-merge on. Stored as `{ "id", "login" }` objects. See [Auto-merging agents' pull requests](#auto-merging-agents-pull-requests). | | `autoMergeMethod` | Their merge method | `merge`, `squash` or `rebase` | as the branch merges | How what `autoMergeAgents` turns on merges. Without it: the merge queue's method, else squash with `requireLinearHistory`, else a merge commit. | | `mergeQueue` | Require a merge queue | object | none | Pull requests merge through a merge queue. See [Merge queue settings](#merge-queue-settings). | ### Required checks A required check is matched by its exact name against two kinds of reports on the pull request's head commit (and on a merge group commit, when a [merge queue](https://diffinery.com/docs/collaboration/merge-queue) is used): - **Check runs.** For Diffinery Actions jobs the check name is `<workflow name> / <job name>`, for example `ci / test` for job `test` in a workflow with `name: ci`. External CI systems and bots create check runs with `POST /repos/{owner}/{repo}/check-runs` and choose their own `name`. - **Commit statuses.** A status's `context` counts as a check name, as on GitHub, so a required check can name a status that external CI posts with `POST /repos/{owner}/{repo}/statuses/{sha}` (for example `ci/external`). A status with the state `success` passes, `pending` is still running, and `failure` or `error` fails. Copy names exactly as they appear in a pull request's checks list. See [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses) for how to report them. A check run passes when its conclusion is `success`, `skipped` or `neutral`. Several accounts can report the same name, so Diffinery decides which reports count, in this order: 1. If the rule binds the check to a reporter in `requiredCheckSources`, only that reporter's reports count. 2. Otherwise, if Diffinery Actions reported a check with that name on the commit, only the Actions result counts. Another account posting a status or check run with the same name cannot override it. 3. Reports from agents never count, whoever they are and whatever the binding says. 4. Of the reports left, any failure fails the check, and any report that has not finished keeps it pending. The check passes only when every counted report passed. A required check named **Agent review** is bound to Diffinery's own reviewer (`diffinery-agent-review`), so only [agent review](https://diffinery.com/docs/agents/review) can pass it. For commit statuses, only each reporter's latest status for a context is used, so one account's later status cannot replace another account's. A required check with no counted report blocks the merge with `required check '<name>' has not run`. That includes a check that only an agent reported. Make sure the workflow or CI system runs for every pull request into the protected branch. Required checks also gate direct pushes, as on GitHub. A push, an API commit or a ref update that moves the protected branch is accepted only when its new commit has already passed every required check, counted the same way. Otherwise it is refused with `protected branch: required check '<name>' has not run on <sha>; push to another branch and open a pull request` (or `has not finished`, or `ended with <conclusion>`). Repository administrators are exempt unless `enforceAdmins` is set. > **Note:** GitHub names an Actions check run after the job only. In Diffinery the check name includes the workflow name, so required check names copied from GitHub usually need the `<workflow> / ` prefix added. ### Binding a check to a reporter `requiredCheckSources` maps a required check's name to the one account whose reports decide it: the login of the account (person or bot account) that posts the check run or commit status, or `diffinery-actions` for Diffinery Actions. Use it when a check comes from an external system and you don't want anyone else with write access to be able to satisfy it. ```json { "requirePullRequest": true, "requiredChecks": ["ci / test", "ci/external"], "requiredCheckSources": { "ci/external": "ci-bot", "ci / test": "diffinery-actions" } } ``` - Logins are matched without regard to case and are stored in lowercase. - A source must start with a letter or digit and contain only letters, digits, `.`, `_`, `-`, `[` and `]`, at most 100 characters. Otherwise the request fails with `422` and `invalid source for required check '<name>'`. - A value that is not an object fails with `requiredCheckSources must map check names to a reporter login or diffinery-actions`. - An entry only has an effect for a name that is also in `requiredChecks`. - Binding a check to an agent's login makes it impossible to satisfy, because agents' reports never count. `requiredCheckSources` can only be set through the API. The **Branch protection** page does not show it, but saving a rule there keeps the bindings of every check that is still listed in **Required status checks**, also when you change the rule's pattern. A binding for a check you remove from **Required status checks** is dropped with it; to require and bind that check again, send the rule with `PUT .../protection`. ### Approvals Diffinery counts each reviewer's most recent approving or "request changes" review: - Only approvals from people count. Approvals from agents and from a workflow's `GITHUB_TOKEN` are advisory and never count. (A job token can approve at all only when the repository allows workflows to approve pull requests; see [Pull requests and workflow tokens](https://diffinery.com/docs/collaboration/pull-requests#pull-requests-and-workflow-tokens).) - You cannot approve your own pull request, and approving requires the write role. - With `dismissStaleReviews`, only approvals given on the pull request's current head commit count. - If a pull request was opened by an agent, it needs at least one approval from a person even when `requiredApprovals` is `0`. ### Agent rules Agents are accounts that act for a person or organization (see [Agents](https://diffinery.com/docs/agents/overview)). By default: - Agents cannot push to the default branch or to any branch that has a rule. The push is rejected with `agents may not push to protected or default branches; open a pull request`. Set `agentsMayPush` on a rule to allow it for matching branches. - Agents cannot merge pull requests into the default branch or a protected branch. The merge is blocked with `agents may not merge into this branch; a human must merge`. Set `agentsMayMerge` to allow it. An agent from **Settings → Agent setup** with **Can merge pull requests** on may merge there once a person approved the pull request's latest commit and every rule is met, never a change to a workflow file ([Merging](https://diffinery.com/docs/connect/cloud-agents#merging)). - Agents cannot create, change or delete branch protection rules (`agents cannot change branch protection`). - Check runs and commit statuses that agents report never satisfy a required check (see [Required checks](#required-checks)), so an agent cannot mark its own pull request as passing. To let agents push to or merge into the default branch, create a rule whose pattern matches the default branch and turn on the option you need. ### Agents landing changes `allowAgentAutoMerge` lets agents land their work on a protected or default branch without ever merging it themselves and without any bypass. An agent turns on auto-merge for its pull request (`PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge`, `diffinery pr merge 12 --auto`, or the MCP tool `enable_auto_merge`), and Diffinery merges it once every rule of the branch is met: required checks green on the head commit, approvals from people (an agent's pull request still needs one), no changes requested, linear history and an up-to-date branch where required. On a branch with a merge queue, the pull request joins the queue instead, and the queue's own checks gate it. - The rule must set `allowAgentAutoMerge` and have at least one required check. Without the setting the agent gets `403` (`agents may not have pull requests merged into main automatically unless its branch protection allows it ...`); without a required check, `422` (`main has no required status checks: allowAgentAutoMerge needs at least one ...`). The same holds for adding a pull request to the merge queue. - A pull request that adds, changes or deletes a workflow file can't land this way (`the pull request changes workflow file ...; a person must merge it`), since the workflows are what gate it. - When a pull request lands this way, a person's approval counts only for the commit it approved, whatever `dismissStaleReviews` says: if the agent pushes again, someone approves again. - Only people with the admin role change the setting, like every other rule; agents and workflow tokens never can. Turning it on or off is audited as `repo.protection.agent_auto_merge`. - The merge is made as the agent, on behalf of its owner, and its commit carries the `diffinery-agent` header. Workflows that the merge triggers run as code from an agent, so their tokens are held to the agent rules too. - A workflow's `GITHUB_TOKEN` is held to the same rule as agents (and needs `contents: write`), even when the run's code didn't come from an agent. - The agent's access is checked again when the merge happens, and auto-merge turns itself off, with a comment saying why, if the rule no longer allows it, if the merge queue refuses the pull request, or if someone else pushes to the pull request's branch. - An archived repository lands nothing by itself. Archiving it turns every waiting auto-merge off (`Auto-merge was turned off: the repository is archived.`) and empties its merge queues (`This pull request was removed from the merge queue: the repository is archived.`); unarchiving doesn't turn them back on. - With `delete_branch` (`diffinery pr merge 12 --auto --delete-branch`), the head branch is deleted once the pull request lands, directly or through the queue, as with a manual merge. A merge never deletes the default branch or a protected branch, even where its rule sets `allowDeletion`. - `GET /repos/{owner}/{repo}/pulls/{number}` and the MCP tool `get_pull_request` show agents and workflow tokens `auto_merge_blockers`, what auto-merge still needs, next to the blockers of a direct merge (which keep saying `agents may not merge into this branch; a human must merge`). Turning on auto-merge, turning it off and the merge itself are audited (`pull_request.auto_merge_enabled`, `pull_request.auto_merge_disabled`, `pull_request.auto_merged`, or `pull_request.auto_merge_enqueued` when it joins a merge queue). How to turn auto-merge on and off in the web UI, the API and MCP, and when it turns itself off, is in [Auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge). #### Waiving approvals for agents By default an agent's pull request needs an approving review from a person before it lands, even through auto-merge. Where a repository's agents should land work on green CI alone, an admin turns on **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`) on the branch's rule, next to `allowAgentAutoMerge`, which it needs (`422` otherwise). - It applies only to what Diffinery lands for an agent or a workflow token through auto-merge or the merge queue. A direct merge by an agent is still refused, and people's own pull requests keep the rule's `requiredApprovals`. - The required checks must pass on the head commit, as always; the setting can't be on without at least one, since `allowAgentAutoMerge` needs one. - A person's requested changes still block the merge, and a pull request that changes a workflow file still needs a person to merge it. - It is set per branch rule, only by people with the admin role, and never comes in with an import. Turning `allowAgentAutoMerge` off turns this off too. Every change is audited as `repo.protection.agent_approval_waiver`. - What lands this way is the agent's push, so later runs on the branch are runs of agent code (see [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)). They get no repository or organization secrets unless an admin turns on **Give runs of agent code the repository's and organization's secrets**, and they do get the secrets of environments limited to protected branches. A deploy that runs after an agent's merge needs one of the two; see [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets). #### Auto-merging agents' pull requests The quickest way to set this up, with the required checks, is the repository's **Settings → Merge on green** page; see [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green). `allowAgentAutoMerge` still needs someone to turn auto-merge on for each pull request. Where a repository's agents should land their work with nobody acting at all, an admin names those agents on the branch's rule, in **Auto-merge pull requests from these agents** (`autoMergeAgents`). Diffinery then turns auto-merge on by itself, as the agent, when one of them: - opens a pull request into a matching branch (not a draft), - marks its draft pull request ready for review, or - pushes to a pull request it opened, which is how a pull request opened before the agent was named gets picked up. Each such pull request then lands exactly as the agent's own auto-merge would, with nothing loosened: - The rule needs `allowAgentAutoMerge` and at least one required check (`422` otherwise), and the required checks must pass on the head commit. - An approval from a person is still needed unless the rule also has `agentAutoMergeWithoutApproval`. With it, green required checks are enough: that pairing is what lands agents' work with no human step. - A person's requested changes still block it. - A pull request that adds, changes or deletes a workflow file never turns on this way. Diffinery says so in a comment (``Auto-merge didn't turn on by itself (branch rule `main` auto-merges my-agent's pull requests): the pull request changes workflow file ...``), and a person merges it. - The agent's write access is checked again when the merge happens, and the merge is made as the agent, on behalf of its owner. - Once auto-merge on a pull request is turned off, by anyone or by Diffinery (for example because a person pushed to the branch), the rule never turns it back on for that pull request. A person can still turn it on, or merge. - Pull requests from other agents, and from people, are left alone. To turn it on for `main`: 1. Open the repository's **Settings**, then **Branch protection** in the sidebar (`/{owner}/{repo}/settings/branches`) and expand the rule for `main` (or add one). 2. Make sure **Required status checks** (under **Status checks**) lists your CI checks, and under **Agents** tick **Allow agents to land pull requests through auto-merge**. 3. To land without an approving review, also tick **Let agents' auto-merges land without an approving review**. 4. In **Auto-merge pull requests from these agents**, enter the agent logins, one per line, and pick **Their merge method** if it should differ from how the branch merges. 5. Click **Save rule**. With the API, the same rule is: ```bash curl -X PUT https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -d '{"requirePullRequest": true, "requiredChecks": ["ci"], "allowAgentAutoMerge": true, "agentAutoMergeWithoutApproval": true, "autoMergeAgents": ["my-agent"], "autoMergeMethod": "squash"}' ``` The answer shows the agents as stored, `"autoMergeAgents": [{ "id": "...", "login": "my-agent" }]`, and that form can be sent back as is. Only accounts that are agents can be named (`422`: `autoMergeAgents: olive is not an agent; ...`), at most 20 per rule. - Only people with the admin role change it, never agents or workflow tokens, and it never comes in with an import. Turning `allowAgentAutoMerge` off removes it. - Every change to the agents or the method is audited as `repo.protection.agent_auto_merge_rule` (`pattern`, `enabled`, `agents`, `method`, `withoutApproval`, `requiredChecks`). Each pull request it turns auto-merge on for is audited as `pull_request.auto_merge_enabled` by `diffinery-auto-merge[bot]`, with `byRule` (the rule's pattern) and `agent`, and the merge as `pull_request.auto_merged` by the agent. - The pull request page says **Auto-merge is on · turned on by the branch rule `main` for my-agent**, with the usual **Disable auto-merge** button. ### Workflow tokens A workflow job's `GITHUB_TOKEN` is held to branch protection like a person with the write role: - It never has more than the write role, whatever its `permissions:` say, so it can't bypass any rule as an admin, and it can't read or change branch protection rules (`this endpoint is not available to a workflow's GITHUB_TOKEN`). - Pushes and merges with it need `contents: write`. - It can't create, change or delete files under `.github/workflows/` or `.diffinery/workflows/`, by push, API commit or merge: the update is rejected with ``refusing to allow a GitHub App to create or update workflow `<path>` without `workflows` permission``. - In a run whose code came from an agent (an agent started the run, pushed the branch last, or opened the pull request, and re-runs of such runs and `workflow_run` runs that follow them), the token acts as an agent: the agent rules above apply to it. - Its approvals never count toward `requiredApprovals`. ### Merge queue settings `mergeQueue` turns on a merge queue for matching branches. Pull requests are then added to the queue instead of being merged directly, and each is tested on top of the base branch and the pull requests ahead of it. See [Merge queue](https://diffinery.com/docs/collaboration/merge-queue) for how the queue works. | JSON field | UI label | Default | Allowed values | |---|---|---|---| | `method` | Merge method | `"merge"` | `"merge"` (Create a merge commit), `"squash"` (Squash and merge), `"rebase"` (Rebase and merge) | | `buildConcurrency` | Build concurrency | `5` | 1 to 20: how many queued pull requests are built and checked at once | | `checkTimeoutMinutes` | Check timeout (minutes) | `60` | 1 to 360: how long to wait for required checks on a merge group before removing it from the queue | Values outside the allowed range are clamped. An unknown `method` becomes `"merge"`. `"mergeQueue": true` turns the queue on with the defaults. On a rule that also sets `requireLinearHistory`, the queue can't create merge commits: a `method` of `"merge"` (or an unknown one) is refused with `422`, and without a `method` the queue squashes. ## Who can bypass the rules Repository admins can bypass branch protection unless the rule sets `enforceAdmins`. Admins are the repository owner, the owners and admins of the organization that owns it, collaborators with the admin role, and site admins. When you use a token, it needs the `admin` scope: a `write`-scoped token caps your role at maintain. See [Permissions](https://diffinery.com/docs/accounts/permissions). Pushes bypass the rules automatically. Merges don't: an admin's merge is held to the rules like anyone else's until the admin explicitly asks for a bypass. | Action | Admin bypass (when `enforceAdmins` is off) | |---|---| | Direct push, force push or deletion on a protected branch | Automatic for admins. An agent with the admin role gets this only on branches where `agentsMayPush` is on. | | Merging a pull request that has blockers | Only on request, and only for admins who are people, when the pull request has no merge conflicts and is not a draft. In the web UI the button reads **Merge without waiting for requirements (bypass)** and asks for confirmation; it isn't shown when the rule sets `enforceAdmins`. Through the API send `"bypass_rules": true` on `PUT /repos/{owner}/{repo}/pulls/{number}/merge`; with the CLI use `diffinery pr merge 12 --admin`. Without the flag the merge fails with `405`, and the message ends with `(as an admin you can bypass with bypass_rules)`. | | A merge commit on a branch that requires linear history | Refused with `405` and `main requires linear history; use squash or rebase`, unless the admin sends `bypass_rules`. The web merge form doesn't offer **Create a merge commit** on such a branch at all. | | Merging directly when a merge queue is required | Only on request: **Merge now (bypass the queue)** in the web UI, `"bypass_merge_queue": true` on `PUT /repos/{owner}/{repo}/pulls/{number}/merge`, or `diffinery pr merge 12 --admin`. This skips only the queue: a pull request that doesn't meet the rules also needs `"bypass_rules": true`. In the web UI the button then reads **Merge now (bypass the queue and the unmet requirements)** and sends both. | | Deleting the default branch | Never allowed. | | Agent rules | Never bypassed by admin status for merges. | A merge that bypassed anything is recorded in the [audit log](https://diffinery.com/docs/security/audit-log#git-and-pull-requests) as `pull_request.merge_bypassed`, with the pull request (`acme/website#42`) as the target and the `base` branch and bypassed `blockers` in the detail: the unmet rules, `linear history required` for a merge commit on a linear-history branch, and `merge queue` for a skipped queue. See [Admin bypass](https://diffinery.com/docs/collaboration/pull-requests#admin-bypass). With `enforceAdmins` on, admins follow the same rules as everyone else. ## Managing rules in the web UI You need the admin role on the repository. 1. Open the repository, for example `https://diffinery.com/acme/website`. 2. Select **Settings**, then **Branch protection** in the sidebar (`https://diffinery.com/acme/website/settings/branches`). 3. Under **Add a rule**, enter the **Branch name pattern**, for example `main`. The form starts with **Require a pull request before merging** checked and **Required approvals** set to 1. 4. Set **Required approvals**, enter **Required status checks** one check name per line (names may contain commas, as matrix jobs' do), and check the options you want. 5. To use a merge queue, check **Require a merge queue** and choose the **Merge method**, **Build concurrency** and **Check timeout (minutes)**. 6. Select **Create rule**. Existing rules are listed above the form with a summary such as `PR required · 1 approval · 2 required checks`. Click a rule to expand and edit it, then select **Save rule**. You see `Branch protection saved.` Saving keeps the rule's `requiredCheckSources` bindings for the checks that are still required. If you change the pattern of an existing rule, the old pattern's rule is replaced, and the rule counts as newly created when several rules match a branch (see [Which rule applies](#which-rule-applies)). Select **Delete** next to a rule to remove it. Changes are recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `repo.protection.set` and `repo.protection.delete`, whether they come from the web UI or the API (API changes carry `"via": "api"` in the detail). On an archived repository, rules can't be changed (`repository is archived (read-only)`). The repository's branches list marks protected branches with a **protected** badge, and `GET /api/v3/repos/{owner}/{repo}/branches` returns `"protected": true` for them. ## Managing rules with the API The endpoints need a token for an account with the admin role on the repository, with the `admin` scope. Agents' tokens are refused for changes. | Method and path | Purpose | |---|---| | `GET /api/v3/repos/{owner}/{repo}/branches/{pattern}/protection` | Read the rule stored for exactly this pattern | | `PUT /api/v3/repos/{owner}/{repo}/branches/{pattern}/protection` | Create or replace the rule for this pattern | | `DELETE /api/v3/repos/{owner}/{repo}/branches/{pattern}/protection` | Delete the rule for this pattern | The `{pattern}` path segment is the rule's pattern, not a branch name: `GET .../branches/release%2F*/protection` reads the `release/*` rule, and returns `404` with `branch not protected` if no rule has exactly that pattern, even when the branch is matched by another pattern. URL-encode `/` in patterns as `%2F`. `PUT` replaces the whole rule with the JSON body. Fields you leave out are turned off. > **Important:** The request body uses Diffinery's rule fields shown above (`requirePullRequest`, `requiredApprovals` and so on). It does not accept GitHub's protection body (`required_status_checks`, `required_pull_request_reviews`, `enforce_admins` ...). Diffinery does not translate those fields, so a GitHub-style body protects nothing. ### Create a rule with curl ```sh curl -sS -X PUT \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \ -d '{ "requirePullRequest": true, "requiredApprovals": 1, "dismissStaleReviews": true, "requiredChecks": ["ci / test", "ci / lint"] }' ``` The response echoes the pattern and the rules you sent: ```json { "pattern": "main", "rules": { "requirePullRequest": true, "requiredApprovals": 1, "dismissStaleReviews": true, "requiredChecks": ["ci / test", "ci / lint"] } } ``` ### Create a rule with the CLI The `diffinery api` command sends an authenticated request (install the CLI with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): ```sh cat > rules.json <<'EOF' { "requirePullRequest": true, "requiredApprovals": 2, "enforceAdmins": true } EOF diffinery api PUT /repos/acme/website/branches/main/protection --input rules.json diffinery api GET /repos/acme/website/branches/main/protection diffinery api DELETE /repos/acme/website/branches/main/protection ``` See [CLI reference](https://diffinery.com/docs/reference/cli) and [REST API](https://diffinery.com/docs/api). ### Example rules A default branch that only changes through reviewed pull requests with green CI, and that admins cannot bypass: ```json { "requirePullRequest": true, "requiredApprovals": 2, "dismissStaleReviews": true, "requiredChecks": ["ci / test"], "requireLinearHistory": true, "enforceAdmins": true } ``` Release branches (`PUT .../branches/release%2F*/protection`) that may be force-pushed and deleted after a release: ```json { "allowForcePush": true, "allowDeletion": true } ``` Agent working branches (`PUT .../branches/bots%2F**/protection`) where agents push directly and merge their own pull requests: ```json { "agentsMayPush": true, "agentsMayMerge": true } ``` A default branch that merges through a merge queue, squashing each pull request: ```json { "requirePullRequest": true, "requiredApprovals": 1, "requiredChecks": ["ci / test"], "mergeQueue": { "method": "squash", "buildConcurrency": 3, "checkTimeoutMinutes": 30 } } ``` ## What happens on a push For each ref in a push (and for each branch update from the API, the web editor or MCP), Diffinery checks, in order: 1. The ref name is valid, and you have at least the write role. 2. `refs/pull/...` refs and merge queue branches (`gh-readonly-queue/...`) are managed by Diffinery and cannot be pushed. The objects the update points at must exist on the server. 3. Moving an existing tag needs the maintain role. 4. Agents are stopped at the default branch and protected branches unless `agentsMayPush` is on. 5. The default branch cannot be deleted. 6. If no rule matches, the update is accepted. If you are an admin and the rule does not set `enforceAdmins`, it is accepted. 7. Deletion needs `allowDeletion`. 8. `requirePullRequest` rejects every direct update. 9. A non-fast-forward update needs `allowForcePush`. 10. With `requireLinearHistory`, new commits must not include merge commits (the first 1,000 new commits are checked). A workflow's `GITHUB_TOKEN` is also refused, right after step 2, when the update creates, changes or deletes a file under `.github/workflows/` or `.diffinery/workflows/`. A new branch is compared with where it branched off the default branch. A rejected ref shows the reason in git's output: ```text ! [remote rejected] main -> main (protected branch: changes must be made through a pull request) ``` Through the API, the same reasons come back as `403` errors with a `message`. | Reason | Meaning | |---|---| | `permission denied` | You need the write role. | | `agents may not push to protected or default branches; open a pull request` | An agent pushed to the default branch or a protected branch without `agentsMayPush`. | | `cannot delete the default branch` | Choose another default branch first. | | `protected branch: deletion not allowed` | The rule does not set `allowDeletion`. | | `protected branch: changes must be made through a pull request` | The rule sets `requirePullRequest`. | | `protected branch: force push not allowed` | The update is not a fast-forward and the rule does not set `allowForcePush`. | | `protected branch: merge commits not allowed (linear history)` | The pushed commits include a merge commit. | | `protected branch: required check '<name>' has not run on <sha>; push to another branch and open a pull request` (or `has not finished`, `ended with <conclusion>`) | The rule requires status checks, and the commit the push moves the branch to hasn't passed them. Push to another branch and open a pull request. Administrators are exempt unless `enforceAdmins` is set. | | `only maintainers may move tags` | Moving an existing tag needs the maintain role. | | `merge queue branches are read-only` | `gh-readonly-queue/...` branches belong to the merge queue. | | `refs/pull/ is managed by Diffinery for pull requests` | Pull request refs cannot be pushed. | | `missing objects` | The update points at a commit the server doesn't have. Push the commits too, or fetch the full history first. | | ``refusing to allow a GitHub App to create or update workflow `<path>` without `workflows` permission`` | A workflow's `GITHUB_TOKEN` tried to add, change or delete a workflow file. Push it as a person or with a personal token. | ## Merge blockers Before a pull request merges, Diffinery evaluates the rule for its base branch and lists every reason it cannot merge yet. You see the list on the pull request page above the merge button, in `merge_blockers` from `GET /api/v3/repos/{owner}/{repo}/pulls/{number}`, and in `blockers` from `GET /api/v3/repos/{owner}/{repo}/pulls/{number}/merge`. | Blocker text | Cause | |---|---| | `already merged` | The pull request is merged. | | `pull request is closed` | Reopen it first. | | `base branch <name> no longer exists` | The base branch was deleted. | | `pull request is a draft` | Draft pull requests cannot merge. | | `merge conflicts in <n> file(s)` | Resolve the conflicts on the head branch. | | `needs <n> more approving review(s)` | Approvals from people are short of `requiredApprovals` (or of 1 for an agent's pull request). | | `changes requested by <logins>` | These reviewers' latest review requests changes. | | `required check '<name>' has not run` | No counted check run or commit status with that name exists on the head commit (reports from agents, or from accounts other than the bound reporter, don't count). | | `required check '<name>' is queued` or `is in_progress` | The check has not finished. A commit status in the `pending` state shows as `is in_progress`. | | `required check '<name>' <conclusion>` | The check finished with a conclusion other than success, skipped or neutral, for example `required check 'ci / test' failure`. A commit status in the `failure` or `error` state shows as `failure`. | | `branch is behind its base; update it (linear history required)` | With `requireLinearHistory`, the head branch must contain the latest base commit. | | `write access is required to merge` | Your role is below write. | | `agents may not merge into this branch; a human must merge` | An agent (or a workflow token in a run whose code came from an agent) tried to merge into the default branch or a protected branch without `agentsMayMerge`. Agents can [land changes through auto-merge](#agents-landing-changes) instead. | | `the pull request changes workflow file <path>; a person must merge it` | An agent's auto-merge or merge queue entry relies on `allowAgentAutoMerge`, and the pull request changes a workflow. | | ``refusing to allow a GitHub App to create or update workflow `<path>` without `workflows` permission`` | A workflow's `GITHUB_TOKEN` tried to merge a pull request that changes a workflow file. | Example response from `GET /api/v3/repos/acme/website/pulls/42`, trimmed: ```json { "number": 42, "mergeable": true, "rebaseable": true, "mergeable_state": "blocked", "merge_blockers": [ "needs 1 more approving review(s)", "required check 'ci / test' is in_progress" ], "approvals": 0, "required_approvals": 1, "checks": [{ "name": "ci / test", "state": "in_progress" }] } ``` As on GitHub, `mergeable` only says whether the head merges into the base without conflicts (`null` once the pull request is closed); the rules are reported in `mergeable_state` and `merge_blockers`. `mergeable_state` is one of: | Value | Meaning | |---|---| | `clean` | No blockers: the pull request can merge now. | | `blocked` | One or more blockers other than conflicts, listed in `merge_blockers`. | | `dirty` | The head conflicts with the base. | | `draft` | The pull request is a draft. | | `unknown` | The pull request is merged or closed, or its base branch no longer exists. | `GET /api/v3/repos/{owner}/{repo}/pulls/{number}/merge` returns Diffinery's report (`mergeable`, `state`, `blockers`, `checks` and more), where `state` uses `conflicting` instead of `dirty` and `merged` or `closed` instead of `unknown`. A client that sends a GitHub media type (`Accept: application/vnd.github+json`) gets GitHub's answer from that path instead: `204` if the pull request is merged and `404` if not. When you try to merge anyway with `PUT /api/v3/repos/{owner}/{repo}/pulls/{number}/merge`, the response is `405` with the blockers joined into the message and listed in `blockers`: ```json { "message": "pull request is not mergeable: needs 1 more approving review(s); required check 'ci / test' is in_progress", "blockers": ["needs 1 more approving review(s)", "required check 'ci / test' is in_progress"] } ``` For an admin who may bypass (see [Who can bypass the rules](#who-can-bypass-the-rules)), the message ends with `(as an admin you can bypass with bypass_rules)`. On a branch with `requireLinearHistory`, a merge with `"merge_method": "merge"` returns `405` with `main requires linear history; use squash or rebase`, even when the pull request has no blockers. When no method is given, the API squashes on such a branch. If the base branch requires a merge queue, a direct merge returns `405` with `main requires a merge queue: add the pull request to the queue` and the blocker `main merges through a merge queue`. Add the pull request to the queue with `PUT /api/v3/repos/{owner}/{repo}/pulls/{number}/merge-queue` instead. See [Merge queue](https://diffinery.com/docs/collaboration/merge-queue) and [Pull requests](https://diffinery.com/docs/collaboration/pull-requests). ## Differences from GitHub - Rules are set by pattern with Diffinery's own JSON fields; GitHub's branch protection request body is not supported (see [Managing rules with the API](#managing-rules-with-the-api)). - Commit status contexts count as required checks, as on GitHub. Where GitHub binds a required check to a GitHub App, Diffinery binds it to an account login (or `diffinery-actions`) with `requiredCheckSources`, and a check that Diffinery Actions reports is decided by Actions alone even without a binding. - Reports from agents never satisfy a required check. - Agent controls (`agentsMayPush`, `agentsMayMerge`, `allowAgentAutoMerge`, `agentAutoMergeWithoutApproval`) and the human-approval requirement for agents' pull requests have no GitHub equivalent. - Required Actions check names include the workflow name: `ci / test`. - Rules have no options for restricting pushes to specific users or teams, requiring signed commits, requiring conversation resolution or requiring code owner reviews. - A "changes requested" review blocks merging even on branches without a rule. - An admin's merge past unmet rules must be asked for with `bypass_rules`, and skipping a merge queue with `bypass_merge_queue`; each covers only what it names, and both are written to the audit log as `pull_request.merge_bypassed`. - When several rules match, the choice follows GitHub: a rule naming the branch exactly wins, then the oldest rule. ## Troubleshooting | Symptom | Cause and fix | |---|---| | `branch not protected` from `GET .../protection` | No rule has exactly that pattern. List rules on the **Branch protection** page, and URL-encode `/` as `%2F`. | | A required check never turns green | The name does not match. Compare it with the checks list on the pull request, including the `workflow / ` prefix. | | A check shows as passed on the pull request but the merge says `has not run` | The passing report doesn't count: it came from an agent, or from an account other than the one bound in `requiredCheckSources`, or Diffinery Actions also reported a check with the same name. Report the check from the bound account, or change the binding. | | `invalid source for required check '<name>'` | A value in `requiredCheckSources` is not a valid login. Use the reporter's login or `diffinery-actions`. | | A `requiredCheckSources` binding disappeared | The check was removed from **Required status checks** when the rule was saved in the web UI, which drops the bindings of checks that are no longer required. Send the rule again with `PUT .../protection`. | | An agent's pull request needs an approval even with `requiredApprovals: 0` | Agent-authored pull requests always need one approval from a person. | | An approval disappeared after a push | `dismissStaleReviews` is on. Ask the reviewer to approve the new commits. | | Your admin push was rejected | The rule sets `enforceAdmins`, or your token has the `write` scope instead of `admin`. | | Your admin merge was refused with `pull request is not mergeable: ...` | Admins aren't exempt from merge rules by default. Send `"bypass_rules": true`, use `diffinery pr merge --admin`, or click **Merge without waiting for requirements (bypass)**. If the message doesn't end with `(as an admin you can bypass with bypass_rules)`, a bypass isn't possible: the rule sets `enforceAdmins`, the pull request conflicts or is a draft, or your token lacks the `admin` scope. | | `main requires linear history; use squash or rebase` | The base branch requires linear history and you asked for a merge commit. Merge with `squash` or `rebase`. | | A branch follows a different rule than you expect | Several patterns match it. A rule with the exact branch name wins, otherwise the oldest matching rule. See [Which rule applies](#which-rule-applies). | | `agents cannot change branch protection` | Change rules as a person, from the web UI or with your own token. | --- # Webhooks > Send repository events to your own HTTPS endpoint, verify their signatures, and check delivery status, with GitHub-compatible headers and payloads. Source: https://diffinery.com/docs/repositories/webhooks A webhook sends an HTTPS `POST` with a JSON payload to a URL you choose whenever something happens in a repository: a push, a pull request opened, edited or merged, an issue comment, a workflow run started by hand or finished, a published release, a commit status or a deployment. Use webhooks to connect chat bots, deploy systems, external CI or any service that reacts to repository activity. ## How webhooks work - Webhooks belong to one repository. You need the **admin** role on it to create, list or delete them. - Each webhook has a payload URL (HTTPS only), a list of events, and a signing secret. The secret is stored encrypted with a key of its own and is never shown again after creation. - When an event happens, Diffinery puts it on an internal queue. Within a few seconds the queue delivers it to every active webhook of the repository that subscribes to that event, in parallel. - Each delivery is signed with an HMAC-SHA256 of the body using the webhook's secret, in the `X-Hub-Signature-256` header, the same scheme GitHub uses. - A delivery that fails with a network error, a timeout, `408`, `429` or a `5xx` status is retried up to five times over about four and a half hours, with the same `X-GitHub-Delivery` id. See [Retries](#retries). - Diffinery records the HTTP status and time of the most recent delivery attempt of each webhook. ## Events | Event | Sent when | Actions | |---|---|---| | `push` | A branch or tag is created, updated or deleted, by `git push`, the web editor, the contents or Git data API, MCP, a merge, the merge queue, or a release that creates its tag | none | | `pull_request` | A pull request is opened, updated, edited, closed, merged, reopened, assigned, gets a review request, is marked ready for review or converted to a draft, enters or leaves the merge queue, or has auto-merge turned on or off | `opened`, `synchronize`, `edited`, `closed`, `reopened`, `assigned`, `review_requested`, `ready_for_review`, `converted_to_draft`, `enqueued`, `dequeued`, `auto_merge_enabled`, `auto_merge_disabled` | | `pull_request_review` | A review is submitted | `submitted` | | `issues` | An issue is opened, edited (title or body), closed, reopened or assigned | `opened`, `edited`, `closed`, `reopened`, `assigned` | | `issue_comment` | A comment is added to an issue or a pull request | `created` | | `workflow_run` | A workflow run is created or finishes | `requested`, `completed` | | `release` | A release is created, published, edited, unpublished or deleted, including drafts | `created`, `published`, `released`, `prereleased`, `edited`, `unpublished`, `deleted` | | `status` | A commit status is posted | none | | `deployment` | A deployment is created, with the REST API or by a workflow job with `environment:` | `created` | | `deployment_status` | A deployment gets a new status, from the REST API or from a workflow job with `environment:` | `created` | | `deployment_review` | A run starts waiting on an environment's [required reviewers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers), or a reviewer approves or rejects it | `requested`, `approved`, `rejected` | | `repository_dispatch` | Someone calls `POST /repos/{owner}/{repo}/dispatches` | the `event_type` that was sent | | `workflow_dispatch` | Someone starts a workflow by hand (web UI, CLI, REST API or MCP) | none | | `*` | Every event above | | A merged pull request is delivered as `pull_request` with `"action": "closed"` and `"pull_request": { "merged": true }`, like GitHub. `edited` is sent when the title or body actually changes, not for label or assignee changes. The `release`, `status`, `deployment`, `deployment_status` and `repository_dispatch` payloads have GitHub's shape (for example `release` holds the same release object as the REST API). The full payload of each event, with examples, is in [Webhook events](https://diffinery.com/docs/reference/webhook-events). ## Creating a webhook ### In the web UI 1. Open the repository, for example `https://diffinery.com/acme/website`. 2. Select **Settings**, then **Webhooks** in the sidebar (`https://diffinery.com/acme/website/settings/webhooks`). 3. Under **Add a webhook**, enter the **Payload URL**. It must start with `https://` and name a public host: not `localhost`, a private or loopback address, or Diffinery itself. 4. Under **Events**, keep **Everything** checked to receive all events, or uncheck it and check the events you want: `push`, `pull_request`, `pull_request_review`, `issues`, `issue_comment`, `workflow_run`, `workflow_dispatch`. If **Everything** stays checked, the webhook receives every event regardless of the other boxes, including `release`, `status`, `deployment`, `deployment_status`, `deployment_review` and `repository_dispatch`. If you uncheck every box, the webhook also receives every event. 5. Optionally enter a **Secret**. Leave it empty to have Diffinery generate one. 6. Select **Add webhook**. The page then shows **Webhook created for** your URL with the signing secret. Copy it now: it is not shown again. If you lose it, delete the webhook and create a new one. > **Note:** The web form only offers the events listed above as separate checkboxes. To subscribe to `release`, `status`, `deployment`, `deployment_status`, `deployment_review` or `repository_dispatch` without receiving everything, create the webhook with the API and list the events you want. ### With the API `POST /api/v3/repos/{owner}/{repo}/hooks` creates a webhook. It needs a token with the `admin` scope for an account with the admin role on the repository. ```sh curl -sS -X POST \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/hooks \ -d '{ "config": { "url": "https://example.com/hooks/diffinery", "secret": "my-long-random-secret" }, "events": ["push", "release", "status"] }' ``` | Field | Required | Description | |---|---|---| | `config.url` | yes | The payload URL. Must start with `https://`, otherwise the request fails with `422` and `webhook URL must use https`. It must name a public host: `localhost`, private and loopback addresses fail with `webhook URL must be a public host`, and Diffinery's own host with `webhook URL can't point at Diffinery itself`. A repository can have at most 20 webhooks. | | `config.secret` | no | The signing secret. When omitted, Diffinery generates a random one. | | `events` | no | Event names to subscribe to. Omitted, empty or not a list means `["*"]` (every event). | A request without `config.url` fails with `422` and `config.url is required`. The response (`201`) is the webhook, in GitHub's hook format. When you didn't send a secret, it also contains the generated `secret`, once: ```json { "type": "Repository", "id": "0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b", "name": "web", "active": true, "events": ["push", "release", "status"], "config": { "url": "https://example.com/hooks/diffinery", "content_type": "json", "insecure_ssl": "0" }, "updated_at": "2026-10-13T09:00:00Z", "created_at": "2026-10-13T09:00:00Z", "url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b", "ping_url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b/pings", "deliveries_url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b/deliveries", "last_response": { "code": null, "status": "unused", "message": null } } ``` When you send `config.secret`, the response has no `secret` field: store your own copy. Webhook ids are 32-character hexadecimal strings. Other GitHub fields such as `name`, `active` and `config.content_type` are ignored on create. Payloads are always JSON. Event names are not validated: a name that is not in the table above never matches anything. The same request with the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): ```sh cat > hook.json <<'EOF' { "config": { "url": "https://example.com/hooks/diffinery" }, "events": ["push"] } EOF diffinery api POST /repos/acme/website/hooks --input hook.json ``` ### Listing and deleting webhooks ```sh # List (the secret is never included) curl -sS -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/hooks # Delete curl -sS -X DELETE -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b ``` The list (paginated with `per_page` and `page`) returns the same objects, never with the secret: ```json [ { "type": "Repository", "id": "0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b", "name": "web", "active": true, "events": ["push", "release", "status"], "config": { "url": "https://example.com/hooks/diffinery", "content_type": "json", "insecure_ssl": "0" }, "updated_at": "2026-10-13T09:30:02Z", "created_at": "2026-10-13T09:00:00Z", "url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b", "ping_url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b/pings", "deliveries_url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b/deliveries", "last_response": { "code": 200, "status": "active", "message": null } } ] ``` | Field | Description | |---|---| | `last_response.code` | HTTP status of the most recent delivery, `0` when it got no response, `null` before the first delivery | | `last_response.status` | `unused` (no delivery yet), `active` (last delivery got a `2xx`) or `failed` | | `updated_at` | Time of the most recent delivery, or the creation time before the first one | `ping_url` and `deliveries_url` are included for GitHub clients, but Diffinery has no ping or delivery-history endpoints, so requests to them return `404`. `DELETE` returns `204`. There is no endpoint to edit a webhook: to change its URL, events or secret, delete it and create a new one. In the web UI, select **Delete** next to the webhook. ## Delivery ### Request ```http POST /hooks/diffinery HTTP/1.1 Host: example.com Content-Type: application/json User-Agent: Diffinery-Hookshot/1 X-GitHub-Event: pull_request X-GitHub-Delivery: 7d3f0c2a9b8e4d1fa6c5b4e3d2c1b0a9 X-Diffinery-Event: pull_request X-Hub-Signature-256: sha256=4b1c0e2f... {"action":"opened","number":42,"headRef":"fix-typo", ...} ``` | Header | Value | |---|---| | `Content-Type` | `application/json` | | `User-Agent` | `Diffinery-Hookshot/1` | | `X-GitHub-Event` | The event name, for example `push` or `pull_request` | | `X-Diffinery-Event` | The same event name | | `X-GitHub-Delivery` | A unique id for this delivery: 32 hexadecimal characters | | `X-Hub-Signature-256` | `sha256=` followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook's secret | The body is compact JSON (no extra whitespace). Every payload has `repository` and `sender` objects. `repository` is GitHub's full repository object, the same one `GET /api/v3/repos/{owner}/{repo}` returns: its `id` is a number, and `private` and `visibility` give the repository's real visibility (`false` and `public` for a public repository). See [Webhook events](https://diffinery.com/docs/reference/webhook-events#the-repository-object). ### Timeouts, redirects and retries | Behavior | Detail | |---|---| | Timeout | Your endpoint has 10 seconds to respond. After that the delivery counts as failed with status `0`. | | Redirects | Not followed. A `3xx` response is recorded as that status and counts as failed. Use the final URL. | | Success | Any `2xx` status. The response body is ignored. | | Failed deliveries | Recorded (the status, or `0` for a timeout, DNS or connection error). Network errors, timeouts, `408`, `429` and `5xx` are retried; see [Retries](#retries). Other statuses are not. | | Duplicates | A retry repeats the same delivery with the same `X-GitHub-Delivery` id, so your endpoint can see a delivery again if it processed it but answered too slowly or with an error. Diffinery's own failures don't cause new deliveries: webhooks and notifications for an event are handled separately, and a step that fails before any webhook was called (for example while loading the repository's webhooks) is tried again on its own after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 1 hour. Still, make your handler idempotent, for example by remembering delivery ids. | | Order | Deliveries are not guaranteed to arrive in the order the events happened. Use the `at` timestamp in the payload, or the `created_at` and `updated_at` fields of the GitHub-shaped events. | Respond quickly with a `2xx` and do slow work afterwards, so that deliveries don't time out. ### Retries When an attempt fails in a way that may be temporary, Diffinery tries the same delivery again later: | Result of an attempt | Retried | |---|---| | `2xx` | No: delivered | | No response: network error, DNS failure, refused connection, or no answer within 10 seconds (recorded as `0`) | Yes | | `408 Request Timeout`, `429 Too Many Requests` | Yes | | `500` and above | Yes | | `3xx` (redirects are not followed) | No | | Any other `4xx`, for example `400`, `401`, `403`, `404` or `410` | No | The retries come 1 minute after the first failed attempt, then 5 minutes, 30 minutes, 1 hour and 3 hours after each further failure: at most six attempts, the last about 4 hours 36 minutes after the first. The delays are minimums; queue processing can add a few seconds. - Every attempt of one delivery has the same `X-GitHub-Delivery` id, the same `X-GitHub-Event` and the same body, so you can use the id to drop repeats. - Each attempt is signed and sent with the webhook's URL and secret as they are at that moment. If the webhook was deleted in the meantime, the remaining retries are dropped. - The delivery fails for good when an attempt gets a status that isn't retried, or when the sixth attempt also fails. Diffinery then stops without further notice: there is no email, no automatic disabling of the webhook, and no manual redelivery. The webhook's last status shows the result of the final attempt. - Every attempt updates the webhook's last delivery status and time, so a delivery that succeeds on a retry shows as successful. - Deliveries for other events are not held back while one is being retried, so a retried delivery can arrive after newer ones. ### Delivery status Diffinery keeps the status and time of the **most recent** delivery attempt of each webhook (retries included), not a full history: - On the **Webhooks** settings page, each webhook shows a status icon (green for `2xx`, red otherwise), its events, whether it is active, and `last delivery <status> <time>`, or `no deliveries yet`. - `GET /api/v3/repos/{owner}/{repo}/hooks` returns `last_response.code` and `last_response.status`, with the delivery time in `updated_at`. A status of `0` means the request did not get an HTTP response: a timeout after 10 seconds, a DNS failure or a refused connection. Failed deliveries are [retried](#retries) automatically, but there is no redelivery button and no ping event: to test a webhook, trigger a real event, for example by pushing a branch or opening an issue. ## Verifying signatures Always verify `X-Hub-Signature-256` before trusting a payload. Compute the HMAC-SHA256 of the **raw request body bytes** with your secret as the key (UTF-8), hex-encode it, prefix `sha256=`, and compare it with the header using a constant-time comparison. Do not parse and re-serialize the JSON before computing the HMAC. ### Node.js ```js import crypto from "node:crypto"; import http from "node:http"; const secret = process.env.WEBHOOK_SECRET; function verify(rawBody, header) { if (typeof header !== "string" || !header.startsWith("sha256=")) return false; const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } http .createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const body = Buffer.concat(chunks); if (!verify(body, req.headers["x-hub-signature-256"])) { res.writeHead(401).end("bad signature"); return; } const event = req.headers["x-github-event"]; const payload = JSON.parse(body.toString("utf8")); console.log(event, payload.action ?? "", payload.repository.full_name, payload.sender.login); res.writeHead(204).end(); }); }) .listen(3000); ``` ### Python Standard library only: ```text import hashlib import hmac import json import os from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = os.environ["WEBHOOK_SECRET"].encode("utf-8") def verify(body: bytes, header: str | None) -> bool: if not header or not header.startswith("sha256="): return False expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header) class Hook(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) if not verify(body, self.headers.get("X-Hub-Signature-256")): self.send_response(401) self.end_headers() return payload = json.loads(body) print(self.headers.get("X-GitHub-Event"), payload.get("action"), payload["repository"]["full_name"]) self.send_response(204) self.end_headers() HTTPServer(("", 3000), Hook).serve_forever() ``` ### Check a signature by hand ```sh printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= /sha256=/' ``` ## GitHub compatibility Diffinery webhooks use GitHub's delivery format, so many GitHub webhook receivers and signature-verification libraries work unchanged: - The same headers: `X-GitHub-Event`, `X-GitHub-Delivery` and `X-Hub-Signature-256`, plus `X-Diffinery-Event`. - The same signature scheme (HMAC-SHA256, `sha256=` prefix, hex). - The same event names and the same `action` values for the actions Diffinery sends. - Payloads have GitHub's full `repository` object (numeric `id`, `node_id`, `owner`, `private`, `visibility`, `default_branch`, URLs and so on), a `sender`, GitHub-style `pull_request` and `issue` objects, and `pull_request.merged` on `closed`. - `release`, `status`, `deployment`, `deployment_status`, `deployment_review` and `repository_dispatch` payloads have GitHub's structure, with the release, status and deployment fields GitHub receivers read. `workflow_dispatch` has GitHub's `inputs`, `ref` and `workflow` fields. - The hooks API returns GitHub's hook object (`type`, `name`, `config`, `last_response`, ...). Differences to plan for: | GitHub | Diffinery | |---|---| | Rich payloads (full pull request, issue, comment, commit list) for every event | `push`, `pull_request`, `pull_request_review`, `issues`, `issue_comment` and `workflow_run` have small payloads: ids, refs, SHAs and links. Fetch details from the [REST API](https://diffinery.com/docs/api) when you need them. For example, `issue_comment` does not include the comment body, and `push` has no `commits` array. | | Numeric ids everywhere | `repository.id` is a number, but `sender.id`, `commentId`, `runId` and webhook ids are 32-character hexadecimal strings. | | `X-Hub-Signature` (SHA-1) is also sent | Only `X-Hub-Signature-256`. | | `ping` event when a webhook is created | No ping event. | | Failed deliveries are not retried automatically; they can be redelivered by hand; delivery history | Network errors, timeouts, `408`, `429` and `5xx` are retried automatically up to five times; no manual redelivery; only the last status and time. `ping_url` and `deliveries_url` return `404`. | | Webhooks can be edited (`PATCH`) | Delete and recreate. | | Payload can be form-encoded | Always JSON. | | Organization webhooks | Repository webhooks only. | | `check_run`, `check_suite` and many other events | Only the events in the table above. | | `events` defaults to `["push"]` in the API | Defaults to `["*"]` (every event). | | Secrets are never returned | A generated secret is returned once by the create request. | Diffinery-specific fields in the small payloads use camelCase, for example `headRef`, `headSha`, `baseRef`, `commentId`, `runId` and `at`. ## Security - Payload URLs must use HTTPS. - The secret is stored with the webhook and is shown only once in the web UI (and returned once by the create API). Use a long random value, and keep it in your receiver's secret store. - Verify the signature on every request, and reject requests without one. - Creating a webhook is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `repo.webhook.create`, and deleting one as `repo.webhook.delete`, both from the web UI and from the API (API changes carry `"via": "api"` in the detail). - On an archived repository, webhooks can't be created or deleted: the request fails with `403` and `repository is archived (read-only)`. - Payloads from a private repository say so (`"private": true`, `"visibility": "private"`), so a receiver can decide what it may show or forward. - Payloads never contain Actions secrets, tokens or file contents. ## Troubleshooting | Symptom | Cause and fix | |---|---| | `webhook URL must use https` | Use an `https://` URL. Plain HTTP endpoints are not supported. | | Last delivery status `0` | Your endpoint did not answer within 10 seconds, or could not be reached. Check DNS, TLS and firewall rules, and respond before doing slow work. | | Last delivery status `301` or `302` | Redirects are not followed. Update the payload URL to the final address (for example, add the trailing slash or `www`). | | Signature mismatch | You are hashing a re-serialized body or the wrong secret. Hash the raw bytes exactly as received. If you lost the secret, recreate the webhook. | | The same `X-GitHub-Delivery` id arrives more than once | A [retry](#retries): an earlier attempt failed or took longer than 10 seconds. Answer with a `2xx` within 10 seconds, and skip ids you already handled. | | Last delivery status `404`, `401` or `403`, and no retries | Client errors other than `408` and `429` are not retried. Fix the payload URL or your endpoint's authentication; the next event is delivered normally. | | No `edited` event after changing labels | `edited` is sent only when the title or body changes. Label changes send no webhook event. | | No `release`, `status` or `deployment` deliveries | The webhook was created in the web UI with individual events checked, which can't include these. Recreate it with **Everything** checked, or with the API listing the events. | | Three `release` deliveries for one new release | Publishing a release sends `created`, `published` and `released` (or `prereleased`), like GitHub. Act on the one you need, usually `published`. | | A `deployment` and a `deployment_status` delivery for one workflow job | A job with `environment:` creates a deployment when it starts (`deployment` with `created`) and reports its progress as `deployment_status` events (`in_progress` when the job starts, then `success`, `failure` or `inactive`), as on GitHub. | | `config.url is required` | Send the payload URL as `config.url`, not as a top-level `url`. | | A webhook stopped receiving events | Check that it still exists on the **Webhooks** page and that the events it subscribes to are the ones you expect. | --- # Apps > Build or connect integrations as GitHub-compatible apps on Diffinery, with app JWTs, installation access tokens, per-repository permissions and signed webhooks. Source: https://diffinery.com/docs/repositories/apps Apps are how deploy services, CI systems, code review bots and other integrations work with your repositories on Diffinery. They work like GitHub Apps: a service registers an app, people install it on their account or organization for all or some repositories, and the app signs in with its own private key to get short-lived tokens for exactly those repositories. It never uses anyone's personal token. Because the endpoints, tokens and webhooks are GitHub's, an integration that already works with GitHub Enterprise Server usually works with Diffinery by changing its API URL to `https://diffinery.com/api/v3`. ## How apps work - **An app** has a name, an owner (a person or an organization), the repository permissions it asks for, the webhook events it subscribes to, and one or more private keys. Its bot signs its work as `<slug>[bot]`. - **An installation** is the app on one account. Someone who administers that account (the person themselves, or an owner or administrator of the organization) chooses **all repositories** or **only select repositories**, and can change that, suspend the app or uninstall it at any time. - **The app's JWT** is a JSON Web Token the app signs with its private key (RS256, `iss` set to the app ID or client ID, valid for at most 10 minutes). It works only on the app endpoints below. - **An installation access token** (`dia_…`) is what the app trades its JWT for. It lasts one hour and reaches only the installation's repositories, with only the installation's permissions; the app can narrow it further to some repositories or permissions. ## Register an app 1. Go to **Settings → Apps** and choose **New app**. You need to be signed in with your passkey, recently. 2. Give it a name (it can't change later), a homepage URL, and if you want: a setup URL (where people land after installing it, with `installation_id` and `setup_action=install` in the query), a webhook URL and secret, and whether anyone can install it. A private app installs only on its owner's account. 3. Choose its **repository permissions** and **events**, then **Register app**. 4. Copy or download the **private key** now. Diffinery keeps only the public half and can't show it again. Generate more keys, or add a public key you made yourself (`openssl rsa -in key.pem -pubout`), on the app's settings page; delete old ones when you rotate. The app's settings page shows its **App ID**, **Client ID** and the API URL to configure in the integration. ## Permissions and events | Permission | Read | Write | | --- | --- | --- | | Metadata | Always granted: repository details | — | | Contents | Code, commits, branches, tags, releases, `git clone` and fetch | Push commits, create branches and releases, merge pull requests | | Pull requests | Pull requests, reviews and their comments | Open, comment on and review pull requests | | Issues | Issues, labels, milestones and comments | Open and comment on issues | | Checks | Check runs | Report check runs on commits | | Commit statuses | Commit statuses | Report commit statuses | | Deployments | Deployments and environments | Create deployments and their statuses | | Actions | Workflows, runs, jobs, logs and artifacts | Re-run and cancel runs | Each request with an installation token needs the permission for its endpoint, the same table a workflow's `GITHUB_TOKEN` is checked against ([Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions)). A request it doesn't cover gets GitHub's `403 Resource not accessible by integration`. Apps are never offered administration, secrets, variables, webhooks, collaborators or workflow permissions. An app can't change a repository's settings, read or set its secrets, or add or change a workflow file in `.github/workflows` or `.diffinery/workflows`, by push, API commit or merge. Webhook events an app can subscribe to: `push`, `pull_request`, `pull_request_review`, `issues`, `issue_comment`, `release`, `status`, `deployment`, `deployment_status`, `deployment_review`, `workflow_run`, `workflow_dispatch` and `repository_dispatch`. Each needs one of its permissions. Every app with a webhook also receives `installation` (`created`, `deleted`, `suspend`, `unsuspend`, `new_permissions_accepted`) and `installation_repositories` (`added`, `removed`). ## Install an app Open the app's page at `https://diffinery.com/apps/<slug>` and choose **Install**. Pick the account, then all repositories or the ones you select. The app gets the permissions shown there. You need to be signed in with your passkey in a browser: no token, agent or app can install an app or give it more repositories. Manage installed apps under **Settings → Installed apps**: - **Repository access**: switch between all and selected repositories, or change the selection. The app gets an `installation_repositories` webhook. - **New permissions**: when an app asks for more than it had, it keeps the old permissions on each installation until someone who administers the account accepts the new ones. When it asks for less, that applies at once, also to tokens already made. - **Suspend**: the app's tokens stop working and it gets no webhooks until you unsuspend it. An app can suspend its own installation, but can't lift a suspension you made. - **Uninstall**: removes the installation; its tokens stop working at once. ## Use the API as an app Sign a JWT with the private key, as any GitHub App library does, and call the app endpoints with `Authorization: Bearer <jwt>`: ```sh # Ruby, Python, Go and Node libraries for GitHub Apps all make this JWT now=$(date +%s) header=$(printf '{"alg":"RS256","typ":"JWT"}' | base64 | tr -d '=\n' | tr '/+' '_-') payload=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' $((now-60)) $((now+540)) "$APP_ID" | base64 | tr -d '=\n' | tr '/+' '_-') sig=$(printf '%s.%s' "$header" "$payload" | openssl dgst -sha256 -sign app.private-key.pem | base64 | tr -d '=\n' | tr '/+' '_-') JWT="$header.$payload.$sig" curl -H "Authorization: Bearer $JWT" https://diffinery.com/api/v3/app/installations curl -X POST -H "Authorization: Bearer $JWT" https://diffinery.com/api/v3/app/installations/$INSTALLATION_ID/access_tokens ``` With Octokit (or any GitHub client), point it at Diffinery: ```js import { App, Octokit } from "octokit"; const app = new App({ appId: process.env.APP_ID, privateKey: process.env.PRIVATE_KEY, Octokit: Octokit.defaults({ baseUrl: "https://diffinery.com/api/v3" }) }); const octokit = await app.getInstallationOctokit(installationId); await octokit.rest.checks.create({ owner, repo, name: "Deploy", head_sha, status: "completed", conclusion: "success" }); ``` Git works with the installation token as the password: ```sh git clone https://x-access-token:$TOKEN@diffinery.com/OWNER/REPO.git ``` | Endpoint | Credential | What it does | | --- | --- | --- | | `GET /app` | JWT | The app | | `GET /app/installations` | JWT | Its installations | | `GET`, `DELETE /app/installations/{id}` | JWT | One installation; uninstall | | `POST /app/installations/{id}/access_tokens` | JWT | An installation access token, optionally with `repositories`, `repository_ids` and `permissions` | | `PUT`, `DELETE /app/installations/{id}/suspended` | JWT | Suspend or unsuspend its installation | | `GET /repos/{owner}/{repo}/installation`, `GET /orgs/{org}/installation`, `GET /users/{username}/installation` | JWT | Its installation that covers a repository or account | | `GET /installation/repositories` | Installation token | The repositories the token reaches | | `DELETE /installation/token` | Installation token | Revoke the token | | `GET /apps/{slug}` | Any | A public app | | `GET /user/installations`, `GET /user/installations/{id}/repositories` | Your token or session | Installations on accounts you administer | | `PUT`, `DELETE /user/installations/{id}/repositories/{repository_id}` | Browser session | Add or remove a repository | Diffinery's own endpoints manage apps: `GET` and `POST /api/v3/diffinery/apps`, `GET`, `PATCH` and `DELETE /api/v3/diffinery/apps/{slug}`, `GET`, `POST` and `DELETE /api/v3/diffinery/apps/{slug}/keys`, `POST /api/v3/diffinery/apps/{slug}/installations`, and `GET`, `PATCH`, `DELETE /api/v3/diffinery/installations/{id}`, `PUT` and `DELETE …/suspended` and `POST …/accept_permissions`. Reading needs a token or session; registering, keys and installing need a passkey session in a browser. ## Webhooks An app with a webhook URL receives, for every repository it's installed on, the events it subscribed to, with an `installation` object (`id`, `node_id`) in the payload. Deliveries use the same headers, signature and retries as [repository webhooks](https://diffinery.com/docs/repositories/webhooks): verify `X-Hub-Signature-256` with the app's webhook secret. ## Not yet - Signing people in to the app with OAuth (GitHub's user-to-server tokens) and the app manifest flow. - GraphQL with installation tokens: use the REST API. - Organization permissions (members, teams, administration). ## Related - [Webhooks](https://diffinery.com/docs/repositories/webhooks) for one repository's own webhooks - [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses), which apps report - [Deploy to Cloudflare Workers](https://diffinery.com/docs/actions/deploy-to-cloudflare-workers) - [Access tokens](https://diffinery.com/docs/accounts/tokens) for scripts acting as you --- # Issues > Open, discuss, label, assign, lock, react to, close and search issues, and group them in milestones, in the web UI, the CLI, the REST API and over MCP. Source: https://diffinery.com/docs/collaboration/issues Issues track bugs, tasks and ideas for a repository. Each issue has a title, a Markdown description, a conversation, labels, assignees and an open or closed state, and you can work with them from the web UI, the `diffinery` CLI, the REST API or an MCP client. ## How issues work - **One number sequence per repository.** Issues and pull requests share the same counter, like on GitHub. If `#4` is a pull request, the next issue is `#5`. Opening `/acme/website/issues/4` when `#4` is a pull request redirects to `/acme/website/pull/4`, and the other way round. - **State.** An issue is `open` or `closed`. A closed issue also has a reason: `completed` (the default) or `not_planned`. Pull requests that merged are closed with the reason `merged`. - **Markdown everywhere.** Titles are plain text. Descriptions and comments are Markdown (see [Markdown](#markdown)). - **Labels and assignees** are lists of names stored on the issue. Labels have a color and description defined per repository; assignees are account logins. - **The API lists pull requests too.** As on GitHub, `GET /repos/{owner}/{repo}/issues` returns issues and pull requests; pull requests carry a `pull_request` field. Everything on this page about comments, labels, assignees, locking, reactions and events also works for pull requests through the issue endpoints. - **GitHub-shaped API.** The REST API returns GitHub's issue objects, so Octokit and other GitHub clients work: an issue's `id` is its number, comment ids are numbers, `labels` are label objects and `assignees` are user objects. ## Permissions Repository roles are read, triage, write, maintain and admin. A token's scope can lower your role. See [Permissions](https://diffinery.com/docs/accounts/permissions) and [Tokens](https://diffinery.com/docs/accounts/tokens). Every change on this page needs a signed-in account and a token that can write: - **Anonymous callers only read.** Without a token you can view the issues of a public repository, but opening an issue, commenting, reacting or any other change gets `401 authentication required`, as on GitHub. - **`read` tokens only read.** Although the read role can open issues and comment, a token with only the `read` scope can't change anything: those requests get `403 this token needs the write scope`. Use a token with the `write` scope (the default) for issue work, even if your role on the repository is read. | Action | Who can do it | | --- | --- | | View issues | Anyone who can read the repository (anyone at all for a public repository) | | Open an issue | Anyone signed in who can read the repository (with a `write` or `admin` token through the API) | | Comment | Anyone signed in who can read the repository (with a `write` or `admin` token through the API) | | Edit the title or description | The issue's author, or triage or higher | | Close or reopen | The issue's author, or triage or higher | | Set labels and assignees in the web UI | Triage or higher | | Set labels and assignees with `PATCH .../issues/{number}` | The issue's author, or triage or higher | | Add or remove labels and assignees with the label and assignee endpoints | Triage or higher | | Create, rename, recolor and delete labels | Triage or higher | | Create, edit and close milestones, and set an issue's milestone | Triage or higher | | Edit or delete a comment | The comment's author, or maintain or higher | | Lock and unlock a conversation | Triage or higher | | Comment or react on a locked issue | Triage or higher | | Add a reaction | Anyone signed in who can read the repository (with a `write` or `admin` token through the API) | | Delete a reaction | The person who added it, or admin | When you open an issue, the `labels` and `assignees` you send are applied only if you have triage access or higher. Without it they are silently dropped and the issue is created without them, as on GitHub: the request still succeeds with `201`. This applies to the REST API, the CLI (`--label`, `--assignee`) and the MCP `create_issue` tool; the web form only offers labels to triage or higher. Editing works the same way: when the issue's author (or anyone else) without triage access sends `labels` or `assignees` with `PATCH .../issues/{number}`, they are dropped too, and the rest of the edit is applied. In an archived repository every change is refused, whatever your role: opening and editing issues, comments, labels, assignees, locks and reactions, through the web UI, the REST API, the CLI and MCP, all fail with `403` and `repository is archived (read-only)`. The web UI shows issues read-only. Subscribing to an issue and watching the repository still work. A workflow's `GITHUB_TOKEN` can work with issues within its `permissions:`: opening an issue (`POST .../issues`) needs `issues: write`, and comments, labels, assignees and reactions on an issue accept `issues: write` or `pull-requests: write`. Without them the request fails with `403` and `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: issues: write)`. A job token acts with at most the write role, so it can set labels and assignees when it opens an issue. ## Open an issue ### Open an issue in the web UI 1. Go to the repository and open the **Issues** tab, or go straight to `https://diffinery.com/acme/website/issues/new`. 2. Click **New issue**. 3. Fill in **Title** (required) and **Description** (Markdown). 4. If you have triage access and the repository has labels, tick the **Labels** to apply. 5. Click **Submit new issue**. You land on the new issue's page. You can prefill the title with a query parameter: `https://diffinery.com/acme/website/issues/new?title=Broken%20link%20on%20pricing%20page`. ### Open an issue with the CLI Install the CLI and sign in through your browser: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login ``` Then, inside a clone of the repository (or with `-R acme/website`): ```sh diffinery issue create -R acme/website \ --title "Broken link on pricing page" \ --body "The *Contact sales* link returns 404." \ --label bug --label website \ --assignee diffy ``` | Flag | Meaning | | --- | --- | | `-t, --title TEXT` | Title (required) | | `-b, --body TEXT` | Description (Markdown) | | `--body-file FILE` | Read the description from a file, or from stdin with `-` | | `-l, --label NAME` | Add a label (repeat for more) | | `-a, --assignee LOGIN` | Assign a login (repeat for more) | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git `origin` remote) | | `--json` | Print the created issue as JSON | The command prints `✓ Opened issue #12 Broken link on pricing page` and the issue URL. ### Open an issue with the REST API ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/issues \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"title": "Broken link on pricing page", "body": "The *Contact sales* link returns 404.", "labels": ["bug"], "assignees": ["diffy"]}' ``` The response (`201 Created`) is GitHub's issue object: `id` and `number` (both the issue number), `title`, `body`, `state`, `state_reason`, `locked`, `active_lock_reason`, `user` (`login`, `id`, and `type` `User` or `Bot`), `labels` (label objects with `id`, `name`, `color`, `description`), `assignee` and `assignees` (user objects), `comments`, `reactions` (counts), `created_at`, `updated_at`, `closed_at`, `html_url` and API URLs such as `comments_url`, `events_url` and `timeline_url`. `assignee` (a single login) is accepted in place of `assignees`, and `labels` may be names or `{ "name": ... }` objects. ### Open an issue over MCP The `create_issue` tool takes `owner`, `repo`, `title`, and optionally `body` and `labels`. See [MCP server](https://diffinery.com/docs/agents/mcp). ## Markdown Descriptions, comments and review bodies are rendered as Markdown. Supported: headings, paragraphs, hard line breaks, fenced code blocks, horizontal rules, blockquotes, ordered, unordered and task lists (nested), tables, inline code, bold, italic, strikethrough, links, images and bare URLs. - Raw HTML is escaped, never rendered. HTML comments (`<!-- ... -->`) are hidden, as on GitHub, except inside code. They stay in the body, so the API and the edit box still show them, but pages and notification emails don't. - Only `http(s)` URLs, `#fragment` links and relative paths are kept in links and images. Relative links resolve against the repository's default branch. - Task list items (`- [ ]` and `- [x]`) render as checkboxes. They are display only; edit the text to tick them. - A body larger than 512 KB is shown as plain preformatted text. ## Labels Labels are defined per repository, each with a name, a six-digit hex color and an optional description. ### Create, change and delete labels In the web UI (triage or higher): 1. Open the **Issues** tab and click **Labels**, or go to `https://diffinery.com/acme/website/labels`. 2. Under **New label**, enter a **Name** (up to 50 characters), an optional **Description** (up to 100 characters) and pick a **Color**. 3. Click **Create label**. In the web UI, to change a label's color or description, create it again with the same name: the new values replace the old ones. To remove a label, click **Delete** next to it and confirm. Deleting a label in the web UI removes only the definition: issues that use it keep the name, shown in a neutral gray. With the REST API (triage or higher for changes): ```sh # List labels, or read one curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/labels curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/labels/bug # Create a label curl -X POST https://diffinery.com/api/v3/repos/acme/website/labels \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"name": "bug", "color": "d73a4a", "description": "Something is broken"}' # Rename it, or change its color or description curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/labels/bug \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"new_name": "type: bug", "color": "b60205"}' # Delete it curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/labels/type%3A%20bug" -H "Authorization: Bearer dfy_..." ``` | Rule | Detail | | --- | --- | | Name | 1 to 100 characters through the API (the web form allows up to 50). Creating a name that exists fails with `a label with this name already exists`. | | Color | Six hex digits, with or without a leading `#`. Defaults to `ededed`. Anything else fails with `color must be a 6-digit hex code`. | | Description | Up to 100 characters (longer text is cut). | | Rename | `new_name` renames the label on every issue that has it. | | Delete | `DELETE` removes the label from every issue that has it, unlike the web UI's **Delete**. | Labels are returned as GitHub label objects: `id`, `node_id`, `url`, `name`, `color`, `default` (always `false`) and `description`. ### Apply labels to an issue - **Web UI (triage or higher):** in the issue's sidebar under **Labels**, click **Edit labels**, tick the labels you want, and click **Apply**. Type a name into **New label…** to create a label on the spot; it gets a color picked from a built-in palette. - **CLI:** `diffinery issue create --label NAME` when you open the issue. - **REST API, whole list:** send the complete list in `labels` with `PATCH /repos/{owner}/{repo}/issues/{number}`. It replaces the previous list. ```sh curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/12 \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"labels": ["bug", "website"]}' ``` - **REST API, one change at a time (triage or higher):** the issue label endpoints work as on GitHub. Each returns the issue's labels after the change. | Method and path | Effect | | --- | --- | | `GET /repos/{owner}/{repo}/issues/{number}/labels` | List the issue's labels | | `POST /repos/{owner}/{repo}/issues/{number}/labels` | Add labels, keeping the existing ones. Body: `{"labels": ["bug"]}` or `["bug"]`. | | `PUT /repos/{owner}/{repo}/issues/{number}/labels` | Replace all labels | | `DELETE /repos/{owner}/{repo}/issues/{number}/labels/{name}` | Remove one label (`404 Label does not exist` if the issue doesn't have it) | | `DELETE /repos/{owner}/{repo}/issues/{number}/labels` | Remove all labels (`204`) | The add and replace endpoints create any label that doesn't exist yet, with the color `ededed`, like GitHub. An issue can have at most 100 labels through `POST` (`an issue can have at most 100 labels`). - **MCP:** `update_issue` with `labels` (the complete list). Label names set with `PATCH`, MCP or the web UI don't have to match a defined label. An undefined name is shown in gray. ## Assignees An issue can have several assignees. Assigned people are notified and follow the thread from then on. - **Web UI (triage or higher):** in the sidebar under **Assignees**, click **Edit assignees**, type logins separated by commas or spaces (a leading `@` is ignored, at most 10), and click **Save**. Leave the box empty to remove everyone. - **CLI:** `diffinery issue create --assignee diffy`. - **REST API, whole list:** send the complete list in `assignees` (or a single `assignee`; `"assignee": null` clears them) on `POST .../issues` or `PATCH .../issues/{number}`. - **REST API, add or remove (triage or higher):** ```sh # Add assignees (returns the issue, 201) curl -X POST https://diffinery.com/api/v3/repos/acme/website/issues/12/assignees \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"assignees": ["diffy", "review-bot"]}' # Remove assignees (returns the issue) curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/12/assignees \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"assignees": ["review-bot"]}' ``` These endpoints allow at most 10 assignees (`an issue can have at most 10 assignees`), and removal ignores letter case. Logins are not checked when you assign them, so check the spelling. `GET /repos/{owner}/{repo}/assignees` lists the accounts GitHub clients offer as assignees: the repository's direct collaborators with triage or higher, plus the owner of a personal repository. `GET /repos/{owner}/{repo}/assignees/{login}` answers `204` for those accounts and `404` otherwise; it doesn't stop you from assigning anyone else. ## Milestones A milestone groups issues and pull requests that belong to one goal, such as a release, with an optional due date. Each issue or pull request has at most one milestone. Milestones have their own numbers in each repository, starting at 1; milestones brought in by a [GitHub import](https://diffinery.com/docs/migrate-from-github) keep their GitHub numbers, and new ones are numbered after them. You create and edit milestones with the REST API (there's no web page, CLI command or MCP tool for them yet) and set an issue's milestone in the web UI or with the REST API. Creating, editing and setting milestones needs triage or higher and a token with the `write` scope; anyone who can read the repository can read them. ### Milestone fields | Field | Description | | --- | --- | | `number` (also `id`) | The milestone's number in the repository | | `title` | 1 to 255 characters; required when you create a milestone | | `description` | Text up to 65,536 characters; empty by default | | `state` | `open` or `closed`. New milestones are `open` unless you create them with `"state": "closed"`. | | `due_on` | An ISO 8601 timestamp, or `null` for no due date. It's stored in UTC. | | `creator` | `{"login": "..."}` of the account that created it | | `open_issues`, `closed_issues` | How many open and closed issues and pull requests have this milestone (merged pull requests count as closed) | | `created_at`, `updated_at`, `closed_at` | Timestamps; `closed_at` is set when the milestone is closed and cleared when it's reopened | ### Create and edit milestones with the REST API | Request | Needs | Does | | --- | --- | --- | | `GET /repos/{owner}/{repo}/milestones` | read | Lists milestones. `state` is `open` (default), `closed` or `all`. Sorted by due date (milestones without one last), then by number. The list isn't paged. | | `GET /repos/{owner}/{repo}/milestones/{number}` | read | One milestone, or `404 milestone not found` | | `POST /repos/{owner}/{repo}/milestones` | triage | Creates a milestone from `title`, `description`, `state` and `due_on`; returns `201` with the milestone | | `PATCH /repos/{owner}/{repo}/milestones/{number}` | triage | Changes any of `title`, `description`, `state` and `due_on` (`null` removes the due date); returns the milestone | ```sh # Create a milestone curl -X POST https://diffinery.com/api/v3/repos/acme/website/milestones \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"title": "v2.0", "description": "Pricing page redesign", "due_on": "2026-12-01T00:00:00Z"}' # Close it curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/milestones/1 \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"state": "closed"}' # List every milestone, open and closed curl "https://diffinery.com/api/v3/repos/acme/website/milestones?state=all" \ -H "Authorization: Bearer dfy_..." ``` Milestones can't be deleted; close one you no longer use. To clear a description, send `"description": ""` (`null` leaves it unchanged). ### Set the milestone of an issue or pull request - **Web UI (triage or higher):** the sidebar of an issue or pull request has a **Milestone** section showing the milestone's title, a `closed` badge if it's closed and its due date, or **No milestone**. Click **Set milestone**, choose a milestone in the **Milestone** list (it offers the open milestones and **No milestone**), and click **Save**. **Set milestone** appears only when the repository has an open milestone or the issue already has one. Click the milestone's title to list its open issues (or pull requests, on a pull request). - **REST API (triage or higher):** send `milestone` with the milestone's number, or `null` to remove it, on `PATCH /repos/{owner}/{repo}/issues/{number}`. This works for pull request numbers too, and also accepts closed milestones. From someone without triage access, such as an issue's author, `milestone` is ignored without an error, like `labels` and `assignees`. ```sh curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/12 \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"milestone": 1}' ``` Issues and pull requests returned by the REST API carry a `milestone` object (`number`, `id`, `title`, `state`, `due_on`, `url`; `description` is always `null` there), or `null`. Things to know about setting it with `PATCH`: - The response shows the issue with its new milestone. - The milestone is checked before anything is saved: a number no milestone has fails with `422 Validation Failed`, and nothing in the request is saved. - A value that isn't a whole number or `null` fails with `422 milestone must be a milestone number or null`, and nothing is saved. A workflow's `GITHUB_TOKEN` needs `issues: write` or `pull-requests: write` to create, edit or set milestones. Setting or changing a milestone doesn't create an event in the timeline, doesn't send a webhook or a notification, and there are no `milestone` webhook events. To list a milestone's issues and pull requests, send `milestone` on `GET .../issues`: a milestone number, `none` (no milestone) or `*` (any milestone). In the web UI, add `milestone=` and the number to the issue list's address; a milestone's `html_url` (`/{owner}/{repo}/milestone/{number}`) opens that list. `milestone:` in a search is still searched as text. ## Comments ### Add a comment - **Web UI:** type in **Add a comment** at the bottom of the issue and click **Comment**. Clicking **Comment** with an empty box shows `write a comment first`. - **CLI:** `diffinery issue comment 12 --body "Fixed in #15."` The body can also come from `--body-file FILE`, or from stdin when you pipe it in. - **REST API:** ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/issues/12/comments \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"body": "Fixed in #15."}' ``` - **MCP:** the `comment` tool (needs triage or higher when used over MCP). An empty body is rejected with `body is required`. On a [locked](#lock-a-conversation) issue, only triage or higher can comment; others get `conversation is locked`. ### List comments ```sh # Comments on one issue (since: only comments updated at or after this time) curl -H "Authorization: Bearer dfy_..." "https://diffinery.com/api/v3/repos/acme/website/issues/12/comments?since=2026-10-01T00:00:00Z" # Comments on every issue and pull request in the repository curl -H "Authorization: Bearer dfy_..." "https://diffinery.com/api/v3/repos/acme/website/issues/comments?sort=updated&direction=desc&per_page=50" # One comment curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/issues/comments/1234 ``` Each comment has a numeric `id` (as on GitHub), `body`, `user`, `created_at`, `updated_at`, `issue_url`, `html_url` and `reactions`. The repository-wide list takes `since`, `sort` (`created`, the default, or `updated`), `direction` (`asc`, the default, or `desc`), `per_page` and `page`. These lists hold conversation comments only; line comments on pull requests are under `/pulls/comments` (see [Pull requests](https://diffinery.com/docs/collaboration/pull-requests#line-comments)). `diffinery issue view 12` prints the issue and all its comments. ### Edit or delete a comment Edit or delete a comment through the REST API, using its `id`: ```sh curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/comments/1234 \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"body": "Fixed in #15 and #16."}' curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/comments/1234 \ -H "Authorization: Bearer dfy_..." ``` Only the comment's author or someone with maintain or higher can edit or delete it (`permission denied` otherwise). Deleting a comment also deletes its reactions. The web UI and the CLI have no comment editing or deleting yet. ### Hide a comment Someone with write access to the repository (site administrators everywhere) can hide a comment that is spam, abuse, off-topic, outdated, a duplicate or resolved, as GitHub's **Hide** does. On the web, open the comment's **…** menu and pick a reason under **Hide comment**. The comment stays, collapsed behind a line such as "This comment was marked as spam."; anyone can click **Show comment** to read it. **Unhide** in the same menu shows it again. This works on issue and pull request comments, review comments and reviews' comments alike. Through the API (Diffinery's; GitHub only has GraphQL's `minimizeComment`), with the comment's `id`: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/diffinery/comments/1234/minimized \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"reason": "spam"}' curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/diffinery/comments/1234/minimized \ -H "Authorization: Bearer dfy_..." ``` `reason` is `spam`, `abuse`, `off_topic`, `outdated`, `duplicate` or `resolved` (GitHub's classifiers, such as `OFF_TOPIC` in `classifier`, work too). The answer is `{"is_minimized": true, "minimized_reason": "spam"}`. Agents can't hide comments. Hiding and showing are audited as `comment.minimize` and `comment.unminimize`. To report a comment to the site administrators instead, pick **Report content** in the same menu. See [Blocking and reporting](https://diffinery.com/docs/accounts/blocking-and-reporting). ### Edit the title and description - **Web UI:** click **Edit** next to the title, change **Title** or **Description**, and click **Save**. An empty title is rejected with `title is required`. - **REST API:** `PATCH /repos/{owner}/{repo}/issues/{number}` with `title` and/or `body`. - **MCP:** `update_issue` with `title` and/or `body`. Changing the title records a `renamed` event. A change to the title or the description sends an `issues` webhook with the action `edited` (for a pull request, a `pull_request` webhook with `edited`, which also triggers workflows that listen for `pull_request` `edited`). Saving the same text again sends nothing. See [Webhook events](https://diffinery.com/docs/reference/webhook-events). ## Lock a conversation Lock an issue or pull request when a discussion should stop. While it is locked, only people with triage or higher can comment, review, add line comments or react; everyone else gets `403` with `conversation is locked`. Closing, reopening and merging are not affected. ```sh # Lock, with an optional reason curl -X PUT https://diffinery.com/api/v3/repos/acme/website/issues/12/lock \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"lock_reason": "too heated"}' # Unlock curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/12/lock \ -H "Authorization: Bearer dfy_..." ``` Both need triage or higher and return `204`. `lock_reason` is optional and must be `off-topic`, `too heated`, `resolved` or `spam` (otherwise `lock_reason must be off-topic, too heated, resolved or spam`). A locked issue reports `"locked": true` and the reason in `active_lock_reason`, and the timeline gets `locked` and `unlocked` events. Locking is available through the REST API only; the web UI doesn't show a lock control or badge yet, and its comment form returns the same error. ## Reactions Reactions are emoji responses to an issue, a pull request, a conversation comment, a pull request line comment or a [release](https://diffinery.com/docs/collaboration/releases). The allowed `content` values are the same as on GitHub: `+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket` and `eyes`. | Subject | Endpoint | | --- | --- | | Issue or pull request | `/repos/{owner}/{repo}/issues/{number}/reactions` | | Conversation comment | `/repos/{owner}/{repo}/issues/comments/{id}/reactions` | | Pull request line comment | `/repos/{owner}/{repo}/pulls/comments/{id}/reactions` | | Release | `/repos/{owner}/{repo}/releases/{id}/reactions` | ```sh # Add a reaction: 201 when new, 200 when you had already reacted with that content curl -X POST https://diffinery.com/api/v3/repos/acme/website/issues/12/reactions \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"content": "+1"}' # List reactions, optionally only one kind curl -H "Authorization: Bearer dfy_..." "https://diffinery.com/api/v3/repos/acme/website/issues/12/reactions?content=heart" # Remove a reaction by its id curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/12/reactions/7 -H "Authorization: Bearer dfy_..." ``` Each reaction has `id`, `content`, `user` and `created_at`. Issues, comments and releases carry a `reactions` object with a `total_count` and a count per content. Rules: - You need to be signed in, with a `write` or `admin` token: anonymous requests get `401 authentication required`, and a `read` token gets `403 this token needs the write scope`. - An unknown `content` fails with `422` and `content must be one of +1, -1, laugh, confused, heart, hooray, rocket, eyes`. - On a locked issue or pull request, only triage or higher can react. - You can delete your own reactions; admins can delete anyone's (`you can only delete your own reactions` otherwise). Reactions are available through the REST API only; the web UI, the CLI and MCP don't show them yet. ## Events and timeline Diffinery records what happens to an issue or pull request as events, with the same names as GitHub: | Event | Extra fields | | --- | --- | | `labeled`, `unlabeled` | `label` (`name`, `color`) | | `assigned`, `unassigned` | `assignee`, `assigner` | | `renamed` | `rename` (`from`, `to`) | | `closed` | `state_reason`; for a merge, `commit_id` | | `reopened` | none | | `locked`, `unlocked` | `lock_reason` | | `review_requested`, `review_request_removed` | `requested_reviewer`, `review_requester` | | `review_dismissed` | `dismissed_review` (`state`, `review_id`, `dismissal_message`) | | `merged` | `commit_id` | | `ready_for_review`, `convert_to_draft` | none; pull requests only (see [Pull requests](https://diffinery.com/docs/collaboration/pull-requests#drafts)). The matching webhook actions are `ready_for_review` and `converted_to_draft`. | Every event also has `id`, `actor`, `event`, `created_at`, `commit_id` and `commit_url`. | Method and path | Returns | | --- | --- | | `GET /repos/{owner}/{repo}/issues/{number}/events` | The events of one issue or pull request, oldest first | | `GET /repos/{owner}/{repo}/issues/events` | Events across the repository, newest first, each with its `issue` | | `GET /repos/{owner}/{repo}/issues/events/{id}` | One event | | `GET /repos/{owner}/{repo}/issues/{number}/timeline` | Events, comments (`"event": "commented"`) and reviews (`"event": "reviewed"`) in time order | All lists are paged with `per_page` and `page`. Opening an issue and posting a comment are not events; read the issue and its comments for those. ## Mentions Write `@login` in an issue description, a comment or a review to notify that person or agent. Mentions inside code blocks and inline code are ignored, and at most 50 mentions per text are picked up. A mentioned account is notified only if it can read the repository. A mention gets through even when that person has muted the thread. See [Notifications](https://diffinery.com/docs/accounts/notifications). ## Close and reopen ### Close reasons | Reason | Set by | Shown as | | --- | --- | --- | | `completed` | **Close issue**, `diffinery issue close`, or `state: "closed"` without a reason | Closed | | `not_planned` | **Close as not planned**, `--reason not_planned`, or `state_reason: "not_planned"` | Closed as not planned | | `merged` | A pull request that merged | Merged | ### Close and reopen in the web UI At the bottom of an open issue, click **Close issue** or **Close as not planned**. If you typed a comment first, it is posted and then the issue closes. On a closed issue, click **Reopen** (again posting any comment you typed). ### Close and reopen with the CLI ```sh diffinery issue close 12 # reason: completed diffinery issue close 12 --reason not_planned --comment "Out of scope for v2." diffinery issue reopen 12 --comment "Still happening on Safari." ``` `--reason` accepts `completed` or `not_planned` (anything else exits with `--reason must be completed or not_planned`). `-c, --comment TEXT` posts a comment before changing the state. ### Close and reopen with the REST API ```sh curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/12 \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"state": "closed", "state_reason": "not_planned"}' ``` Send `{"state": "open"}` to reopen; the reason is cleared. ### Close issues from a pull request When a pull request merges, Diffinery closes every open issue in the same repository that its title or description references with a closing keyword: `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves` or `resolved`, followed by `#` and the number (for example `Fixes #12`). The keywords are case-insensitive and the issues close with the reason `completed`. References to other repositories are not supported. ## Subscribe and unsubscribe You get notifications for an issue when you opened it, commented, were assigned or @mentioned, or watch the repository with **All activity**. The issue sidebar's **Notifications** section tells you why and has a **Subscribe** or **Unsubscribe** button: - **Subscribe** sends you every new comment and state change: `Subscribed. You'll be notified about all activity.` - **Unsubscribe** mutes the thread: `Unsubscribed. You'll still be notified if you're @mentioned, assigned or asked to review.` Thread subscriptions are set in the web UI or from the unsubscribe link in a notification email; there is no API for them yet. To watch a whole repository, use its **Watch** menu or the `/repos/{owner}/{repo}/subscription` API. See [Notifications](https://diffinery.com/docs/accounts/notifications). ## Find issues ### The issue list The **Issues** tab (`https://diffinery.com/acme/website/issues`) lists issues newest first, 25 per page. - **Open and Closed:** the two counters at the top switch between open and closed issues. - **Search:** the search box (press `/` to focus it) matches text anywhere in the title or the description, up to 200 characters. - **Label:** pick one from the **Label ▾** menu, or click a label on any issue. - **Author:** click an author's login on any issue to show only their issues. - **Milestone:** click the milestone in an issue's sidebar, or add `milestone=` and its number to the address. - Active filters appear at the top of the list with a **clear** link. Filters combine. Filters are query parameters, so you can bookmark or share them: ```text https://diffinery.com/acme/website/issues?state=closed&label=bug&author=diffy&q=pricing ``` ### List issues with the CLI ```sh diffinery issue list -R acme/website --state all --label bug --author diffy --search pricing --limit 50 diffinery issue view 12 ``` | Flag | Meaning | | --- | --- | | `-s, --state STATE` | `open` (default), `closed` or `all` | | `-l, --label NAME` | Only issues with this label | | `-A, --author LOGIN` | Only issues opened by this login | | `-S, --search QUERY` | Text to find in titles and descriptions | | `-L, --limit N` | Maximum number to show (default 30) | `diffinery issue list` hides pull requests. All issue commands accept `--json`. ### List issues with the REST API ```sh curl -H "Authorization: Bearer dfy_..." \ "https://diffinery.com/api/v3/repos/acme/website/issues?state=all&labels=bug&creator=diffy&q=pricing&per_page=50&page=1" ``` | Parameter | Meaning | | --- | --- | | `state` | `open` (default), `closed` or `all` | | `labels` | Comma-separated label names (exact match); an issue must have all of them | | `assignee` | A login, `none` (no assignees) or `*` (any assignee) | | `creator` | Author login (case-insensitive) | | `since` | Only issues updated at or after this ISO 8601 time | | `sort` | `created` (default), `updated` or `comments` | | `direction` | `desc` (default) or `asc` | | `q` | Text to find in titles and descriptions (a Diffinery extension) | | `per_page` | Results per page, default 30, at most 100 | | `page` | Page number, starting at 1 | The `x-total-count` response header holds the total number of matches, and a `Link` header points at the next, previous, first and last pages, so `octokit.paginate` works. Results include pull requests. Over MCP, `list_issues` takes `state`, `q` and `label` and returns up to 100 issues (no pull requests); `get_issue` returns one issue with its comments. ### Search issues and pull requests `GET /search/issues` searches issues and pull requests with GitHub's query syntax, across every repository you can read: ```sh curl -H "Authorization: Bearer dfy_..." --get https://diffinery.com/api/v3/search/issues \ --data-urlencode 'q=repo:acme/website is:issue is:open label:bug -label:wontfix pricing' \ --data-urlencode 'sort=updated' --data-urlencode 'order=desc' ``` The response has `total_count`, `incomplete_results` and `items` (issue objects, each with `"score": 1`), with a `Link` header for further pages. | Qualifier | Meaning | | --- | --- | | `repo:acme/website` | Only this repository (repeat for several) | | `user:`, `org:`, `owner:` | Only repositories of this account | | `is:issue`, `is:pr` (also `type:`) | Issues or pull requests | | `is:open`, `is:closed` (also `state:`) | State; `-is:open` means closed | | `is:merged`, `is:unmerged` | Merged or not (pull requests only) | | `is:draft`, `draft:true`, `draft:false` | Draft pull requests or not | | `is:locked`, `is:unlocked` | Locked conversations or not | | `label:bug`, `label:bug,ui` | Has these labels (all of them); `-label:` excludes | | `no:label`, `no:assignee` | Has no labels, or no assignees | | `author:diffy`, `-author:review-bot` | Opened by, or not by, this login | | `assignee:diffy` | Assigned to this login | | `head:fix-pricing-link`, `base:main` | Pull requests by head or base branch | | `in:title`, `in:body`, `in:comments` | Where words must appear (default: title and body); combine with commas | | `created:`, `updated:`, `closed:` | Date ranges: `>2026-09-01`, `>=`, `<`, `<=`, `2026-09-01..2026-09-30`, `2026-09-01..*`, or one day | | `sort:created-asc`, `sort:updated`, `sort:comments-desc` | Order (default: newest created first) | Words without a qualifier must all appear (case-insensitive substring match); `-word` excludes a word, and `"two words"` is matched as a phrase. Unknown qualifiers are searched as text. The `sort` (`created`, `updated` or `comments`) and `order` (`asc` or `desc`) query parameters override a `sort:` qualifier. Limits: - A query can be up to 256 characters (`The search is longer than 256 characters.`), and an empty `q` fails with `Validation Failed`. - One search looks at up to 50 repositories. When you can read more, `incomplete_results` is `true`; narrow the search with `repo:`, `user:` or `org:`. - Only the first 1,000 results can be paged through (`Only the first 1000 search results are available`). - In each repository, the 5,000 newest issues and pull requests are searched, and `in:comments` reads the 2,000 newest comments. - Without a token, only public repositories are searched. A workflow job token searches only its own repository. The web UI's issue list and `diffinery issue list --search` don't use this syntax; they match plain text in titles and descriptions. ## Differences from GitHub - An issue's `id` is its number; issues are addressed by number only. - `/search/issues` covers at most 50 repositories per search and ignores qualifiers it doesn't know (they are searched as text). `review-requested:`, `mentions:`, `commenter:`, `milestone:`, `linked:` and `reactions:` are not supported. - There are no issue types, issue templates, transfers or pinned issues. - Milestones are created and edited through the REST API only, can't be deleted, and have no events or webhooks. See [Milestones](#milestones). - Deleting a label in the web UI keeps its name on issues; deleting it through the API removes it from them. - Locking, reactions, events and comment deletion are available through the REST API only, not in the web UI. - `GET /repos/{owner}/{repo}/assignees` lists direct collaborators only, not organization members. - Thread subscriptions have no API. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `title is required` | The title was empty. Give the issue a title. | | `body is required` | You posted an empty comment. | | `permission denied` | You tried to edit, close or reopen someone else's issue without triage access, or edit someone else's comment without maintain access. | | `triage access required` | Creating, changing or deleting labels, setting labels and assignees in the web UI, the issue label, assignee and lock endpoints, and creating, editing and setting milestones need triage or higher. | | `#12 not found` | There is no issue or pull request with that number. | | `you can't open issues or pull requests, comment, review or react in acme's repositories: acme blocked you` | The repository's owner blocked you. See [Blocking and reporting](https://diffinery.com/docs/accounts/blocking-and-reporting). | | `conversation is locked` | The issue is locked. Only triage or higher can comment or react. | | `a label with this name already exists` | Use `PATCH /labels/{name}` to change an existing label. | | `milestone not found` | There is no milestone with that number. List them with `GET .../milestones?state=all`. | | `title is required`, `title must be 1 to 255 characters` | A new milestone needs a title, and a milestone title can't be blank or longer than 255 characters. | | `due_on must be an ISO 8601 timestamp` | Send a date such as `2026-12-01T00:00:00Z`, or `null`. | | `state must be open or closed`, `state must be open, closed or all` | Use one of the listed values for a milestone's `state`, or for the `state` filter when listing milestones. | | `description must be a string` | A milestone description must be text of at most 65,536 characters. | | `milestone must be a milestone number or null` | `PATCH .../issues/{number}` takes the milestone's number, not its title. | | The milestone didn't change in the `PATCH` response | The response shows the issue before the milestone changed. Read the issue again. | | `incomplete_results: true` in a search | You can read more than 50 repositories. Add `repo:`, `user:` or `org:` to the query. | | `repository not found` | The repository doesn't exist, or your account or token can't see it (private repositories return 404). | | `authentication required` | You called the API without a token, either on a private repository or to change something (open an issue, comment, react) on a public one. Send `Authorization: Bearer dfy_...`. | | `this token needs the write scope` | Your token has only the `read` scope, which can't open issues, comment or react. Create a token with the `write` scope. | | The new issue has no labels or assignees | You don't have triage access, so the `labels` and `assignees` you sent were dropped. Ask someone with triage access to add them. | | `repository is archived (read-only)` | The repository is archived. An admin must unarchive it before issues can change. | | `Resource not accessible by integration (...)` | A workflow's `GITHUB_TOKEN` lacks the permission named in the message. Add `issues: write` to the job's `permissions:`. | --- # Pull requests > Open, review, check, update and merge pull requests in the web UI, the CLI, the REST API and over MCP, including intent, drafts, blockers and auto-merge. Source: https://diffinery.com/docs/collaboration/pull-requests A pull request proposes merging the commits on one branch (the head) into another branch of the same repository (the base). Use it to get changes reviewed, run CI on them, and merge them under the base branch's protection rules, whether the author is a person or an AI agent. ## How pull requests work - **Same repository.** The head and base are branches of the same repository. In the API, `head` may be written `owner:branch`; the part before the colon is ignored. - **Shared numbering.** Pull requests and issues share one number sequence per repository, like on GitHub. `/acme/website/pull/12` and `/acme/website/issues/12` redirect to each other depending on what `#12` is, and `/acme/website/pulls/12` redirects to `/acme/website/pull/12`. - **One open pull request per branch pair.** You can't open a second pull request for the same head and base while one is open. - **The head moves with the branch.** Pushing to the head branch updates the pull request (a `synchronize` event), re-runs its `pull_request` workflows, and, if the base branch's rule has **Dismiss stale approvals when new commits are pushed**, dismisses earlier approvals. A push by anyone other than the person or agent who turned on [auto-merge](#auto-merge) turns it off. - **Intent.** Each pull request can carry an `intent`: a short statement of what the change is meant to do, shown above the conversation. See [Intent](#intent). - **Auto-merge.** Instead of waiting to click merge, you can turn on auto-merge: Diffinery merges the pull request by itself once every rule of its base branch is met. See [Auto-merge](#auto-merge). - **Agents.** Pull requests opened by agents always need at least one human approval. See [Pull requests opened by agents](#pull-requests-opened-by-agents). ## Permissions | Action | Who can do it | | --- | --- | | View pull requests | Anyone who can read the repository | | Open a pull request | Write or higher | | Comment and leave a Comment review | Anyone signed in who can read the repository | | Approve or request changes | Write or higher, and not the pull request's author | | Request reviewers after opening, or remove a request | The author, or triage or higher | | Edit title and description, close, reopen | The author, or triage or higher | | Mark a draft ready for review, or convert it back to a draft | The author, or write or higher | | Edit or delete a comment or line comment | Its author, or maintain or higher | | Lock and unlock the conversation | Triage or higher | | Comment, review or react on a locked pull request | Triage or higher | | Dismiss a review | Maintain or higher, not the pull request's author, and never an agent | | Update the branch with its base (`update-branch`) | Write or higher, subject to the head branch's protection | | Merge, or add to and remove from the merge queue | Write or higher, subject to [merge blockers](#merge-blockers) | | Turn [auto-merge](#auto-merge) on or off | Write or higher; agents and workflow tokens only where the base branch allows it | A token's scope can lower your role: a `read` token acts with at most read, a `write` token with at most maintain, and only an `admin` token keeps admin. Every change (opening, commenting, reviewing, editing, marking ready, merging) needs a signed-in caller: without credentials the API answers `401` with `authentication required`, even on a public repository, and a `read`-scoped token gets `403` with `this token needs the write scope`. On an archived repository every change is refused with `403` and `repository is archived (read-only)`. A workflow's `GITHUB_TOKEN` has extra limits: see [Pull requests and workflow tokens](#pull-requests-and-workflow-tokens). See [Permissions](https://diffinery.com/docs/accounts/permissions) and [Tokens](https://diffinery.com/docs/accounts/tokens). ## Open a pull request ### From a push When you push a new branch (other than the default branch) that has no pull request yet, git prints a link to open one: ```text remote: Create a pull request for 'fix-pricing-link': remote: https://diffinery.com/acme/website/compare/fix-pricing-link ``` Pushing to a branch that already has an open pull request prints `Updated pull request #12: https://diffinery.com/acme/website/pull/12`. ### In the web UI 1. Open the **Pull requests** tab and click **New pull request**, or go to `https://diffinery.com/acme/website/compare`. You can also open `https://diffinery.com/acme/website/compare/main...fix-pricing-link` directly, or click **Compare** next to a branch on the **Branches** page. 2. Pick the **base** (the branch to merge into) and the **compare** branch (your changes), then click **Compare**. The page shows how many commits the compare branch is ahead and behind, the commits, and the diff. 3. Under **Open a pull request**, fill in: - **Title** (required). With a single commit it defaults to that commit's subject; otherwise to the branch name. - **Intent**: what this change is meant to do. Optional, but recommended. - **Description** (Markdown). With a single commit it defaults to the rest of that commit's message. - **Create as draft** if the work isn't ready to merge. 4. Click **Create pull request**. The form appears only when both sides are branches, the compare branch has commits the base doesn't, and you have write access. If a pull request for the pair is already open, the page says `A pull request for these branches already exists: #12` instead. When there is nothing to compare, it says `There isn't anything to compare.` ### With the CLI Install the CLI and sign in through your browser: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login ``` From a clone with your branch checked out: ```sh git push -u origin fix-pricing-link diffinery pr create \ --title "Fix the Contact sales link" \ --intent "Point the pricing page's Contact sales link at /contact; no other behaviour changes." \ --body "Fixes #12" \ --reviewer diffy ``` | Flag | Meaning | | --- | --- | | `-t, --title TEXT` | Title (required) | | `-b, --body TEXT` | Description (Markdown) | | `--body-file FILE` | Read the description from a file, or stdin with `-` | | `-H, --head BRANCH` | Branch with your changes (default: the current branch) | | `-B, --base BRANCH` | Branch to merge into (default: the repository's default branch) | | `-d, --draft` | Open as a draft | | `--intent TEXT` | What the change is for (agents should always set it) | | `-r, --reviewer LOGIN` | Request a review (repeat for more) | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git `origin` remote) | | `--json` | Print the pull request as JSON | ### With the REST API ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{ "title": "Fix the Contact sales link", "head": "fix-pricing-link", "base": "main", "body": "Fixes #12", "intent": "Point the pricing page'\''s Contact sales link at /contact; no other behaviour changes.", "draft": false, "reviewers": ["diffy"] }' ``` `base` defaults to the default branch. The response (`201 Created`) is GitHub's pull request object: `id` and `number` (both the pull request number), `title`, `body`, `state`, `locked`, `user`, `labels` (label objects), `assignees`, `requested_reviewers` (user objects), `head` and `base` (`ref`, `sha`, `label`, `repo`), `draft`, `merged`, `merged_at`, `merged_by`, `merge_commit_sha`, `html_url` and the usual `_links`, plus Diffinery's `intent`. ### Over MCP The `create_pull_request` tool takes `owner`, `repo`, `title`, `head`, and optionally `body`, `base`, `draft`, `intent` and `reviewers`. An agent typically creates a branch and commits with `commit_files` first. See [MCP server](https://diffinery.com/docs/agents/mcp). ### Errors when opening | Message | Cause | | --- | --- | | `head and base must be different branches` | Head and base are the same branch. | | `head branch 'x' does not exist` | Push the branch first. | | `base branch 'x' does not exist` | Check the base name. | | `no commits between main and x` | The head has nothing the base doesn't already contain. | | `a pull request already exists for x: #12` | Use the open pull request, or close it first. The response also includes `number`. | | `title is required` | Give it a title. | | `write access required` | Opening a pull request needs write access. | | `GitHub Actions is not permitted to create pull requests. ...` (403) | A workflow's `GITHUB_TOKEN` tried to open a pull request in a repository that doesn't allow it. See [Pull requests and workflow tokens](#pull-requests-and-workflow-tokens). | ## Intent The intent is a plain statement of what the change is supposed to achieve, separate from the description. It appears at the top of the conversation in a highlighted **Intent** box ("what this change is meant to do"), is included in notification emails, is returned as `intent` by the API and by `diffinery pr view`, and is included in the `get_pull_request` MCP result. Reviewers, human or AI, can check the diff against it rather than guessing the goal from the code. Write it as one or two sentences about the outcome and its limits, for example: "Fix the race in token refresh without changing the public API." Agents should always set it. The intent is set when you open the pull request and can't be edited afterwards. ## Drafts Open a pull request as a draft with **Create as draft**, `diffinery pr create --draft`, `"draft": true` in the API, or `draft: true` in `create_pull_request`. A draft: - shows a **Draft** badge and appears in lists with a `draft` label, - can be reviewed, and runs its `pull_request` workflows like any other pull request, - can't be merged or added to a merge queue: it has the blocker `pull request is a draft`, and admins can't bypass it. When the work is ready, mark the draft ready for review. You can also turn an open pull request back into a draft. Either change needs you to be the pull request's author or to have write access or higher, and the pull request must be open. Through the API, the token needs the `write` scope. ### Mark a draft ready for review - **Web UI:** on the **Conversation** tab, the merge box of a draft says `This pull request is still a draft and can't be merged yet.` next to a **Ready for review** button. Click it; you see `Pull request marked ready for review.` The button is shown to the author and to people with write access, and not on archived repositories. - **CLI:** `diffinery pr ready 12` prints `✓ Marked #12 ready for review`. With `--json` it prints the pull request instead. - **REST API:** `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{"draft": false}`. GitHub offers this only through GraphQL; on Diffinery `draft` is a field of the REST update. ```sh curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/pulls/12 \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"draft": false}' ``` The response is the pull request with `"draft": false`. There is no MCP tool for this yet: agents use the REST API or the CLI. ### Convert a pull request to a draft - **CLI:** `diffinery pr ready 12 --undo` prints `✓ Converted #12 to a draft`. - **REST API:** `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{"draft": true}`. The web UI has no button for this. ### What changing the draft state does - The pull request's timeline gets a `ready_for_review` or `convert_to_draft` event (GitHub's timeline names), and webhooks get a `pull_request` event with the action `ready_for_review` or `converted_to_draft` (GitHub's webhook names). See [Webhook events](https://diffinery.com/docs/reference/webhook-events#pull_request). - `pull_request` and `pull_request_target` workflows whose `types` include `ready_for_review` or `converted_to_draft` run. The default types don't include them. See [Events](https://diffinery.com/docs/actions/events#pull_request). - Converting a pull request to a draft turns off [auto-merge](#auto-merge), with the comment `Auto-merge was turned off: the pull request was converted to a draft.` Marking it ready again doesn't turn auto-merge back on. - Asking for the state the pull request already has changes nothing and sends no event. - `PATCH` can change `draft` together with `title`, `body` and `state` in one request. | Error | Cause | | --- | --- | | `only the author or someone with write access can change draft status` (403) | Ask the author or a maintainer. | | `the pull request is not open` (422) | Reopen it first. | | `not a pull request` (404) | The number belongs to an issue. | | `this token needs the write scope` (403) | Use a token with the `write` or `admin` scope. | ## The pull request page The page at `https://diffinery.com/acme/website/pull/12` has four tabs. It updates live as comments, reviews, pushes and checks arrive. | Tab | Shows | | --- | --- | | **Conversation** | The description, the intent, comments, reviews with their line comments, the merge box, **Review changes**, the comment form, and a sidebar with **Reviewers**, **Labels**, **Milestone**, **Assignees**, **Notifications** and **Participants** | | **Commits** | The commits on the head branch that aren't on the base (up to 250) | | **Checks** | Check runs and commit statuses reported on the head commit, and the workflow runs for this pull request | | **Files changed** | The diff from the merge base to the head (up to 300 files), where you can comment on lines | The header says `wants to merge into main from fix-pricing-link` (or `merged into` once merged). Agent authors and reviewers are marked with an `agent` badge and `on behalf of` their owner. Labels, assignees, milestones, comments, mentions, editing the title and description, and subscribing work exactly as on issues. See [Issues](https://diffinery.com/docs/collaboration/issues). To put a pull request in a milestone, someone with triage access or higher uses **Set milestone** in the sidebar, or sends `{"milestone": 1}` (or `null`) with `PATCH /repos/{owner}/{repo}/issues/{number}` using the pull request's number. Pull request objects from the REST API carry the same `milestone` object as issues. Milestones themselves are created and edited with the REST API; see [Milestones](https://diffinery.com/docs/collaboration/issues#milestones). ## Reviews A review is one of three kinds: | Kind | Web UI label | API and MCP `event` | Effect | | --- | --- | --- | --- | | Approve | **Approve** | `APPROVE` | Counts toward required approvals (people only, not agents or workflow tokens) | | Request changes | **Request changes** | `REQUEST_CHANGES` | Blocks merging until that reviewer approves | | Comment | **Comment** | `COMMENT` | Feedback only; needs a body or line comments | Rules: - You can't approve or request changes on your own pull request; you can still leave a Comment review. - Approving and requesting changes need write access. Anyone signed in who can read the repository can leave a Comment review. - Only open pull requests can be reviewed. - For each reviewer, their latest Approve or Request changes review is the one that counts. A later Comment review doesn't change it. To withdraw a change request, submit an Approve review, or ask a maintainer to [dismiss it](#dismiss-a-review). - Approvals from agents and from a workflow's `GITHUB_TOKEN` are shown but never count toward required approvals. An agent's (or a workflow token's) Request changes review does block merging. - A workflow's `GITHUB_TOKEN` can approve or request changes only when the repository allows it; see [Pull requests and workflow tokens](#pull-requests-and-workflow-tokens). - If the base branch's rule has **Dismiss stale approvals when new commits are pushed**, pushing to the head marks earlier approvals as dismissed (shown as `reviewed (dismissed)`), and only approvals of the current head commit count. - Submitting any review removes you from the pull request's requested reviewers. Reviews are submitted immediately. There are no pending (draft) reviews. ### Review in the web UI 1. On the **Conversation** tab, expand **Review changes**. 2. Write a **Review summary** (Markdown). 3. Pick the **Review type**: **Comment**, **Approve** or **Request changes**. Approve and Request changes are disabled on your own pull request or without write access. 4. Click **Submit review**. ### Review with the CLI ```sh diffinery pr review 12 --approve diffinery pr review 12 --approve --body "Looks good." diffinery pr review 12 --request-changes --body "Please add a test for the redirect." diffinery pr review 12 --comment --body "Nit: the link text could be clearer." ``` Exactly one of `-a, --approve`, `-r, --request-changes` or `-c, --comment` is required. `--body` (or `--body-file`) is required for request changes and comment. ### Review with the REST API ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls/12/reviews \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{ "event": "REQUEST_CHANGES", "body": "Please add a test.", "comments": [{ "path": "src/pricing.html", "line": 42, "side": "RIGHT", "body": "This should be /contact." }] }' ``` `event` defaults to `COMMENT`. List reviews with `GET /repos/{owner}/{repo}/pulls/{number}/reviews`, or read one with `GET /repos/{owner}/{repo}/pulls/{number}/reviews/{review_id}`. Each has a numeric `id` (as on GitHub), `state` (`APPROVED`, `CHANGES_REQUESTED`, `COMMENTED` or `DISMISSED`), `body`, `commit_id`, `submitted_at`, `user` and `html_url`. ### Review over MCP `review_pull_request` takes `owner`, `repo`, `number`, `event`, and optionally `body` and `comments` (`[{ path, line, side?, body }]`). ### Dismiss a review Dismissing a review takes it out of the merge decision: a dismissed approval stops counting toward required approvals, and a dismissed change request stops blocking. The review stays visible with the state `DISMISSED`, and the reviewer can review again. This is the same as GitHub's dismissal, with stricter rules about who may do it, because dismissing can unblock a merge: - You need the maintain or admin role on the repository (a token with the `write` scope caps you at maintain, which is enough). - You can't dismiss reviews on your own pull request. - Agents can never dismiss reviews. - Only `APPROVED` and `CHANGES_REQUESTED` reviews can be dismissed. Dismiss through the REST API with a message saying why: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/reviews/81/dismissals \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"message": "The requested test was added in 3f2a9c1."}' ``` `81` is the review's `id` from the reviews list. The response is the review with `"state": "DISMISSED"`, and the pull request's timeline gets a `review_dismissed` event with the message. There is no dismiss button in the web UI, CLI command or MCP tool yet. | Error | Cause | | --- | --- | | `message is required` (422) | Send a non-empty `message`. | | `maintain access is required to dismiss reviews` (403) | Your role, or your token's scope, is below maintain. | | `agents can't dismiss reviews` (403) | Dismiss as a person. | | `you can't dismiss reviews on your own pull request` (403) | Ask another maintainer. | | `only approving or change-requesting reviews can be dismissed` (422) | The review is a Comment review or already dismissed. | | `review not found` (404) | Check the review id and the pull request number. | ## Line comments Line comments attach to a file and line of the diff. - **Web UI:** on the **Files changed** tab, click a line, type in **Leave a comment on this line** and click **Add comment**. Each line comment is posted at once as a Comment review. - **API and MCP:** include `comments` in a review: `path` (the file), `line` (the line number), `side` (`RIGHT` for the head version, the default, or `LEFT` for the base version) and `body`. Line comments appear on the **Files changed** tab next to their line and in the conversation under their review. A comment made on an older head commit is marked `outdated`. List them with `GET /repos/{owner}/{repo}/pulls/{number}/comments` (fields include a numeric `id`, `pull_request_review_id`, `body`, `path`, `line`, `side`, `commit_id`, `in_reply_to_id`, `user`, `created_at`, `reactions` and Diffinery's `resolved`). ### Single line comments and replies with the REST API You can also post one line comment, or reply to one, without writing a whole review. Each is stored as a one-comment Comment review, as on GitHub. ```sh # A new line comment curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls/12/comments \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"body": "This should be /contact.", "path": "src/pricing.html", "line": 42, "side": "RIGHT"}' # A reply to line comment 57: it lands on the same file, line and side curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls/12/comments/57/replies \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"body": "Done in the latest push."}' ``` `POST .../pulls/{number}/comments` with `in_reply_to` (the parent comment's id) and `body` is the same as the replies endpoint. `position` is accepted as another name for `line`. A new comment needs `path` and `line` (otherwise `path and line are required`), and every comment needs a `body` (`body is required`). The same rules as reviews apply: the pull request must be open, and a locked pull request accepts comments only from triage or higher. Other line comment endpoints: | Method and path | Purpose | | --- | --- | | `GET /repos/{owner}/{repo}/pulls/comments` | Line comments across all pull requests in the repository. Takes `since`, `sort` (`created` or `updated`), `direction` (`asc`, the default, or `desc`), `per_page` and `page`. | | `GET /repos/{owner}/{repo}/pulls/comments/{id}` | One line comment | | `PATCH /repos/{owner}/{repo}/pulls/comments/{id}` | Change its `body` (its author, or maintain or higher) | | `DELETE /repos/{owner}/{repo}/pulls/comments/{id}` | Delete it (its author, or maintain or higher) | Comment ids are numbers, as on GitHub. ## Requested reviewers Ask specific people or agents to review: - when opening: `reviewers` in the API and MCP, or `diffinery pr create --reviewer LOGIN`, - later, through the API (the author, or triage or higher): ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls/12/requested_reviewers \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"reviewers": ["diffy", "review-bot"]}' ``` Requested reviewers are notified and appear in the sidebar's **Reviewers** list as `pending` until they submit a review. Logins aren't checked. Requesting a review doesn't make it required; required approvals come from the branch protection rule. `team_reviewers` is refused with `team reviewers are not supported; request people by login`. Read the current requests with `GET /repos/{owner}/{repo}/pulls/{number}/requested_reviewers` (`{"users": [...], "teams": []}`), and remove one (the author, or triage or higher): ```sh curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/pulls/12/requested_reviewers \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"reviewers": ["review-bot"]}' ``` Requests and removals show in the pull request's events as `review_requested` and `review_request_removed`. ## Checks Checks are reported on the pull request's head commit, as on GitHub. Each workflow job reports a check named `<workflow name> / <job name>` (for example `CI / test`). Other systems can report check runs or commit statuses (write access). Commit statuses show with the checks and count as checks for branch protection. See [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses) for both APIs. A check run, for example: ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/check-runs \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"name": "lighthouse", "head_sha": "HEAD_SHA", "status": "completed", "conclusion": "success", "details_url": "https://ci.example.com/run/1", "output": {"title": "Score 98", "summary": "All budgets met."}}' ``` Posting again with the same `name` on the same commit (from the same account) updates the check. Read check runs with `GET /repos/{owner}/{repo}/commits/{ref}/check-runs` and statuses with `GET /repos/{owner}/{repo}/commits/{ref}/status`. A check whose reporter gave a `details_url` (or a status `target_url`) links there from the pull request. Where checks show up: - The merge box lists every check with its state: `All checks have passed`, `N failing checks`, or `Some checks haven't completed yet`. - The **Checks** tab shows each check and the pull request's workflow runs. - `diffinery pr checks 12` prints them and exits `0` when all passed, `1` when any failed, and `8` when some are still pending, so scripts can wait on it. Like `gh pr checks --watch`, `diffinery pr checks 12 --watch` polls (every 10 seconds; `-i SECONDS` to change it) until no check is pending and then exits `0` or `1`. On a terminal it prints the checks each time they change; when piped, it prints them once at the end (`--json` prints the final list). - `GET /repos/{owner}/{repo}/pulls/12` includes `checks` (name and state). A check counts as passing when its conclusion is `success`, `skipped` or `neutral` (a status: when its state is `success`). Which checks must pass is set by **Required status checks** in branch protection, and not every report counts toward them: checks reported by agents never do, and a check that Diffinery Actions reports is decided by Actions alone. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection#required-checks). ## Pull request workflows Workflows that trigger on `pull_request` behave as on GitHub: - They check out the **test merge commit**, `refs/pull/<n>/merge`: the head merged into the current base. `GITHUB_REF` is `refs/pull/<n>/merge` and `GITHUB_SHA` is the test merge commit. Their checks are reported on the head commit. - The default activity types are `opened`, `synchronize` and `reopened`. Add `closed` to `types` to run when a pull request closes; `github.event.pull_request.merged` tells you whether it merged. Add `edited` to run when the title or description changes, and `ready_for_review` or `converted_to_draft` to run when the draft state changes. - While the pull request conflicts with its base, there is no test merge commit and `pull_request` workflows don't run. - `pull_request_target` workflows run the base branch's own workflow file on the base commit, with the repository's secrets, so a pull request can't change what runs. They run even while the pull request conflicts. Example: ```yaml name: CI on: pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test ``` See [Events](https://diffinery.com/docs/actions/events) for every trigger and filter. ## Update a branch with its base When the base branch has moved on, you can merge it into the pull request's head branch on the server, like GitHub's **Update branch**. This is available through the REST API (write access); the web UI, CLI and MCP have no button or command for it yet. ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/update-branch \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"expected_head_sha": "3f2a9c1e..."}' ``` `expected_head_sha` is optional: when given, the update only happens if the head is still at that commit. Diffinery creates a merge commit on the head branch with the parents (head, base), the message `Merge branch 'main' into fix-pricing-link`, and you as author and committer (`<login>@users.noreply.diffinery.com`). The response is `202` with `{"message": "Updating pull request branch.", "url": "..."}`, and the branch has already moved when it arrives. The update is a push to the head branch: the head branch's protection rules and agent rules apply, the pull request gets a `synchronize` event, its `pull_request` workflows run again, and stale approvals are dismissed if the base's rule says so. A commit created by an agent carries the `diffinery-agent` header. | Error | Cause | | --- | --- | | `There are no new commits on the base branch.` (422) | The head already contains the base. | | `merge conflict between base and head` (422) | Resolve the conflict locally (see [Conflicts](#conflicts)). | | `expected head sha didn't match current head ref.` (422) | Someone pushed to the head. Fetch it and try again. | | `head branch moved; try again` (409) | The head moved while the merge was being made. | | `pull request is closed` (422) | Only open pull requests can be updated. | | `base branch main no longer exists` (422) | The base branch was deleted. | | `write access is required to update the branch` (403) | You need write access. | If the base requires linear history, don't use `update-branch`: it adds a merge commit to the head. Rebase locally instead. ## Conflicts When the head and base change the same lines, the merge box says `This branch has conflicts that must be resolved`, lists the conflicting files, and shows the blocker `merge conflicts in N file(s)`. Diffinery has no web conflict editor, and `update-branch` refuses to merge a conflicting base. Resolve the conflict locally and push: ```sh git fetch origin git switch fix-pricing-link git merge origin/main # or: git rebase origin/main, then git push --force-with-lease # fix the conflicts, then git add -A && git commit git push ``` The push updates the pull request, rebuilds its test merge commit, and runs its `pull_request` workflows again. ## Merge methods | Method | Web UI label | Result | | --- | --- | --- | | `merge` | **Create a merge commit** | A merge commit with two parents (base, head). Subject `Merge pull request #12 from fix-pricing-link`, body the pull request title. Author and committer: the person merging. | | `squash` | **Squash and merge** | One commit with a single parent (base). Subject `<title> (#12)`, body a `* <subject>` line per commit. Author: the pull request's author; committer: the person merging. | | `rebase` | **Rebase and merge (fast-forward)** | No new commit: the base fast-forwards to the head. Only works when the head already contains the base; otherwise it fails with `rebase merge requires the branch to be up to date with its base (fast-forward only for now)`. | Commits Diffinery writes use the email `<login>@users.noreply.diffinery.com`. Through the API (and `diffinery pr merge --subject/--body`) you can set the commit title and message for `merge` and `squash`. Default method when you don't pick one: | Where | Base branch without linear history | Base branch with **Require linear history** | | --- | --- | --- | | REST API (`merge_method` left out) | `merge` | `squash` | | Web UI merge form | **Create a merge commit** | **Squash and merge**; **Create a merge commit** isn't offered | | CLI (`diffinery pr merge` without `--merge`, `--squash` or `--rebase`) | `merge` | `merge`, which is refused: pass `--squash` or `--rebase` | | MCP `merge_pull_request` (no `method`) | `merge` | `merge`, which is refused: pass `method` | On a branch whose rule has **Require linear history**, a merge with the `merge` method is refused with `405` and `main requires linear history; use squash or rebase` (the merge queue refuses it with `422` and the same message), so choose `squash` or `rebase` there. Only an explicit [admin bypass](#admin-bypass) with `bypass_rules` can create a merge commit on such a branch, and that is recorded in the audit log. In a [merge queue](https://diffinery.com/docs/collaboration/merge-queue), `rebase` replays each commit onto the queue instead of fast-forwarding. ## Merge a pull request Merging needs write access and a pull request with no [merge blockers](#merge-blockers). If the base branch uses a merge queue, merging adds the pull request to the queue instead; see [Merge when ready and the merge queue](#merge-when-ready-and-the-merge-queue). If the pull request isn't ready yet, turn on [auto-merge](#auto-merge) and Diffinery merges it once it is. ### Merge in the web UI 1. On the **Conversation** tab, check the merge box: `This branch has no conflicts with the base branch` means it is ready; otherwise it lists the blockers. 2. Pick the **Merge method**: **Create a merge commit**, **Squash and merge** or **Rebase and merge (fast-forward)**. On a base branch that requires linear history, the form starts on **Squash and merge** and doesn't list **Create a merge commit**. 3. Leave **Delete fix-pricing-link after merge** ticked to delete the head branch, or untick it. 4. Click **Merge pull request**. The timeline then shows `diffy merged commit abc1234 into main`. ### Merge with the CLI ```sh diffinery pr merge 12 --squash --delete-branch diffinery pr merge 12 --merge --subject "Merge the pricing fix" --body "Fixes #12" diffinery pr merge 12 --rebase --match-head-commit 3f2a9c1e... ``` | Flag | Meaning | | --- | --- | | `-m, --merge` | Create a merge commit (default) | | `-s, --squash` | Squash into one commit | | `-r, --rebase` | Fast-forward the base to the head | | `-d, --delete-branch` | Delete the head branch after merging (with `--auto`, once it lands). A merge never deletes the default branch or a protected branch. | | `-t, --subject TEXT` | Commit title | | `-b, --body TEXT`, `--body-file FILE` | Commit message body | | `--match-head-commit SHA` | Only merge if the head is still at this commit | | `--admin` | Merge now, bypassing unmet branch rules and any merge queue (repository admins; audited). Sends `bypass_rules` and `bypass_merge_queue`. See [Admin bypass](#admin-bypass). | | `--auto` | Turn on auto-merge instead of merging now: Diffinery merges (or adds it to the merge queue) once every requirement is met. Sends `PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge` with the method flag, `--delete-branch`, `--subject`, `--body` and `--match-head-commit` you pass. Can't be combined with `--admin`. | | `--disable-auto` | Turn auto-merge off (`DELETE /repos/{owner}/{repo}/pulls/{number}/auto-merge`). If the pull request is already in the merge queue, it is also removed from it, so Diffinery no longer lands it by itself. | Like `gh pr merge --auto`, `diffinery pr merge 12 --auto --squash` prints `✓ Auto-merge is on for #12 (squash): it merges once every requirement is met`, or `✓ Merged #12` / `✓ Added #12 to the merge queue` when the pull request was ready already. Without a method flag the server picks the merge queue's method, `squash` where linear history is required, and a merge commit otherwise. `diffinery pr merge 12 --disable-auto` prints `✓ Turned off auto-merge for #12`, followed by `and removed it from the merge queue` when it was queued. ### Merge with the REST API ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/merge \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"merge_method": "squash", "delete_branch": true, "sha": "HEAD_SHA"}' ``` | Field | Meaning | | --- | --- | | `merge_method` | `merge`, `squash` or `rebase` | | `commit_title`, `commit_message` | Override the commit title and body (`merge` and `squash`) | | `sha` | Only merge if the head is still at this commit; otherwise `409 head branch was modified; review and try again` | | `delete_branch` | Delete the head branch after merging (default `false`) | | `bypass_rules` | Admins only: merge even though the pull request has unmet branch rules, or create a merge commit on a branch that requires linear history. It doesn't skip a merge queue. See [Admin bypass](#admin-bypass). | | `bypass_merge_queue` | Admins only: merge directly on a branch that uses a merge queue. It skips only the queue: a pull request with unmet branch rules also needs `bypass_rules`. | The response is `{"merged": true, "sha": "...", "method": "squash", "message": "Pull Request successfully merged"}`. ### Check mergeability `GET /repos/{owner}/{repo}/pulls/{number}` reports, like GitHub: | Field | Meaning | | --- | --- | | `mergeable` | `true` when the head merges into the base without conflicts, `false` when it conflicts, `null` once the pull request is closed. It doesn't consider approvals or checks. | | `mergeable_state` | `clean` (no blockers), `blocked` (blockers other than conflicts), `dirty` (conflicts), `draft`, or `unknown` (merged, closed, or the base branch is gone) | | `rebaseable` | `true` when the head has no conflicts and already contains the latest base commit, so a `rebase` (fast-forward) merge works. `null` once closed. | Diffinery adds `merge_blockers` (the reasons it can't merge yet), `approvals`, `required_approvals`, `checks` (name and state of every check run and status on the head) and `merge_queue`. `GET /repos/{owner}/{repo}/pulls/{number}/merge` depends on the client. With a GitHub media type (`Accept: application/vnd.github+json`, as Octokit and `gh` send), it answers like GitHub: `204` if the pull request is merged and `404` if not. Other clients get Diffinery's mergeability report: `mergeable`, `state` (`clean`, `blocked`, `conflicting`, `draft`, `merged`, `closed` or `unknown`), `conflicts`, `approvals`, `requiredApprovals`, `changesRequested`, `checks`, `blockers`, `mergeBase` and `mergeQueue`. ### Merge over MCP `merge_pull_request` takes `owner`, `repo`, `number`, and optionally `method`, `expected_head_sha` and `delete_branch`. On a branch with a merge queue it queues the pull request instead. Agents can't merge into the default branch or a protected branch themselves; they use `enable_auto_merge` there (see [Auto-merge](#auto-merge)). ### After the merge - The pull request is closed with the reason `merged`, and `merged_by` and `merge_commit_sha` are set. - The base branch moves as if pushed to: `push` workflows run and webhooks fire. - `pull_request` workflows whose `types` include `closed` run, with `github.event.pull_request.merged` set to `true`. - Open issues referenced with a closing keyword (`Fixes #12`, `closes #3`, `resolves #7`) close. See [Issues](https://diffinery.com/docs/collaboration/issues#close-issues-from-a-pull-request). - If you asked for it, the head branch is deleted, unless it is the default branch or has moved since the merge. ### Admin bypass A repository admin can merge a pull request whose branch rules aren't met, but only by asking for it explicitly. Without the request, an admin's merge is refused like anyone else's. A bypass is possible when all of these hold: - you have the admin role on the repository (through a token, that needs the `admin` scope: a `write` token caps you at maintain); - you are a person: agents can never bypass; - the base branch's rule doesn't have **Do not allow administrators to bypass these rules**; - the pull request has no merge conflicts and isn't a draft. Each bypass flag covers only what it names: | Flag | Skips | Doesn't skip | | --- | --- | --- | | `bypass_rules` | Missing approvals, change requests, required checks that failed or haven't run, a branch that is behind its base under linear history, and the refusal of merge commits under linear history | A merge queue | | `bypass_merge_queue` | The merge queue: the pull request merges directly | Any unmet branch rule (approvals, checks, linear history) | To merge past both a merge queue and unmet rules, send both flags. Conflicts, a draft state and the agent rules are never bypassed. How to ask for it: - **Web UI:** when you are an admin and the pull request has blockers but no conflicts and isn't a draft, the button reads **Merge without waiting for requirements (bypass)** and asks `Merge without meeting the branch requirements?` before merging. It sends `bypass_rules`. The button isn't shown when the base branch's rule has **Do not allow administrators to bypass these rules**. On a branch with a merge queue, the merge box instead offers **Merge now (bypass the queue)**, which sends only `bypass_merge_queue`; see [Merge queue](https://diffinery.com/docs/collaboration/merge-queue#bypass-the-queue). - **CLI:** `diffinery pr merge 12 --squash --admin`. The CLI sends both `bypass_rules` and `bypass_merge_queue`. - **REST API:** add `"bypass_rules": true` to `PUT /repos/{owner}/{repo}/pulls/{number}/merge`: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/merge \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"merge_method": "squash", "bypass_rules": true}' ``` When you could bypass but didn't ask, the `405` message ends with `(as an admin you can bypass with bypass_rules)`, for example `pull request is not mergeable: needs 1 more approving review(s) (as an admin you can bypass with bypass_rules)`. MCP's `merge_pull_request` has no bypass option. A merge that bypassed anything is recorded in the [audit log](https://diffinery.com/docs/security/audit-log#git-and-pull-requests) as `pull_request.merge_bypassed`, with the pull request as the target and the `base` branch and the bypassed `blockers` in the detail, in addition to the usual `pull.merge` entry. `blockers` lists the unmet rules skipped with `bypass_rules`, `linear history required` when a merge commit was created on a linear-history branch, and `merge queue` when the queue was skipped with `bypass_merge_queue`. ## Merge blockers A pull request can be merged when it has no blockers. The merge box, `diffinery pr view`, `merge_blockers` in the API and `mergeability.blockers` in MCP list them with these exact messages: | Blocker | Meaning and fix | | --- | --- | | `already merged` | The pull request is merged. | | `pull request is closed` | Reopen it first. | | `base branch main no longer exists` | The base branch was deleted. Recreate it, or open a new pull request against another base. | | `pull request is a draft` | Drafts can't be merged. See [Drafts](#drafts). | | `merge conflicts in N file(s)` | Resolve the conflicts and push. See [Conflicts](#conflicts). | | `needs N more approving review(s)` | Get more approvals from people with write access. Agent approvals don't count. | | `changes requested by diffy` | That reviewer must approve before merging. | | `required check 'CI / test' has not run` | No counted check run or status with that exact name was reported on the head commit. Check the workflow's trigger and the check name. Reports from agents don't count, and neither do reports from accounts other than the one bound in the rule's `requiredCheckSources`. | | `required check 'CI / test' is queued` (or `is in_progress`) | Wait for it to finish. A `pending` commit status shows as `is in_progress`. | | `required check 'CI / test' failure` (or another conclusion such as `cancelled`, `timed_out` or `action_required`) | The check didn't pass. Fix the code or re-run the workflow. | | `branch is behind its base; update it (linear history required)` | The base requires linear history and the head doesn't contain the latest base. Rebase onto the base and push. | | `write access is required to merge` | You need write access. | | `agents may not merge into this branch; a human must merge` | An agent tried to merge into the default branch or a branch with a protection rule that lacks **Allow agents to merge pull requests**. This also applies to a workflow's `GITHUB_TOKEN` in a run whose code came from an agent. Where the rule allows it, the agent can [turn on auto-merge](#auto-merge-for-agents-and-workflow-tokens) instead. | | ``refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission`` | A workflow's `GITHUB_TOKEN` tried to merge a pull request that adds, changes or deletes a workflow file. A person must merge it. | Required approvals come from the branch protection rule's **Required approvals** (0 to 10). Pull requests opened by agents need at least one even without a rule. When the base branch uses a merge queue, merging directly fails with `main requires a merge queue: add the pull request to the queue` (`405`), with `blockers: ["main merges through a merge queue"]`. For an admin who could bypass, the message adds `(or, as an admin, bypass the queue with bypass_merge_queue)`. Merge blockers apply to admins too, unless they ask for an [admin bypass](#admin-bypass). When Diffinery lands a pull request for an agent or a workflow token, through [auto-merge](#auto-merge) or the merge queue, the agent blocker is replaced by the rules in [Auto-merge for agents and workflow tokens](#auto-merge-for-agents-and-workflow-tokens), and these blockers can appear: `needs N more approving review(s) of its latest commit`, ``the pull request changes workflow file `<path>`; a person must merge it``, and the refusals `agents may not have pull requests merged into main automatically ...` and `main has no required status checks: ...`. ## Auto-merge Auto-merge lets you approve the outcome now and have Diffinery do the merge later: you turn it on for a pull request that isn't ready yet (required checks still running, an approval still missing, the branch behind its base), and Diffinery merges it by itself as soon as every rule of its base branch is met. It works like GitHub's auto-merge, and it is also how agents land changes on protected branches without ever merging there themselves. To have it on for every pull request without turning it on each time, see [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green): a repository's **Settings → Merge on green** picks the checks to wait for, and **Settings → Pull requests** (or an agent's **Merge its pull requests on green**) turns auto-merge on as each pull request opens. `diffinery pr create --auto` and `"auto_merge": true` on `POST /repos/{owner}/{repo}/pulls` turn it on for one pull request. ### How auto-merge works - **It checks right away.** When you turn it on, Diffinery looks at the pull request at once. If nothing blocks it, it merges immediately (the API answers `"state": "merged"`). Otherwise auto-merge stays on and waits. - **It looks again when something changes.** Diffinery checks the pull request again when a check run completes or a commit status other than `pending` is reported on its head commit, when a review is submitted or dismissed, when its head branch or its base branch is pushed to, and when a branch protection rule is saved or deleted. - **It merges only when nothing blocks.** The pull request must have no [merge blockers](#merge-blockers): every required check passed on the head commit, enough approvals from people, no changes requested, no conflicts, and an up-to-date branch where the base requires linear history. Auto-merge never bypasses anything, even when an admin turned it on. Checks that aren't required aren't waited for, so on a branch without required checks a pull request with enough approvals merges at once, even while CI is still running. - **Into a merge queue, it queues.** When the base branch has **Require a merge queue**, auto-merge adds the pull request to the [merge queue](https://diffinery.com/docs/collaboration/merge-queue) once it is ready, with the auto-merge method, and then turns itself off: the queue takes over and merges it when its merge group passes. The API answers `"state": "queued"` when that happens right away. - **It merges as the one who turned it on.** The merge is made by the person, agent or workflow token that turned auto-merge on, with their access at the time of the merge, and never with more than they had when they turned it on. They appear as `merged_by`, and the commit is written as if they had merged by hand (see [Merge methods](#merge-methods)). A merge or squash commit made for an agent carries the `diffinery-agent` header. - **It keeps the head branch.** Auto-merge doesn't delete the head branch after merging. Delete it yourself afterwards. - **Turning it on again changes it.** Calling it again while auto-merge is on replaces the merge method and commit text. Closing the pull request, or merging it some other way, ends auto-merge quietly. Reopening a closed pull request doesn't turn it back on. ### Who can turn on auto-merge - You need write access to the repository, and through the API a token with the `write` (or `admin`) scope. On an archived repository it is refused with `repository is archived (read-only)`. - The pull request must be open, not a draft, not merged and not already in the merge queue. - **People** can turn it on for any base branch. - **Agents and workflow tokens** (`GITHUB_TOKEN`) can turn it on for a base branch that is neither the default branch nor protected by a rule. On the default branch or a protected branch, the branch's rule must have **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`) and at least one required status check; an agent may also use it where the rule has **Allow agents to merge pull requests** (`agentsMayMerge`). See [Auto-merge for agents and workflow tokens](#auto-merge-for-agents-and-workflow-tokens). - A workflow's `GITHUB_TOKEN` needs `contents: write` in the job's `permissions:`, as for merging. Anyone with write access can turn it off, whoever turned it on. ### Auto-merge method Auto-merge uses one of the three [merge methods](#merge-methods). When you don't pick one: | Base branch | Default auto-merge method | | --- | --- | | Uses a merge queue | The queue's method | | Requires linear history (no merge queue) | `squash` | | Anything else | `merge` | On a branch that requires linear history, `merge` is refused with `main requires linear history; use squash or rebase` (`422`). Outside a merge queue, `rebase` is a fast-forward: it only works once the head already contains the latest base commit, so prefer `squash` unless you keep the branch rebased yourself. Through the API and MCP you can also set the commit title and message for `merge` and `squash`; the web form doesn't offer them. ### Turn on auto-merge in the web UI 1. Open the pull request's **Conversation** tab. The merge box offers auto-merge when you have write access, the pull request can't merge yet, has no conflicts and isn't a draft, and auto-merge isn't already on. 2. Under the merge button, next to `Merge by itself once every requirement is met.` (on a branch with a merge queue, `Join the merge queue by itself once every requirement is met.`), pick the **Auto-merge method**: **Create a merge commit**, **Squash and merge** or **Rebase and merge**. **Create a merge commit** isn't listed on a branch that requires linear history, and on a merge queue branch the queue's method is preselected. 3. Click **Enable auto-merge**. You see `Auto-merge is on: it merges once every requirement is met.`, or `Pull request merged.` or `Added to the merge queue.` when the pull request turned out to be ready. While it waits, the merge box shows **Auto-merge is on**, who turned it on, and what it will do, for example `Auto-merge is on · turned on by diffy · squash and merge once every requirement is met` (or `joins the merge queue (squash and merge) once every requirement is met`). To turn it off, click **Disable auto-merge** in that row; you see `Auto-merge is off.` The button isn't offered while the pull request can already merge: merge it directly instead. ### Turn on auto-merge with the REST API GitHub offers auto-merge only through GraphQL (`enablePullRequestAutoMerge` and `disablePullRequestAutoMerge`); Diffinery has REST endpoints for it: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/auto-merge \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"merge_method": "squash", "commit_title": "Fix the Contact sales link (#12)"}' ``` | Field | Meaning | | --- | --- | | `merge_method` | `merge`, `squash` or `rebase`. Default: see [Auto-merge method](#auto-merge-method). | | `commit_title`, `commit_message` | Title and body of the merge or squash commit. Default: the same as a merge by hand. | All fields are optional; an empty body works. The response (`200`) says what happened: ```json { "state": "enabled", "auto_merge": { "enabled_by": { "login": "diffy", "type": "User" }, "merge_method": "squash", "commit_title": "Fix the Contact sales link (#12)", "commit_message": null }, "merge_commit_sha": null } ``` | `state` | Meaning | | --- | --- | | `enabled` | Auto-merge is on and waiting. `auto_merge` shows its settings (`enabled_by` is a full user object). | | `merged` | The pull request was ready and merged right away. `auto_merge` is `null` and `merge_commit_sha` is the new commit. | | `queued` | The base branch uses a merge queue and the pull request was ready, so it joined the queue. `auto_merge` is `null`. | While auto-merge is on, the pull request object (`GET /repos/{owner}/{repo}/pulls/{number}` and the list) carries the same `auto_merge` object, as on GitHub; otherwise `auto_merge` is `null`. Turn it off with: ```sh curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/pulls/12/auto-merge \ -H "Authorization: Bearer dfy_..." ``` It returns `204`, also when auto-merge was already off. Both endpoints need write access and a `write` token. ### Turn on auto-merge with the CLI `diffinery pr merge --auto` turns auto-merge on, with the same flags as a merge, and `--disable-auto` turns it off: ```sh diffinery pr merge 12 --auto --squash --delete-branch diffinery pr merge 12 --disable-auto ``` `diffinery pr view 12` lists the merge blockers auto-merge is waiting on. ### Turn on auto-merge over MCP `enable_auto_merge` takes `owner`, `repo`, `number`, and optionally `merge_method`, `commit_title`, `commit_message`, `delete_branch` and `expected_head_sha`. It returns `state` (`enabled`, `queued` or `merged`), `autoMerge` (`method`, `commitTitle`, `commitMessage`, `enabledBy` and `enabledAt`, or `null`) and, once merged, `mergeCommit`. `disable_auto_merge` takes `owner`, `repo` and `number` and returns `{"auto_merge": null}`. `get_pull_request` shows the current setting as `issue.pull.autoMerge`. Both tools need a `write` token. See [MCP server](https://diffinery.com/docs/agents/mcp). ### When auto-merge turns itself off Auto-merge turns itself off when the pull request can never go through as set up, and posts a comment on the pull request saying why, as `diffinery-auto-merge[bot]`: `Auto-merge was turned off: <reason>.` The comment notifies the pull request's participants like any other comment. | Reason in the comment | What happened and what to do | | --- | --- | | `diffy pushed new commits to the branch` | Someone other than the one who turned auto-merge on pushed to the head branch, as on GitHub. Review the new commits and turn it on again. Pushes by the same person or agent keep it on. | | `the pull request was converted to a draft` | Mark it ready for review and turn auto-merge on again. | | `diffy no longer has write access` | The account that turned it on lost write access to the repository. Someone with write access turns it on again. | | `agents may not have pull requests merged into main automatically unless its branch protection allows it ...` or `main has no required status checks: allowAgentAutoMerge needs at least one ...` | An agent's or workflow token's auto-merge, and the branch rule no longer allows it. See [Auto-merge for agents and workflow tokens](#auto-merge-for-agents-and-workflow-tokens). | | `main requires linear history, and auto-merge was set to create a merge commit` | The rule started requiring linear history. Turn auto-merge on again with `squash` or `rebase`. | | `rebase merge requires the branch to be up to date with its base (fast-forward only for now)` | A `rebase` auto-merge found the head behind the base. Rebase the branch and push, or use `squash`. | | Another merge error, or `the merge failed` | The merge was attempted and refused for a reason that won't fix itself. Read the message, fix the cause, and turn auto-merge on again. | When the head or base branch moves in the middle of the merge, auto-merge stays on and tries again on the next change. Whoever's action caused the turn-off (the pusher, or the person who converted the pull request to a draft) is recorded as turning it off; otherwise it is `diffinery-auto-merge[bot]`. ### Auto-merge for agents and workflow tokens Agents never merge into the default branch or a protected branch themselves (`agents may not merge into this branch; a human must merge`). Auto-merge is how they land work there anyway, with CI and people still in control: the agent turns on auto-merge, and Diffinery merges once the branch's rules are met. A repository admin decides per branch rule whether this is allowed; see [Agents landing changes](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes) for the full rules. In short: - The rule needs **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`) and at least one required status check. The same applies to agents adding a pull request to a merge queue on such a branch. - Approvals are required as for any merge (the rule's **Required approvals**, and at least one from a person for a pull request an agent opened), but an approval counts only for the commit it approved: if the agent pushes again, a person approves again. The blocker then reads `needs 1 more approving review(s) of its latest commit`. An admin can waive the approval on green required checks with **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`); see [Waiving approvals for agents](https://diffinery.com/docs/repositories/branch-protection#waiving-approvals-for-agents). Requested changes always block. - A pull request that adds, changes or deletes a workflow file can't land this way; a person must merge it. - A workflow's `GITHUB_TOKEN` is held to the same rule, even when the run's code didn't come from an agent. - On branches that are neither the default branch nor protected, agents and workflow tokens can use auto-merge without any rule. - An admin can also name agents in **Auto-merge pull requests from these agents** (`autoMergeAgents`) on the rule. Diffinery then turns auto-merge on by itself, as the agent, for each pull request those agents open, mark ready or push to, so neither the agent nor a person has to. Every point above still applies. See [Auto-merging agents' pull requests](https://diffinery.com/docs/repositories/branch-protection#auto-merging-agents-pull-requests). A typical agent flow over MCP: 1. `commit_files` with `create_branch_from` to put the change on a new branch. 2. `create_pull_request` with an `intent`. 3. `enable_auto_merge` with `merge_method: "squash"` (not needed where the rule names the agent in `autoMergeAgents`: `get_pull_request` then already shows `issue.pull.autoMerge` with `byRule`). If the answer is `isError: true` with `agents may not have pull requests merged into main automatically ...`, the branch doesn't allow it: ask a person to merge, or an admin to set `allowAgentAutoMerge`. 4. Follow CI with `list_workflow_runs`, and fix failures with more commits. The agent's own pushes keep auto-merge on. 5. `get_pull_request` shows `issue.pull.autoMerge` while it waits, and `issue.pull.merged` once Diffinery merged it. Over the REST API the same flow uses `PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge` with the agent's token. ### Auto-merge events and audit log - **Timeline:** turning auto-merge on or off adds an `auto_merge_enabled` or `auto_merge_disabled` event to the pull request (GitHub's names), returned by `GET /repos/{owner}/{repo}/issues/{number}/events` and the timeline. When a branch rule's `autoMergeAgents` turned it on, the event's actor is `diffinery-auto-merge[bot]`. - **Webhooks:** a `pull_request` webhook with the action `auto_merge_enabled` or `auto_merge_disabled` is sent. The sender is who turned it on or off; when auto-merge turned itself off, it is the pusher, the person who converted the pull request to a draft, or `diffinery-auto-merge[bot]`. See [Webhook events](https://diffinery.com/docs/reference/webhook-events#pull_request). - **Workflows and email:** these actions don't start `pull_request` workflows and send no notification emails. The merge itself does everything a merge by hand does (see [After the merge](#after-the-merge)). - **Audit log:** each step is recorded with the pull request (`acme/website#12`) as the target: | Action | When | Detail | | --- | --- | --- | | `pull_request.auto_merge_enabled` | Auto-merge is turned on (or changed) | `base`, `method`, `viaAgentRule` (`true` when it relies on `allowAgentAutoMerge`); with `byRule` (the branch rule's pattern) and `agent` when a rule's `autoMergeAgents` turned it on, by `diffinery-auto-merge[bot]` | | `pull_request.auto_merge_disabled` | Auto-merge is turned off, by someone or by itself | `enabledBy`, and `reason` when it turned itself off | | `pull_request.auto_merged` | Auto-merge merged the pull request; a `pull.merge` entry is written too | `base`, `method`, `sha` | | `pull_request.auto_merge_enqueued` | Auto-merge added the pull request to the merge queue | `base`, `state` | See [Audit log](https://diffinery.com/docs/security/audit-log#git-and-pull-requests). ### Auto-merge errors | Message | Cause and fix | | --- | --- | | `pull request is a draft; mark it ready for review first` (422) | Mark it ready, then turn on auto-merge. | | `pull request is closed` (422) or `pull request is already merged` (422) | Only open pull requests can auto-merge. | | `not a pull request` (404) | The number belongs to an issue. | | `pull request is already in the merge queue` (422) | It is queued already; the queue merges it. | | `merge_method must be merge, squash or rebase` (422) | Fix `merge_method`. | | `main requires linear history; use squash or rebase` (422) | Pick `squash` or `rebase`. | | `write access required` (403) | You need write access to the repository. | | `this token needs the write scope` (403) | Use a `write` or `admin` token. | | `agents may not have pull requests merged into main automatically unless its branch protection allows it (allowAgentAutoMerge, set by a repository admin); a person must merge` (403) | The base branch's rule doesn't allow agents (or workflow tokens) to land changes. A person merges, or an admin turns on `allowAgentAutoMerge`. For a workflow token the message says `workflow tokens` instead of `agents`. | | `main has no required status checks: allowAgentAutoMerge needs at least one, so that CI gates what agents land` (422) | Add a required status check to the rule. | | ``the pull request changes workflow file `.github/workflows/ci.yml`; CI can't gate a change to itself, so a person must merge it`` (422) | An agent or workflow token relies on `allowAgentAutoMerge` for a pull request that changes a workflow. A person must merge it. | | `Resource not accessible by integration (...)` (403) | A workflow's `GITHUB_TOKEN` lacks `contents: write`. | | Auto-merge is on but nothing happens | A blocker remains. Check the merge box, `diffinery pr view 12` or `merge_blockers` in the API: usually a required check that hasn't run (check the check name), a missing approval, or a change request. | | Auto-merge merged before CI finished | Only required checks are waited for. Add the checks to **Required status checks** in the branch's rule. | ## Pull requests opened by agents When the author is an [agent](https://diffinery.com/docs/agents/overview): - the pull request needs at least one approval from a person, even on a branch without protection rules, and approvals from agents never count; - the agent's owner can approve it, because the owner is a different account from the agent; - agents can't merge into the default branch or any protected branch unless its rule has **Allow agents to merge pull requests**, and even then the human approval is still required; - where the rule has **Allow agents to land pull requests through auto-merge** and a required check, the agent can turn on [auto-merge](#auto-merge-for-agents-and-workflow-tokens) and Diffinery merges once the rules are met; an admin can also waive the human approval for those landings with **Let agents' auto-merges land without an approving review**; - the author is shown with an `agent` badge and `on behalf of` its owner, and the API returns `"type": "Bot"` for it. ## Pull requests and workflow tokens A workflow job's `GITHUB_TOKEN` can work with pull requests, within its `permissions:` and these limits, as on GitHub: - **Permissions.** Opening, reviewing and editing pull requests (the `/pulls` endpoints) need `pull-requests: write`; conversation comments, labels and assignees (the `/issues/{number}/...` endpoints) accept `issues: write` or `pull-requests: write`. Merging needs `contents: write`. A request outside the token's permissions gets `403` with `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: pull-requests: write)`. - **Opening and approving is off by default.** A job token can't open a pull request, approve one or request changes unless a repository admin allows it. Otherwise the request fails with `403` and `GitHub Actions is not permitted to create pull requests.` (or `... to approve pull requests.`), followed by `An admin can allow it in the repository's Actions settings ("Allow GitHub Actions to create and approve pull requests").` Comment reviews are always allowed. - **Its approvals never count.** Even where approving is allowed, an approval from a job token doesn't count toward required approvals: only people's approvals do. Its Request changes review still blocks merging. - **No admin bypass.** A job token acts with at most the write role, so branch protection always applies to its merges. - **No workflow file changes.** A job token can't merge a pull request that adds, changes or deletes files under `.github/workflows/` or `.diffinery/workflows/` (see [Merge blockers](#merge-blockers)). - **Auto-merge.** A job token can turn on [auto-merge](#auto-merge) with `contents: write`. On the default branch or a protected branch it is held to the agent rule (**Allow agents to land pull requests through auto-merge** and a required check), even when the run's code didn't come from an agent. The later merge keeps the token's permissions from when it was turned on. - **Code from agents.** In a run whose code came from an agent (an agent opened the pull request or pushed its branch last, or started the run, and re-runs of such a run and `workflow_run` runs that follow it), the job token acts as an agent: it can't merge into the default branch or a protected branch unless the rule has **Allow agents to merge pull requests**. - **No new runs.** Pull requests a job token opens, pushes or merges don't start `pull_request` workflows, so workflows can't trigger each other in a loop. An admin allows workflows to open and approve pull requests on the repository's **Settings → Actions** tab (check **Allow GitHub Actions to create and approve pull requests** and click **Save**), or with the REST API: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/permissions/workflow \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"can_approve_pull_request_reviews": true}' ``` `GET` on the same path returns `default_workflow_permissions` (`read` or `write`) and `can_approve_pull_request_reviews` (`false` by default). Changing it needs a person with the admin role (a token with the `admin` scope), and each change is recorded in the audit log as `repo.actions_workflow_settings`. See [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax) for `permissions:`. ## Merge when ready and the merge queue If the base branch's protection rule has **Require a merge queue**, the merge box shows **Merge when ready** instead of **Merge pull request**. Clicking it (or `diffinery pr merge 12`, `PUT /repos/{owner}/{repo}/pulls/{number}/merge-queue`, or the `merge_pull_request` MCP tool) adds the pull request to the queue. Diffinery tests it together with the pull requests ahead of it on a merge group branch and merges it once the required checks pass there. The merge box then shows `Queued to merge`, its position, and a **Remove from queue** button. The pull request must already have no blockers to join the queue. Pushing to its branch removes it from the queue. To have it join the queue by itself once it has none, turn on [auto-merge](#auto-merge). See [Merge queue](https://diffinery.com/docs/collaboration/merge-queue) for the full behaviour. ## Lock the conversation Locking a pull request limits comments, reviews, line comments and reactions to people with triage access or higher. Lock and unlock with the issue endpoints (a pull request is an issue): ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/issues/12/lock \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"lock_reason": "resolved"}' curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/12/lock -H "Authorization: Bearer dfy_..." ``` Both need triage access and return `204`. `lock_reason` is optional and one of `off-topic`, `too heated`, `resolved` or `spam`. The pull request then reports `"locked": true` and `active_lock_reason`. Others get `403` with `conversation is locked`. Merging is not affected. The web UI doesn't show a lock control or badge yet. See [Issues](https://diffinery.com/docs/collaboration/issues#lock-a-conversation). ## Reactions Add emoji reactions to a pull request, a conversation comment or a line comment through the REST API, the same way as on issues: `POST /repos/{owner}/{repo}/issues/{number}/reactions`, `POST /repos/{owner}/{repo}/issues/comments/{id}/reactions` or `POST /repos/{owner}/{repo}/pulls/comments/{id}/reactions` with `{"content": "+1"}`. See [Issues](https://diffinery.com/docs/collaboration/issues#reactions) for the allowed values and rules. ## Events and timeline Changes to a pull request are recorded as events: `labeled`, `unlabeled`, `assigned`, `unassigned`, `renamed`, `closed`, `reopened`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `review_dismissed`, `ready_for_review`, `convert_to_draft`, `auto_merge_enabled`, `auto_merge_disabled` and `merged`, with GitHub's names. (The `pull_request` webhook action for converting to a draft is `converted_to_draft`, as on GitHub.) Read them with `GET /repos/{owner}/{repo}/issues/{number}/events`, or read events, comments and reviews in time order with `GET /repos/{owner}/{repo}/issues/{number}/timeline`. See [Issues](https://diffinery.com/docs/collaboration/issues#events-and-timeline). ## Close and reopen - **Web UI:** **Close pull request** at the bottom of the conversation (posting any comment you typed), and **Reopen** on a closed one. - **REST API:** `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{"state": "closed"}` or `{"state": "open"}`. The same endpoint changes `title`, `body` and [`draft`](#drafts). - **MCP:** `update_issue` with `state`. Changing the title or description (in the web UI, with `PATCH`, or with `update_issue`) sends a `pull_request` webhook event with the action `edited` and runs `pull_request` workflows whose `types` include `edited`. Saving the same text again sends nothing. Closing runs `pull_request` workflows whose `types` include `closed`, and removes the pull request from a merge queue. Reopening runs `pull_request` workflows with the `reopened` type. A merged pull request can't be reopened: `a merged pull request cannot be reopened`. Only the author or someone with triage or higher can close or reopen. ## Pull request refs Like GitHub, every pull request has two read-only refs: | Ref | Points at | | --- | --- | | `refs/pull/<n>/head` | The pull request's head commit | | `refs/pull/<n>/merge` | The test merge commit: parents `[base, head]`, authored by `Diffinery <noreply@diffinery.com>`. Removed while the pull request conflicts. | They are refreshed when the pull request is opened, pushed to, reopened or closed. Fetch them with git: ```sh git fetch origin refs/pull/12/head:pr-12 git fetch origin +refs/pull/12/merge:refs/remotes/origin/pr/12/merge ``` They can't be pushed: `refs/pull/ is managed by Diffinery for pull requests`. They are also hidden from pushes, so `git push --mirror` leaves them alone. ## Find pull requests The **Pull requests** tab lists pull requests with the same filters as the issue list: open or closed, search, label and author. See [Issues](https://diffinery.com/docs/collaboration/issues#find-issues). ```sh diffinery pr list --state merged --author diffy --limit 20 diffinery pr list --head fix-pricing-link diffinery pr view 12 --comments diffinery pr diff 12 --name-only ``` `pr list --state` accepts `open` (default), `closed`, `merged` or `all`, and `pr list --head BRANCH` lists only pull requests from that branch. As with `gh`, the pull request number is optional for `pr view`, `pr checks`, `pr diff`, `pr comment`, `pr review`, `pr ready` and `pr merge`: without it, the CLI uses the open pull request whose head is your current git branch, in the repository of your `origin` remote (or `-R OWNER/NAME`). With no current branch (a detached HEAD) or no open pull request for it, the command fails and asks for a number. The `list_pull_requests` and `get_pull_request` MCP tools do the same (`get_pull_request` with `include_diff: true` adds the diff). The REST API is `GET /repos/{owner}/{repo}/pulls`. Closed results include merged ones. Results come back as a page, with a `Link` header for the next pages and `x-total-count` with the total. | Parameter | Meaning | | --- | --- | | `state` | `open` (default), `closed` or `all` | | `head` | Head branch, as `branch` or `owner:branch` (the owner part is ignored) | | `base` | Base branch | | `sort` | `created` (default), `updated`, or `popularity` (by number of comments) | | `direction` | `desc` (default) or `asc` | | `labels` | Comma-separated label names; a pull request must have all of them | | `assignee` | A login, `none` (no assignees) or `*` (any assignee) | | `creator` | Author login | | `since` | Only pull requests updated at or after this ISO 8601 time | | `q` | Text to find in titles and descriptions | | `per_page`, `page` | Paging: up to 100 per page, default 30 | To search across repositories with qualifiers, use `GET /search/issues` with `is:pr`, for example `q=repo:acme/website is:pr is:open base:main`. Supported qualifiers are listed in [Issues](https://diffinery.com/docs/collaboration/issues#search-issues-and-pull-requests). To find the pull requests a commit belongs to, use `GET /repos/{owner}/{repo}/commits/{sha}/pulls`. It returns pull requests whose head commit or merge commit is that commit, and open pull requests (among the 50 most recently opened) whose head branch contains it. An unknown commit returns `422` with `No commit found for SHA: <sha>`. `diffinery pr comment 12 --body "..."` adds a conversation comment. ## Differences from GitHub - Pull requests are between branches of the same repository; there are no forks. - The intent can't be edited after opening. - Draft state changes through `PATCH /pulls/{number}` with `draft` (GitHub offers this only in GraphQL). - An admin's merge past unmet branch rules must be asked for with `bypass_rules`, and skipping a merge queue with `bypass_merge_queue`; without them an admin is refused like anyone else. A merge commit is refused on a branch that requires linear history unless `bypass_rules` is sent. - Reviews are submitted immediately; there are no pending reviews, suggested changes, or thread resolution yet. - `rebase` outside the merge queue is a fast-forward only. - `update-branch` exists in the REST API only (merge method only, no rebase); there is no **Update branch** button and no web conflict editor. - Dismissing a review needs maintain access, and the pull request's author and agents can't do it. - Pull requests opened by agents always need a human approval, and agent approvals never count. - `GET /pulls/{number}/merge` answers GitHub clients with `204` or `404`, and other clients with a mergeability report. - `mergeable_state` never reports `behind`, `unstable` or `has_hooks`. - Requested reviewers are accounts only (no teams). - Locking, reactions, review dismissal and repository-wide comment lists have no web UI yet. - Auto-merge is available in every repository, with no repository setting to allow it first, and has REST endpoints (`PUT` and `DELETE /pulls/{number}/auto-merge`) where GitHub uses GraphQL. It waits only for required checks, never deletes the head branch, and its `auto_merge_enabled` and `auto_merge_disabled` actions don't start `pull_request` workflows. The CLI has no `--auto` flag. - Agents land changes on protected branches through auto-merge only where a branch rule allows it (`allowAgentAutoMerge`), which has no GitHub equivalent. - See also [Merge queue](https://diffinery.com/docs/collaboration/merge-queue#differences-from-github). ## Troubleshooting | Message | Cause and fix | | --- | --- | | `pull request is not mergeable: ...` | The merge has blockers, listed after the colon. See [Merge blockers](#merge-blockers). If it ends with `(as an admin you can bypass with bypass_rules)`, you can merge anyway with an [admin bypass](#admin-bypass). | | `main requires linear history; use squash or rebase` (405) | You asked for a merge commit on a branch that requires linear history. Use `squash` or `rebase` (`diffinery pr merge 12 --squash`). | | `only the author or someone with write access can change draft status` (403) | Only the author or someone with write access can mark a draft ready or convert it back. | | `GitHub Actions is not permitted to approve pull requests. ...` (403) | A workflow's `GITHUB_TOKEN` tried to approve or request changes. An admin can allow it; see [Pull requests and workflow tokens](#pull-requests-and-workflow-tokens). | | `Resource not accessible by integration (...)` (403) | A workflow's `GITHUB_TOKEN` lacks the permission named in the message. Add it to the job's `permissions:`. | | `repository is archived (read-only)` (403) | The repository is archived. An admin must unarchive it first. | | `authentication required` (401) | You sent no credentials. Every change needs a signed-in caller, even on a public repository. | | `this token needs the write scope` (403) | Your token has the `read` scope. Create one with `write` (or `admin`). | | `head branch was modified; review and try again` (409) | Someone pushed after you loaded the page or read the head SHA. Review the new commits and merge again. | | `base branch moved during merge; try again` (409) | The base changed while merging. Retry. | | `merge conflicts` (405) | The head conflicts with the base. Resolve and push. | | `rebase merge requires the branch to be up to date with its base (fast-forward only for now)` | Rebase the head onto the base and push, or use `merge` or `squash`. | | `you cannot approve or request changes on your own pull request` | Ask someone else to review; you can leave a Comment review. | | `write access is required to approve or request changes` | Approving needs write access. | | `a comment review needs a body or line comments` | Add a review body or at least one line comment. | | `pull request is closed` (when reviewing) | Only open pull requests can be reviewed. | | `a merged pull request cannot be reopened` | Open a new pull request instead. | | `conversation is locked` (403) | The pull request is locked. Only triage or higher can comment, review or react. | | `path and line are required` | A new line comment needs `path` and `line` (or `in_reply_to` for a reply). | | `team reviewers are not supported; request people by login` | Request reviewers by login in `reviewers`. | | `main requires a merge queue: add the pull request to the queue` | Use **Merge when ready**, `diffinery pr merge`, or the merge queue API. | | `Auto-merge was turned off: ...` (a comment) | Auto-merge can't go through as set up. See [When auto-merge turns itself off](#when-auto-merge-turns-itself-off). | | `agents may not have pull requests merged into main automatically ...` | An agent or workflow token turned on auto-merge for a branch that doesn't allow it. See [Auto-merge errors](#auto-merge-errors). | | No `pull_request` runs appear | The pull request conflicts with its base (no test merge commit), or the workflow's `on.pull_request` filters exclude it. | --- # Merge on green automatically > Have pull requests merge by themselves once their checks pass, for agents and for people, in a few clicks; with Settings → Merge on green, your own auto-merge setting, Agent setup and the CLI. Source: https://diffinery.com/docs/collaboration/merge-on-green "Merge on green" means a pull request merges by itself as soon as its checks pass, with nobody clicking Merge. In Diffinery it takes two switches, each set once: 1. **The repository says which checks must pass.** An admin opens the repository's **Settings → Merge on green**, ticks the checks, and saves. This is what makes auto-merge wait for CI. 2. **Whoever opens pull requests turns on auto-merge for them.** For people, **Settings → Pull requests → Turn on auto-merge for pull requests I open** (or answer yes in `diffinery setup`). For agents, tick **Merge its pull requests on green** when you create the agent or its token, or name the agents on the Merge on green page. After that, every new pull request merges on green. Nothing is skipped: auto-merge waits for every rule of the branch, a person's pull request still needs the approvals the branch asks for, and a change an agent makes to a workflow file still needs a person to merge it. ![The merge box of an agent's pull request: the required check passed, and auto-merge is on, waiting for a person's approval](https://diffinery.com/docs/images/merge-on-green.webp) ## The quickest path (a repository you work on alone) 1. Push a workflow that runs on pull requests (for example `on: pull_request`; see [Actions](https://diffinery.com/docs/actions/overview)), open one pull request, and let its checks run once. 2. In the repository, open **Settings → Merge on green**. The checks that ran on recent pull requests are listed and ticked. Tick **Turn on auto-merge for pull requests I open** under **Your own pull requests**, and click **Turn on merge on green**. 3. Open your next pull request. Its merge box shows `Auto-merge is on · turned on by <you>'s own setting`, and it merges the moment its checks pass. Your agents' pull requests merge on green too: the agents that opened pull requests in the repository, and the ones from its [Agent setup](https://diffinery.com/docs/connect/cloud-agents), are listed and ticked on the same page. ## Set up a repository **Settings → Merge on green** is for the repository's admins (people, not agents). It writes the default branch's [protection rule](https://diffinery.com/docs/repositories/branch-protection), the same fields as Branch protection, and leaves the rule's other settings as they were. | Part of the page | What it sets | | --- | --- | | **Checks that must pass** | The default branch's **Required status checks**. The page lists the checks reported on the heads of the latest 20 pull requests and ticks the ones that ran on each of the last three, so a check that some pull requests skip doesn't hold them up. Add others under **Other checks**, one per line, exactly as they are named on pull requests. At least one is needed. | | **Agents** | **Auto-merge pull requests from these agents**: Diffinery turns on auto-merge by itself, as the agent, for each pull request these agents open, mark ready or push to. **Their merge method** is how those merge (squash by default). | | **Agents' pull requests merge** | **As soon as the checks pass** waives the approving review for what Diffinery lands for agents here (**Let agents' auto-merges land without an approving review**). **Once the checks pass and a person approved** keeps it: a person approves the latest commit and it merges by itself. Either way, a person's requested changes stop it. | | **Your own pull requests** | Turns on your own [auto-merge setting](#your-own-pull-requests), for every repository. | Saving also turns on **Allow agents to land pull requests through auto-merge**, so any agent with write access may turn on auto-merge for its own pull requests here, under the same rules. Good to know: - Required checks also gate direct pushes to the default branch by people who aren't admins: the pushed commit must already have passed them, as on GitHub. Admins can still push unless the rule includes them. - When the default branch follows a pattern rule (for example `**`), saving makes a rule for the default branch alone, starting from a copy of that one, so other branches stay as they are. - **Turn off** removes what lets agents land through auto-merge (the setting, the waiver and the agents). The required checks stay; change them in **Branch protection**. - Pull requests whose base branch has no required checks show admins a line in the merge box: `main has no required checks, so auto-merge wouldn't wait for CI. To have pull requests merge by themselves once checks pass, set up merge on green.` The same setup through the API, with an admin's token: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"requiredChecks": ["CI / test"], "allowAgentAutoMerge": true, "agentAutoMergeWithoutApproval": true, "autoMergeAgents": ["website-agent"], "autoMergeMethod": "squash"}' ``` This replaces the whole rule, so include the rule's other fields too (read them first with `GET` on the same path). See [Branch protection](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes). ## Your own pull requests Your auto-merge setting turns on auto-merge for you as you open a pull request, or as you mark your own draft ready for review. It is your own click on **Enable auto-merge**, done for you, so it never lets anything through that the click wouldn't: - It turns on only where the pull request's base branch has at least one required check, so it always waits for CI. Elsewhere it stays off. - The pull request merges once every rule of its base branch is met: required checks, the approvals it needs, no requested changes, an up-to-date branch where linear history is required. On a branch that needs no approvals, that is as soon as the checks pass. - It merges as you, with your access at the time. - It turns on only for pull requests you open, and never again on a pull request where someone turned auto-merge off. Turn it on in any of these: - **Web:** **Settings → Pull requests**, tick **Turn on auto-merge for pull requests I open**, pick a **Merge method** (**As the branch merges** by default), and click **Save**. The repository's Merge on green page has the same switch. - **CLI:** `diffinery setup` asks `Turn on auto-merge for pull requests you open? [y/N]` on a terminal. `diffinery setup --auto-merge` says yes without asking; `--no-auto-merge` skips the question. - **API:** `PUT /api/v3/diffinery/auto-merge` with `{"enabled": true}`; see [API](#api). For one pull request: - **Web:** the **Open a pull request** form has **Merge by itself once checks pass** where the base branch has required checks, ticked by your setting. Untick it to keep this one waiting for you. - **CLI:** `diffinery pr create --auto` turns it on for this pull request whatever your setting; `--no-auto` keeps it off. The CLI prints `auto-merge: on (squash): it merges once every requirement is met`, or a warning saying why it stayed off. - **Later:** **Disable auto-merge** in the merge box, or `diffinery pr merge 12 --disable-auto`. ## Agents An agent's pull requests merge on green when two things are true: the agent turns on auto-merge for them, and the repository lets agents land through auto-merge. Each part is a person's choice. **The agent turns on auto-merge**, in any of these ways: - **Agent setup** (the repository's **Settings → Agent setup**): tick **Merge its pull requests on green** under the token's **Label** when you generate the first token, or later next to **Can merge pull requests**, then **Save**. The text under the box says what it does in this repository right now. - **Settings → Agents:** tick **Merge its pull requests on green** when you create an agent, or on the agent's row, then **Save**. Only the agent's owner (or an owner or admin of the organization that owns it) changes it. - **The repository names it:** an admin ticks it under **Agents** on the Merge on green page. - **The agent itself,** per pull request: `create_pull_request` with `auto_merge: true` over MCP, `"auto_merge": true` in `POST /repos/{owner}/{repo}/pulls`, `diffinery pr create --auto`, or `enable_auto_merge` afterwards. **The repository lets it land**, through Merge on green (or **Allow agents to land pull requests through auto-merge** with at least one required check on the branch's rule). An agent from Agent setup that may merge (**Can merge pull requests**, on by default) also lands on a branch with required checks and without that setting, once a person approved its latest commit. Then, as for any agent's auto-merge: - Unless the repository waived it, a person approves the pull request's latest commit; if the agent pushes again, a person approves again. - A pull request that adds, changes or deletes a workflow file never lands this way; a person merges it. - When an agent's setting is on but its pull request can't turn on auto-merge, Diffinery says why once, in a comment from `diffinery-auto-merge[bot]`: `Auto-merge didn't turn on by itself (website-agent turns on auto-merge for its pull requests): <why>. A person can merge it.` ## When auto-merge doesn't turn on | What you see | Why, and what to do | | --- | --- | | No auto-merge, and the merge box says `main has no required checks` | The base branch has no required check, so auto-merge wouldn't wait for CI. An admin sets up [merge on green](#set-up-a-repository). | | `auto-merge is off: main has no required status checks, so auto-merge would not wait for CI` (CLI) or `auto_merge_skipped` (API) | The same, for a pull request opened with `--auto` or `auto_merge: true`. | | A comment `Auto-merge didn't turn on by itself (... turns on auto-merge for its pull requests): agents may not have pull requests merged into main automatically ...` | The repository doesn't let agents land through auto-merge. An admin turns on merge on green, or a person merges. | | A comment saying the pull request `changes workflow file ...` | An agent's change to a workflow file needs a person to merge it. | | Nothing on a draft | Auto-merge turns on when the draft is marked ready for review, by its author. | | Nothing after someone turned auto-merge off | It never turns itself back on there. Click **Enable auto-merge** to turn it on again. | | Auto-merge is on but nothing merges | The merge box lists what it waits for: a check still running or failed, a missing approval, requested changes, conflicts. | ## API | Endpoint | What it does | | --- | --- | | `GET /api/v3/diffinery/auto-merge` | Your setting: `{"enabled": true, "merge_method": "default"}` (`merge_method` is `default`, `merge`, `squash`, `rebase`, or `null` when off). Needs a token that isn't limited to repositories; an agent's reads the agent's own. | | `PUT /api/v3/diffinery/auto-merge` | Change your setting: `{"enabled": true, "merge_method": "squash"}` (`merge_method` optional), or `true`/`false`. People only, with a `write` token that isn't limited to repositories. | | `PUT /api/v3/diffinery/agents/{login}/auto-merge` | The same for one of your agents. | | `POST /api/v3/diffinery/agents` | Also takes `"auto_merge": true` when creating an agent. `GET /api/v3/diffinery/agents` shows each agent's `auto_merge`. | | `POST /api/v3/repos/{owner}/{repo}/pulls` | Also takes `"auto_merge": true` (turn it on as the pull request opens) or `false` (keep it off whatever the setting). When it was asked for and stayed off, the answer has `auto_merge_skipped` with the reason. | While auto-merge is on, a pull request's `auto_merge` object has `"by_default": true` when the author's setting turned it on, and `"by_rule": "main"` when a branch rule's agents did. Changes are in the [audit log](https://diffinery.com/docs/security/audit-log): `account.auto_merge` and `agent.auto_merge` (with `merge_method`), `repo.merge_on_green` (with the checks and agents) and the branch protection entries, and `pull_request.auto_merge_enabled` with `byDefault: true` for each pull request a setting turned it on for. ## Related - [Auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge): how auto-merge works on one pull request. - [Agents landing changes](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes): the full rules for agents. - [Merge queue](https://diffinery.com/docs/collaboration/merge-queue): on a busy branch, auto-merge joins the queue instead of merging. - [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents): Agent setup, step by step. --- # Merge queue > Merge pull requests in order, each tested on top of the base branch and the pull requests ahead of it, with merge_group workflows and required checks. Source: https://diffinery.com/docs/collaboration/merge-queue A merge queue merges pull requests into a busy branch one after another, testing each one together with the base branch and every pull request queued ahead of it before it lands. Use it when many pull requests target the same branch and you want the branch to stay green without asking authors to rebase and re-run CI over and over. Diffinery's merge queue works like GitHub's: it uses the same `gh-readonly-queue/` branches and the same `merge_group` workflow event. ## How it works 1. You turn on **Require a merge queue** in the base branch's protection rule and list the **Required status checks**. 2. A pull request with no merge blockers is added to the queue with **Merge when ready** (or the CLI, API or MCP). 3. Diffinery builds a **merge group** for it: a commit that contains the base branch, every pull request ahead of it in the queue, and this pull request's changes. The commit goes on a branch named `gh-readonly-queue/<base>/pr-<number>-<head sha>`. 4. Creating that branch triggers `merge_group` workflows (and `push` workflows that match the branch) on the merge group commit. Their checks are reported on that commit. 5. When the required checks pass on the merge group and every pull request ahead has merged, the base branch fast-forwards to exactly that tested commit. The pull request is marked merged, and its merge group branch is deleted. 6. If a merge group fails a required check, conflicts, or takes longer than the check timeout, its pull request leaves the queue with a comment saying why, and the groups behind it are rebuilt without it. Because each group already contains everything ahead of it, the base branch only ever moves to commits that were tested exactly as they land. ## Turn on the merge queue You need admin access to the repository, and you must be a person: agents can't change branch protection. ### Turn it on in the web UI 1. Open the repository's **Settings** and choose **Branch protection** (`https://diffinery.com/acme/website/settings/branches`). 2. Open the rule for the branch (for example `main`), or fill in **Add a rule** with `main` as the **Branch name pattern**. 3. In **Required status checks**, list the checks that must pass, one per line and exactly as they appear on pull requests, for example `CI / test` and `CI / lint`. 4. Tick **Require a merge queue**, then set: - **Merge method**: **Create a merge commit**, **Squash and merge** or **Rebase and merge**. - **Build concurrency**: how many merge groups are built and checked at once. - **Check timeout (minutes)**: how long a merge group may wait for its required checks. 5. Click **Create rule** or **Save rule**. You see `Branch protection saved.` ### Turn it on with the REST API `PUT /repos/{owner}/{repo}/branches/{branch}/protection` replaces the whole rule, so send every setting you want to keep: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{ "requirePullRequest": true, "requiredApprovals": 1, "requiredChecks": ["CI / test"], "mergeQueue": { "method": "squash", "buildConcurrency": 5, "checkTimeoutMinutes": 30 } }' ``` `"mergeQueue": true` turns it on with the defaults. Read the rule back with `GET` on the same path. The token needs the `admin` scope. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection) for the other rule settings. ### Settings | Setting | API field | Default | Allowed values | | --- | --- | --- | --- | | Merge method | `mergeQueue.method` | `merge` | `merge`, `squash`, `rebase` (anything else becomes `merge`) | | Build concurrency | `mergeQueue.buildConcurrency` | 5 | 1 to 20 (values outside are clamped) | | Check timeout (minutes) | `mergeQueue.checkTimeoutMinutes` | 60 | 1 to 360 (values outside are clamped) | Turning the queue off removes every queued pull request with the comment `This pull request was removed from the merge queue: main no longer uses a merge queue.` ## Required checks The queue decides on **Required status checks** from the same branch protection rule: - A pull request needs its required checks to pass on its own head commit before it can join the queue, like any merge (see [merge blockers](https://diffinery.com/docs/collaboration/pull-requests#merge-blockers)). - Its merge group needs the same required checks to pass on the merge group commit before it merges. - A check passes when its conclusion is `success`, `skipped` or `neutral`. Checks that aren't required are ignored by the queue, even when they fail. - A required check can be a check run or a commit status context, and the queue counts reports the same way as branch protection: when Diffinery Actions reports a check, only its result counts; a check bound in the rule's `requiredCheckSources` counts only that reporter; reports from agents never count; and any counted failure fails the group. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection#required-checks). So the workflows that produce your required checks must run on both `pull_request` and `merge_group`. A job's check name is `<workflow name> / <job name>`, and it is the same for both events. An external CI system that reports a required check must also report it on the merge group commit (`github.event.merge_group.head_sha`, the head of the `gh-readonly-queue/...` branch), with `POST /repos/{owner}/{repo}/statuses/{sha}` or `POST /repos/{owner}/{repo}/check-runs`. A finished commit status, or a check run sent with `POST` as completed, makes the queue look at the group again at once. A check run completed later with `PATCH` is only noticed at the queue's next event, at the latest when the check timeout ends, so send the final result with `POST` (see [Required checks and the merge queue](https://diffinery.com/docs/collaboration/checks-and-statuses#required-checks-and-the-merge-queue)). See [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). > **Important:** With no required checks, a merge group passes as soon as it is built, so pull requests merge immediately in queue order without any CI on the combined result. List at least one required check. ## Write workflows for the merge queue Add `merge_group` to the triggers of each workflow that produces a required check: ```yaml name: CI on: pull_request: branches: [main] merge_group: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test ``` With this workflow, add `CI / test` to **Required status checks**. What a `merge_group` run sees: | Value | Content | | --- | --- | | `github.event_name` | `merge_group` | | `github.ref` | `refs/heads/gh-readonly-queue/main/pr-12-<head sha>` | | `github.sha` | The merge group commit | | `github.event.action` | `checks_requested` | | `github.event.merge_group.head_sha` | The merge group commit | | `github.event.merge_group.head_ref` | The merge group branch, as a full ref | | `github.event.merge_group.base_sha` | The commit the group was built on (the base tip, or the group ahead of it) | | `github.event.merge_group.base_ref` | `refs/heads/main` | | `github.event.merge_group.head_commit` | `id`, `tree_id`, `message`, `timestamp`, `author`, `committer` | Trigger filters: - `merge_group` accepts `types` (only `checks_requested` exists) and `branches` / `branches-ignore`, which match the **base** branch (`main`), not the queue branch. - Creating a merge group branch is also a push, so `push` workflows whose branch filters match `gh-readonly-queue/...` run on it, as on GitHub. Restrict `push` to the branches you mean (as above), or exclude the queue with `branches-ignore: ['gh-readonly-queue/**']`. `actions/checkout` checks out the merge group commit, so the job tests exactly what would land. ## Add a pull request to the queue The pull request must have no merge blockers: approvals, required checks on its head, no conflicts, not a draft (mark a draft ready first with **Ready for review** or `diffinery pr ready 12`; see [Drafts](https://diffinery.com/docs/collaboration/pull-requests#drafts)). You need write access, and through the API a token with the `write` scope. Agents (and workflow tokens) can queue a pull request into the default branch or a protected one only when its rule has **Allow agents to land pull requests through auto-merge** and at least one required check, or, for agents, **Allow agents to merge pull requests**. To queue a pull request that isn't ready yet, turn on [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge): it adds the pull request to the queue by itself once it has no blockers, with the auto-merge method (by default the queue's). Agents use it the same way (see [Agents landing changes](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes)). ### Queue in the web UI On the pull request's **Conversation** tab, the merge box says `main merges through a merge queue: the pull request is tested together with the ones ahead of it before it merges.` 1. Pick the **Merge method**. It defaults to the queue's method. 2. Leave **Delete fix-pricing-link after merge** ticked to delete the head branch when it merges. 3. Click **Merge when ready**. You see `Added to the merge queue.`, or `Merged through the merge queue.` if it merged straight away. The merge box then shows `Queued to merge · position 2 in the main merge queue`, followed by `checking merge group abc1234` or `waiting for the pull requests ahead of it`, the required checks of the merge group, and a **Remove from queue** button. ### Queue with the CLI ```sh diffinery pr merge 12 --squash --delete-branch ``` `diffinery pr merge` first tries a direct merge. Like `gh pr merge`, when the base branch uses a merge queue it adds the pull request to the queue instead, unless you pass `--admin` (see [Bypass the queue](#bypass-the-queue)). It prints `✓ Added #12 to the merge queue (position 2)`, or `✓ Merged #12 through the merge queue` and the commit when it merged at once. A method flag (`--merge`, `--squash`, `--rebase`) overrides the queue's method; without one the queue's method is used. ### Queue with the REST API ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/merge-queue \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"merge_method": "squash", "delete_branch": true}' ``` Both fields are optional; `merge_method` defaults to the queue's method. The response says where the pull request stands after the queue has been processed once: ```json { "state": "queued", "entry": { "number": 12, "title": "Fix the Contact sales link", "base_ref": "main", "position": 2, "state": "checking", "head_sha": "3f2a9c1e...", "merge_method": "squash", "delete_branch": true, "enqueued_by": { "login": "diffy" }, "enqueued_at": "2026-10-13T09:12:44.000Z", "merge_group": { "head_sha": "9be41d07...", "head_ref": "refs/heads/gh-readonly-queue/main/pr-12-3f2a9c1e...", "base_sha": "51c0e2aa...", "built_at": "2026-10-13T09:12:45.000Z" }, "checks": [{ "name": "CI / test", "state": "in_progress" }] }, "merge_commit_sha": null, "reason": null } ``` | `state` | HTTP status | Meaning | | --- | --- | --- | | `queued` | 201 | In the queue; `entry` describes it | | `merged` | 200 | Merged during this call; `merge_commit_sha` is the commit | | `removed` | 200 | It joined but left at once; `reason` says why | Adding a pull request that is already queued returns its current entry. ### Queue over MCP `merge_pull_request` queues the pull request when its base branch uses a merge queue, with the optional `method` and `delete_branch`. If you pass `expected_head_sha` and the head has moved, it fails with `head branch was modified; review and try again`. The result has the same `state`, `entry`, `mergeCommit` and `reason` information. ## Order and merge groups - **Order.** Pull requests are queued first come, first served, per base branch. `position` 1 is the front of the queue. - **Group contents.** The first group is built on the current tip of the base branch. Each later group is built on the group ahead of it, so it contains the base and every pull request ahead plus its own changes. - **Branch.** Each group lives on `gh-readonly-queue/<base>/pr-<number>-<head sha>`. Nobody can push to these branches: `merge queue branches are read-only`. - **Concurrency.** At most **Build concurrency** groups exist at once. Pull requests beyond that wait with the state `queued` (shown as `waiting to be built`) and are built as the ones ahead merge or leave. A built group has the state `checking`. - **Merging.** Only the front group merges, and only when it was built on the current base tip and its required checks passed. A group further back whose checks passed waits for those ahead. - **Group commits** depend on the merge method: | Method | Merge group commit | | --- | --- | | `merge` | A merge commit with parents (previous group or base, pull request head). Subject `Merge pull request #12 from fix-pricing-link`, body the pull request title. | | `squash` | One commit on top of the previous group. Subject `<title> (#12)`, body a `* <subject>` line per commit. Author: the pull request's author. | | `rebase` | Each of the pull request's commits replayed on top of the previous group, keeping its author and message. Pull requests that contain merge commits can't be rebased. | The committer is the person who queued the pull request (`<login>@users.noreply.diffinery.com`). If the base branch requires linear history, the `merge` method is refused: `main requires linear history; use squash or rebase`. A rule can't combine the two in the queue's own method either: it is refused with `422`, and a queue without a method squashes there. ## When a pull request merges When its turn comes, the base branch fast-forwards to the merge group commit. Then, as for any merge: - the pull request is closed as merged, with `merge_commit_sha` set to the merge group commit and `merged_by` set to whoever queued it; - `push` workflows and webhooks run for the base branch; - open issues referenced with closing keywords (`Fixes #12`) close; - the head branch is deleted if **Delete ... after merge** was ticked; - the merge group branch is deleted. Its runs are left to finish. ## When a pull request leaves the queue The queue removes a pull request in these cases. Except where noted, it posts a comment from `diffinery-merge-queue[bot]` on the pull request: `This pull request was removed from the merge queue: <reason>.` | Reason in the comment | What happened | | --- | --- | | `the required check 'CI / test' failed on its merge group` | A required check concluded with a failure. Other wordings: `timed out`, `was cancelled`, `needs action`. | | `required checks did not finish within 60 minutes ('CI / test')` | The required checks hadn't all completed within **Check timeout** of the group being built. | | `it conflicts with main and the pull requests ahead of it (src/app.ts)` | The pull request can't be combined with the base and the groups ahead. Up to five files are listed. | | `rebase merges cannot include merge commits; use a merge commit or squash` | The queue uses `rebase` and the pull request contains a merge commit. | | `new commits were pushed to its branch` | The head branch moved after the pull request was queued. Queue it again once its checks pass. | | `main no longer uses a merge queue` | The merge queue was turned off for the branch. | | `the base branch main no longer exists` | The base branch was deleted. | | `the pull request was closed` | No comment is posted. | | `diffy removed it from the queue` | Someone removed it by hand. No comment is posted. | When a pull request leaves, its merge group branch is deleted and its unfinished runs are cancelled with `Cancelled because its merge group was rebuilt or left the queue.` Fix the problem, wait for the pull request's own checks, and add it to the queue again. Webhooks receive `pull_request` events with the actions `enqueued` and `dequeued`. Queueing and removal don't send notifications themselves; the removal comment does, like any comment. ## Rebuilding groups A group is thrown away and rebuilt (and its unfinished runs cancelled) when the commit it was built on is no longer the expected one: - a pull request ahead of it left the queue, so the groups behind it are rebuilt without it; - the base branch moved outside the queue, for example an admin pushed to it or bypassed the queue, so every group is rebuilt on the new tip. A rebuilt group gets a new commit, runs its `merge_group` workflows again, and its check timeout starts over. ## Remove a pull request from the queue - **Web UI:** click **Remove from queue** in the merge box. You see `Removed from the merge queue.` - **CLI:** `diffinery pr merge 12 --disable-auto`. Like `gh`, it turns auto-merge off, and a pull request that is in the queue also leaves it. - **REST API:** `DELETE /repos/{owner}/{repo}/pulls/{number}/merge-queue` (returns `204`, or `404 #12 is not in the merge queue`). - **MCP:** the `dequeue_pull_request` tool. You need write access. Closing the pull request or pushing to its branch also removes it. ## The queue page Each branch with a queue has a page at `/<owner>/<repo>/queue/<branch>`, for example `https://diffinery.com/acme/website/queue/main`. The merge box links to it. It shows the merge method and, for each queued pull request, its position, title and number, who queued it and when, and either `checking merge group abc1234` with the state of each required check, or `waiting to be built`. The page updates live. When the queue is empty it says `The queue is empty.`; for a branch without a queue it says `main does not use a merge queue. Turn it on in the branch protection settings.` The same data is available from the API (read access): ```sh curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/merge-queue/main ``` ```json { "branch": "main", "enabled": true, "settings": { "method": "squash", "buildConcurrency": 5, "checkTimeoutMinutes": 30 }, "entries": [] } ``` `entries` has the same shape as `entry` in the enqueue response. Each entry's `checks` lists the required checks (or, without required checks, the names of the check runs and statuses reported so far) with a state such as `expected` (no counted report yet), `queued`, `in_progress`, `success` or `failure`. A `pending` commit status shows as `in_progress`, and an `error` status as `failure`. `GET /repos/{owner}/{repo}/pulls/{number}/merge` also includes `mergeQueue` (`method` and the pull request's `entry`) when its base uses a queue. ## Bypass the queue A repository admin who is a person can merge a pull request directly, skipping the queue, when it has no conflicts, isn't a draft, and the rule doesn't have **Do not allow administrators to bypass these rules**. Through a token, that needs the `admin` scope. The bypass must be asked for explicitly, and it skips only the queue: | Request | Effect | | --- | --- | | `"bypass_merge_queue": true` | Merges directly instead of queueing. The pull request must still meet its branch rules (approvals, required checks on its head, linear history); otherwise the merge fails with `405` and `pull request is not mergeable: ...`. | | `"bypass_rules": true` | Skips unmet branch rules, but not the queue: alone, it still gets `main requires a merge queue: add the pull request to the queue`. | | Both | Merges directly even with unmet branch rules. | How to ask for it: - **Web UI:** **Merge now (bypass the queue)** under **Merge when ready**, which asks `Merge directly, bypassing the merge queue?` first. It merges with the queue's merge method. When the pull request doesn't meet the rules, the button reads **Merge now (bypass the queue and the unmet requirements)** instead and also sends `bypass_rules`. The button isn't shown when the rule has **Do not allow administrators to bypass these rules**. - **CLI:** `diffinery pr merge 12 --admin`. It sends `bypass_merge_queue` and `bypass_rules`, so it skips both the queue and unmet rules, and the CLI doesn't fall back to queueing the pull request. - **REST API:** `PUT /repos/{owner}/{repo}/pulls/{number}/merge` with `"bypass_merge_queue": true`, plus `"bypass_rules": true` when the pull request has unmet rules. A direct merge with the `merge` method on a branch that requires linear history also needs `bypass_rules`; otherwise it fails with `main requires linear history; use squash or rebase`. Every merge that skipped the queue is recorded in the [audit log](https://diffinery.com/docs/security/audit-log#git-and-pull-requests) as `pull_request.merge_bypassed`, with `merge queue` in the detail's `blockers`, together with any unmet rules skipped with `bypass_rules` (and `linear history required` for a merge commit on a linear-history branch). See [Admin bypass](https://diffinery.com/docs/collaboration/pull-requests#admin-bypass). Everyone else gets `main requires a merge queue: add the pull request to the queue` (`405`) from a direct merge; an admin who could bypass sees `(or, as an admin, bypass the queue with bypass_merge_queue)` added to it. A bypass moves the base branch, so the queued groups are rebuilt. ## Differences from GitHub - Branch names (`gh-readonly-queue/<base>/pr-<number>-<sha>`), the `merge_group` event and its `checks_requested` action match GitHub, so existing workflows and `branches-ignore` filters work unchanged. - GitHub manages the merge queue through GraphQL; Diffinery has REST endpoints: `GET /repos/{owner}/{repo}/merge-queue/{branch}`, and `PUT` and `DELETE /repos/{owner}/{repo}/pulls/{number}/merge-queue`. - Each pull request has its own merge group. The settings are the merge method, build concurrency and check timeout; there are no minimum or maximum group sizes or wait times. - The queue is configured inside a branch protection rule (`mergeQueue`), not a separate ruleset. - `rebase` in the queue replays the commits; a direct `rebase` merge outside the queue is a fast-forward only. - Pull requests opened by agents still need a human approval before they can be queued, and agents can queue only where agents may merge. ## Troubleshooting | Message or symptom | Cause and fix | | --- | --- | | `main does not use a merge queue; merge the pull request directly` | The base branch's rule doesn't have **Require a merge queue**. Merge normally. | | `pull request cannot be added to the merge queue: ...` | The pull request has merge blockers, listed after the colon. Fix them first. | | `main requires linear history; use squash or rebase` | Pick `squash` or `rebase`, or change the queue's method. | | **Merge now (bypass the queue)** fails with `pull request is not mergeable: ... (as an admin you can bypass with bypass_rules)` | Skipping the queue doesn't skip unmet rules. Meet them, or merge with `diffinery pr merge 12 --admin` (or the API with both `bypass_merge_queue` and `bypass_rules`). | | `the pull request left the merge queue: ...` (web UI) | It joined and was removed in the same step, for example because it conflicts with the groups ahead. | | `merge queue branches are read-only` | You pushed to a `gh-readonly-queue/` branch. Push to the pull request's branch instead. | | The group waits until the timeout | The required check names don't match what the workflow reports, or the workflow has no `merge_group` trigger. Compare **Required status checks** with the check names on the merge group commit. | | An external check passed on the merge group but the group still waits | The report doesn't count: it came from an agent, from an account other than the one bound in `requiredCheckSources`, or Diffinery Actions reports a check with the same name. It may also have been posted on the pull request's head commit instead of the merge group commit. | | Pull requests merge without CI | No required checks are configured. Add them to the rule. | | `push` workflows run on `gh-readonly-queue/...` | Restrict `on.push.branches`, or add `branches-ignore: ['gh-readonly-queue/**']`. | --- # Checks and commit statuses > How Diffinery Actions, external CI and bots report check runs and commit statuses on commits, and how they decide required checks in branch protection. Source: https://diffinery.com/docs/collaboration/checks-and-statuses Checks tell you whether a commit passes your tests. Diffinery Actions reports a check for every job it runs, and any other CI system or bot can report its own results through the GitHub-compatible REST API, as check runs or as commit statuses. Pull requests, commits and the merge queue show them, and branch protection can require some of them to pass before a pull request merges. ## Check runs and commit statuses There are two kinds of report. Both show up in the same lists and both can satisfy a required check. | | Check run | Commit status | |---|---|---| | Reported by | Diffinery Actions (one per job), or any account with the write role over `POST /repos/{owner}/{repo}/check-runs` | Any account with the write role over `POST /repos/{owner}/{repo}/statuses/{sha}` | | Identified by | Its `name` | Its `context` | | States | `status`: `queued`, `in_progress`, `completed`; when completed, a `conclusion` such as `success` or `failure` | `state`: `pending`, `success`, `failure`, `error` | | Extra detail | Title, summary, text and line annotations | A one-line description | | Can be changed | Yes, by the account that created it (`PATCH`) | No: post a new status for the same context; the newest one counts | | Webhook | None | `status` | Use check runs when you want annotations or to update one report as the build progresses. Use commit statuses for the simplest integration: one request per state change. ## Checks from Diffinery Actions Every job of a [Diffinery Actions](https://diffinery.com/docs/actions/overview) workflow run reports a check run named `<workflow name> / <job name>`, for example `ci / test` for the job `test` in a workflow with `name: ci`. The check is: - created as `queued` when the job is scheduled, set to `in_progress` when it starts, and `completed` with the job's conclusion (for example `success`, `failure`, `cancelled` or `timed_out`) when it ends; - reported on the commit the run is for. For pull request runs, that is the pull request's head commit, not the test merge commit; - linked to the job's page (`https://diffinery.com/acme/website/actions/runs/{run_id}/job/{job_id}`); - reported by the app `diffinery-actions`. Re-running a job replaces the check of the same name on the same commit. A job that a failed-job re-run keeps from the earlier attempt keeps its conclusion, and its check run points at the new attempt. Check runs from Actions can't be changed over the REST API: a `PATCH` fails with `check runs from Diffinery Actions can't be changed over the API`. A job's `check_run_url` (in `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` and `GET /repos/{owner}/{repo}/actions/jobs/{job_id}`) is `https://diffinery.com/api/v3/repos/acme/website/check-runs/{job_id}`. `GET` on it returns the check run that job reports (its latest one, after a re-run), so tools that follow `check_run_url` from a job to its check run work as on GitHub. > **Note:** GitHub names an Actions check run after the job only. In Diffinery the name includes the workflow name, so required check names copied from GitHub usually need the `<workflow> / ` prefix. A step can also report with the job's own `GITHUB_TOKEN`, within the job's `permissions:`: - **Commit statuses** need `statuses: write`. They are reported by `diffinery-actions[bot]` and count toward required checks like any other non-agent report (in a run whose code came from an agent, the token acts as an agent, and its statuses don't count). - **Check runs** can't be created with the job token: `POST .../check-runs` fails with `this account can't report check runs`. Report a commit status instead, or use a personal token with the `write` scope stored as a secret. ```yaml permissions: contents: read statuses: write steps: - run: | curl -sS -X POST -H "Authorization: Bearer $GITHUB_TOKEN" -H "Content-Type: application/json" \ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ -d '{"state": "success", "context": "lighthouse", "description": "Score 98"}' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` Without `statuses: write`, the request fails with `403` and `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: statuses: write)`. ## Report results from external CI An external CI system (or any script or bot) reports results with a token for an account that has the **write** role on the repository. A personal access token with the `write` scope works. For results that should satisfy required checks, use a regular account, not an agent: reports from agents never satisfy required checks (see [Agents and checks](#agents-and-checks)). A common setup is a dedicated account, for example `ci-bot`, added as a collaborator with the write role, with a `write` token stored in your CI system. Bind your required checks to it with [requiredCheckSources](#bind-a-required-check-to-its-reporter) so nobody else can satisfy them. ### Report a commit status ```sh SHA=9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d # When the build starts curl -sS -X POST \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/statuses/$SHA \ -d '{ "state": "pending", "context": "ci/external", "description": "Build #812 started", "target_url": "https://ci.example.com/builds/812" }' # When it finishes curl -sS -X POST \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/statuses/$SHA \ -d '{ "state": "success", "context": "ci/external", "description": "Build #812 passed in 3m 12s", "target_url": "https://ci.example.com/builds/812" }' ``` | Field | Required | Description | |---|---|---| | `state` | yes | `pending`, `success`, `failure` or `error`. Anything else fails with `state must be one of error, failure, pending, success`. | | `context` | no | The name the status is reported under, and the name a required check matches. Default `default`. At most 255 characters (longer values are cut). | | `description` | no | A short description, shown next to the check. At most 1024 characters (longer values are cut). | | `target_url` | no | A link to the build. Must start with `http://` or `https://`, otherwise the request fails with `target_url must be an http(s) URL`. | The `{sha}` in the path is normally a full commit SHA. Diffinery also accepts anything that resolves to a commit, such as a branch name, and stores the status on the resolved commit. A commit that does not exist fails with `no commit found for SHA: <sha>`. The response is `201`: ```json { "url": "https://diffinery.com/api/v3/repos/acme/website/statuses/9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d", "avatar_url": "https://diffinery.com/api/avatars/ci-bot", "id": 17, "node_id": "U3RhdHVzQ29udGV4dDphY21lL3dlYnNpdGUvMTc", "state": "success", "description": "Build #812 passed in 3m 12s", "target_url": "https://ci.example.com/builds/812", "context": "ci/external", "created_at": "2026-10-13T09:40:00Z", "updated_at": "2026-10-13T09:40:00Z", "creator": { "login": "ci-bot", "id": 1058134509943791, "node_id": "VXNlcjpjMWIyYTNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5YQ", "avatar_url": "https://diffinery.com/api/avatars/ci-bot", "gravatar_id": "", "url": "https://diffinery.com/api/v3/users/ci-bot", "html_url": "https://diffinery.com/ci-bot", "repos_url": "https://diffinery.com/api/v3/users/ci-bot/repos", "organizations_url": "https://diffinery.com/api/v3/users/ci-bot/orgs", "type": "User", "user_view_type": "public", "site_admin": false } } ``` Statuses are append-only: to change a status, post a new one with the same `context`. A commit can have up to 1,000 statuses per context; after that, posting fails with `this SHA and context has reached the maximum number of statuses`. Every new status sends a `status` webhook event. ### Read commit statuses | Endpoint | Returns | |---|---| | `GET /repos/{owner}/{repo}/commits/{ref}/statuses` (also `GET /repos/{owner}/{repo}/statuses/{ref}`) | Every status of the commit, newest first, paginated | | `GET /repos/{owner}/{repo}/commits/{ref}/status` | The combined status: the newest status of each context, and an overall state | `{ref}` can be a SHA, a branch or a tag. An unknown ref returns `404` with `No commit found for the ref`. Reading needs the read role (none for a public repository). ```sh curl -sS -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/repos/acme/website/commits/main/status ``` The combined status has these fields: | Field | Description | |---|---| | `state` | `failure` if any context's newest status is `failure` or `error`; otherwise `pending` if any is `pending` or there are no statuses; otherwise `success` | | `statuses` | The newest status of each context, sorted by context (paginated with `per_page`, default 100) | | `sha` | The commit | | `total_count` | Number of contexts | | `repository` | The repository object | | `commit_url`, `url` | API links | The combined status only covers commit statuses. Check runs are listed separately (see [Read check runs and check suites](#read-check-runs-and-check-suites)). ### Report a check run Create the check run when the build starts, keep its `id`, and update it when the build ends: ```sh SHA=9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d # Start: returns the check run, including its numeric "id" curl -sS -X POST \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/check-runs \ -d '{ "name": "ci/external", "head_sha": "'"$SHA"'", "status": "in_progress", "details_url": "https://ci.example.com/builds/812", "external_id": "812", "output": { "title": "Building", "summary": "Build #812 is running" } }' # Finish, with two annotations curl -sS -X PATCH \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/check-runs/31 \ -d '{ "conclusion": "failure", "output": { "title": "2 lint errors", "summary": "eslint found 2 problems", "annotations": [ { "path": "src/app.ts", "start_line": 12, "end_line": 12, "annotation_level": "failure", "message": "Unexpected any" }, { "path": "src/util.ts", "start_line": 40, "end_line": 41, "annotation_level": "warning", "title": "Unused variable", "message": "'tmp' is never read" } ] } }' ``` Fields of `POST /repos/{owner}/{repo}/check-runs`: | Field | Required | Description | |---|---|---| | `name` | yes | The check's name, at most 255 characters. Required checks match this. Otherwise the request fails with `name is required`. | | `head_sha` | yes | The commit. A branch or tag name also works. Unknown commits fail with `head_sha is not a commit in this repository`. | | `status` | no | Default `queued`. | | `conclusion` | no | Setting it marks the check run `completed`. | | `details_url` | no | Link to the build; the check's name links there in the web UI. | | `external_id` | no | Your own id for the run. | | `output` | no | `title`, `summary`, `text` and `annotations`. | Fields of `PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}`: | Field | Description | |---|---| | `name` | Rename the check run. | | `status` | `queued`, `in_progress`, `completed`, `waiting`, `requested` or `pending`. Other values fail with `invalid status`. | | `conclusion` | `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out`, `action_required` or `stale`. Other values fail with `invalid conclusion`. Setting a conclusion marks the check run `completed`. `status: completed` without a conclusion fails with `conclusion is required when status is completed`. | | `details_url` | Must start with `http://` or `https://` (`details_url must be an http(s) URL`). | | `external_id` | Your own id. | | `started_at`, `completed_at` | ISO 8601 times. When omitted, Diffinery records when the check run started and completed. | | `output.title`, `output.summary`, `output.text` | Replace the stored values. Each is cut to 65,535 characters. | | `output.annotations` | Added to the annotations already stored, like GitHub. At most 50 per request are taken, and a check run keeps at most 1,000. | Each annotation can have `path`, `start_line`, `end_line`, `start_column`, `end_column`, `annotation_level` (`notice`, `warning` or `failure`; default `notice`), `title`, `message` and `raw_details`. Read them back with `GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations`. The web UI does not display annotations. The response of both requests is the check run: ```json { "id": 31, "head_sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d", "node_id": "Q2hlY2tSdW46OWY4ZTdkNmM1YjRhMzkyODE3MDZmNWU0ZDNjMmIxYTA", "external_id": "812", "url": "https://diffinery.com/api/v3/repos/acme/website/check-runs/31", "html_url": "https://ci.example.com/builds/812", "details_url": "https://ci.example.com/builds/812", "status": "completed", "conclusion": "failure", "started_at": "2026-10-13T09:36:01Z", "completed_at": "2026-10-13T09:39:13Z", "output": { "title": "2 lint errors", "summary": "eslint found 2 problems", "text": null, "annotations_count": 2, "annotations_url": "https://diffinery.com/api/v3/repos/acme/website/check-runs/31/annotations" }, "name": "ci/external", "check_suite": { "id": "app-ci-bot-9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d" }, "app": { "id": "ci-bot", "slug": "ci-bot", "node_id": "QXBwOmNpLWJvdA", "name": "ci-bot", "owner": null }, "pull_requests": [], "run_id": null, "job_id": null } ``` - `app` is the login of the account that reported the check run, or `diffinery-actions` for Actions. `run_id` and `job_id` are set only for Actions checks. - A check run is keyed by commit, name and reporter. Posting again with the same `head_sha` and `name` from the same account updates that check run (same `id`) instead of adding a second one. - Accounts whose login starts with `diffinery-actions` can't report check runs (`this account can't report check runs`). ### Who may update a check run Only the account that created a check run can update it. Anyone else, including repository admins, gets `only the app that created a check run can update it`. Check runs from Diffinery Actions can't be updated by anyone over the API. Updating needs the write role, like creating. ### Read check runs and check suites | Endpoint | Returns | |---|---| | `GET /repos/{owner}/{repo}/commits/{sha}/check-runs` | All check runs of a commit (from Actions and external reporters) as `{ total_count, check_runs }`. Filter with `check_name` and `status`. | | `GET /repos/{owner}/{repo}/check-runs/{check_run_id}` | One check run | | `GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations` | Its annotations | | `GET /repos/{owner}/{repo}/commits/{ref}/check-suites` | The commit's check suites as `{ total_count, check_suites }` | | `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}` | One check suite | | `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs` | The check runs of a suite | Check suites are derived, not created: each Actions workflow run is one suite (its id is the run id), and each external reporter has one suite per commit (id `app-<login>-<sha>`). A suite is `completed` when all its check runs are, with conclusion `failure` if any of them failed, timed out, was cancelled or needs action, and `success` otherwise. You can't create or re-request suites, and commit statuses are not part of any suite. ## Required checks A branch protection rule's **Required status checks** (`requiredChecks` in the API) lists check names that must pass on a pull request's head commit before it can merge, and on a merge group's commit before the merge queue merges it. Both check runs and commit statuses count: a required check named `ci/external` is satisfied by a check run named `ci/external` or by a commit status with context `ci/external`. To set them in the web UI, open **Settings**, then **Branch protection**, and enter the names in **Required status checks**, one per line, exactly as they appear on pull requests. A name may contain commas, for example `CI / test (ubuntu, 22)`. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection) for the whole rule. ### How a required check is decided For each required name, Diffinery looks at every check run and commit status with that name on the commit, then: 1. If the rule binds the name to a reporter in `requiredCheckSources`, only reports from that reporter count. 2. Otherwise, if Diffinery Actions reported a check with that name, only the Actions check counts. Other reports with the same name are ignored. 3. Reports from agents are ignored. 4. Of the reports left: - none: the check has not run, and the pull request is blocked with `required check '<name>' has not run`; - any completed with a conclusion other than `success`, `neutral` or `skipped` (a status of `failure` or `error` counts as failed): blocked with `required check '<name>' <conclusion>`, for example `required check 'ci/external' failure`; - any still pending or in progress: blocked with `required check '<name>' is <status>`, for example `required check 'ci / test' is in_progress`; - otherwise the check passes. For commit statuses, only the newest status of each context from each reporter counts. One account's later status never replaces another account's. These blockers appear in the pull request's merge box, in `merge_blockers` from `GET /repos/{owner}/{repo}/pulls/{number}`, in `mergeability.blockers` from the MCP `get_pull_request` tool, and in the error of a refused merge. ### Bind a required check to its reporter Without a binding, any account with the write role could post a passing status with the same name as your external CI. Bind each external required check to the account that reports it with `requiredCheckSources`, a map from check name to the reporter's login (or `diffinery-actions` for Actions): ```sh curl -sS -X PUT \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \ -d '{ "requirePullRequest": true, "requiredApprovals": 1, "requiredChecks": ["ci / test", "ci/external"], "requiredCheckSources": { "ci/external": "ci-bot", "ci / test": "diffinery-actions" } }' ``` - The request replaces the whole rule, so send every field you want to keep. It needs a token with the `admin` scope for a person with the admin role. Agents can't change branch protection. - Each value must be a login (letters, digits, `.`, `_`, `-`, `[` and `]`, starting with a letter or digit, up to 100 characters) or `diffinery-actions`, and each name at most 255 characters. Otherwise the request fails with `invalid source for required check '<name>'`, or with `requiredCheckSources must map check names to a reporter login or diffinery-actions` when it isn't an object. - Logins are compared without regard to case. - Binding only affects names listed in `requiredChecks`. - Binding a check to an agent's login means it can never pass, because agent reports are ignored. `requiredCheckSources` can only be set through the API, and the **Branch protection** settings page does not show it. Saving the rule from that page keeps the bindings of every check that is still listed in **Required status checks** (also when you rename the rule's pattern). A binding for a check you removed from the list is dropped; add the check back with the API, binding included. ### Required checks and the merge queue When the base branch uses a [merge queue](https://diffinery.com/docs/collaboration/merge-queue), required checks are evaluated on each merge group's commit, on the `gh-readonly-queue/<base>/...` branch. Actions workflows with `on: merge_group` (or `on: push` for those branches) report there automatically. An external CI system must also build the merge group branch and report its status or check run on the merge group commit; otherwise the entry waits until the queue's check timeout and leaves the queue. The queue page shows a required check that has not reported yet as `expected`. A finished commit status, or a check run sent with `POST .../check-runs` as completed, makes the queue look at the group again at once. Completing a check run with `PATCH` doesn't: the queue only notices it at its next event, at the latest when the check timeout ends. To finish a check that the queue waits on, post the final result with `POST` (the same account, commit and name update the existing check run). ### Required checks for deployments `POST /repos/{owner}/{repo}/deployments` uses the same rules (without source bindings) for `required_contexts`: without `required_contexts`, every check name on the commit that a non-agent reported must have passed; with a list, those names must have passed; `[]` skips the check. Deployments that a workflow job creates for its `environment:` skip the check. A failing check makes the request fail with `409` and `Conflict: Commit status checks failed for <ref>.` See [Environments](https://diffinery.com/docs/actions/environments). ## Agents and checks [Agents](https://diffinery.com/docs/agents/overview) with the write role can report check runs and commit statuses, and their reports appear on pull requests and commits like any other. But a report from an agent never satisfies a required check, and never fails one either: Diffinery ignores agent reports when it decides required checks, merge queue verdicts and deployment `required_contexts`. This stops an agent from approving its own changes by reporting a passing check. Run the checks you rely on in Diffinery Actions, or report them from a person's account bound with `requiredCheckSources`. ## Checks in the web UI ### On a pull request - The **Conversation** tab's merge box lists every check run and commit status on the head commit, with a summary: **All checks have passed**, **Some checks haven't completed yet**, or the number of failing checks (for example **2 failing checks**), and the number of checks. Each line has a status icon, the name, the check's title (for a commit status, its description) and how long it ran. Below are the approvals and the merge blockers, including any required check that is missing, pending or failed. - The **Checks** tab shows **Checks for** the head commit's short SHA: each check with its reporter (`diffinery-actions`, the login of an external reporter, or `status` for a commit status), its state, the start of its summary, and its duration. When nothing has reported, it says **No checks have reported for the latest commit.** Under **Workflow runs** are the pull request's Actions runs. The check lists update live while the page is open. ### On a commit The commit page (`https://diffinery.com/acme/website/commit/<sha>`) lists the commit's check runs and statuses under its header, with their titles or descriptions. ### Where a check's name links | Check | Link | |---|---| | From Diffinery Actions | The job's page and log | | Check run with `details_url`, or commit status with `target_url` | That URL, in a new tab (only `http` and `https` links) | | Otherwise | No link | ## Differences from GitHub | GitHub | Diffinery | |---|---| | Actions check runs are named after the job | Named `<workflow> / <job>`. | | Check runs are created by GitHub Apps; personal tokens can't create them | Any account with the write role can create check runs; `app` is the account's login. | | Creating a check run with an existing name adds a new run | The same account, commit and name update the existing check run. | | Check suites can be created and re-requested | Check suites are derived from check runs and read-only. | | Status descriptions are limited to 140 characters | Up to 1,024 characters are kept. | | `required_status_checks` with an app id restricts who can satisfy a check | `requiredCheckSources` binds a check name to a login or to `diffinery-actions`; when Actions reports a name, it alone decides that check even without a binding. | | Statuses and check runs can be posted with the workflow's `GITHUB_TOKEN` | Statuses can (with `statuses: write`); check runs can't. | | `check_run` and `check_suite` webhook events | Not sent. Commit statuses send `status` events. | | Agents don't exist | Reports from agents never decide a required check. | ## Troubleshooting | Symptom or message | Cause and fix | |---|---| | `required check '<name>' has not run` | No report with exactly that name on the head commit, or only reports that don't count (from agents, from Actions-overridden reporters, or from someone other than the bound source). Compare the name with the **Checks** tab, including the `<workflow> / ` prefix for Actions jobs. | | A passing external status is ignored | Actions also reports a check with that name, and Actions wins; or `requiredCheckSources` binds the name to a different login; or the reporter is an agent. Rename your context or fix the binding. | | Bindings disappeared | The check was removed from **Required status checks** when the rule was saved from the **Branch protection** settings page, which drops the binding of a check that is no longer required. Set it again with the API. | | `write access required` | The token's account lacks the write role, or a workflow's `GITHUB_TOKEN` has no write permission at all (for example `permissions: read-all`). | | `Resource not accessible by integration (...)` | A workflow's `GITHUB_TOKEN` lacks the permission named in the message. Add `statuses: write` to the job's `permissions:` to post statuses. | | `this account can't report check runs` | Check runs can't be created with a workflow's `GITHUB_TOKEN`. Post a commit status, or use a personal token. | | `this token needs the write scope` | The token has only the `read` scope. Use a token with the `write` scope. | | `only the app that created a check run can update it` | Update the check run with the same account (any of its tokens) that created it. | | `check runs from Diffinery Actions can't be changed over the API` | Re-run the job instead. | | `conclusion is required when status is completed` | Send a `conclusion` when completing a check run. | | `this SHA and context has reached the maximum number of statuses` | You posted 1,000 statuses for one context on one commit. Post less often, for example only on state changes. | | A merge queue entry waits for an external check | Your CI must report on the merge group commit (`gh-readonly-queue/...` branch), not only on the pull request's head commit. | --- # Releases > Publish versioned releases with notes and downloadable files, from the web UI, the REST API, MCP or a workflow, and react to them with webhooks. Source: https://diffinery.com/docs/collaboration/releases A release marks a tag as a version of your project and attaches release notes and downloadable files (assets) to it. Use releases to publish builds, changelogs and source archives for each version, and to start deployment workflows when a version is published. Diffinery releases follow GitHub's model and REST API, so GitHub clients and scripts that manage releases work with small or no changes. ## How releases work - Every release belongs to one tag, `refs/tags/<tag_name>`. If the tag does not exist when you publish, Diffinery creates it at `target_commitish` (a branch, tag or commit SHA; the default branch when omitted). An existing tag is used as it is and never moved. - A release is either a **draft** or **published**. Drafts are visible only to people and agents with the write role or higher. Readers, anonymous visitors of public repositories and the REST API for read-only callers never see them. Publishing a draft creates its tag. - A published release can be marked as a **pre-release** (not production ready). - One published, non-pre-release release is the repository's **latest release**. It appears on the repository home page and at `/releases/latest`. - Release notes are Markdown. Diffinery can generate them from the pull requests merged since the previous release, grouped by the categories in `.github/release.yml`. - Assets are files of up to 100 MB each, stored with the release and downloadable by anyone who can see the release. Every published release also offers the source code as zip and tar.gz archives. - Creating, publishing, editing and deleting a release sends `release` webhook events and starts `on: release` workflows. Each repository numbers its releases and assets with its own integer ids, starting at 1. ## Who can do what | Action | Role needed | |---|---| | See published releases, their notes and assets; download assets and source archives | read (anyone, for a public repository) | | See draft releases | write | | Create, edit, publish and delete releases; save drafts | write | | Upload, rename and delete assets | write | | Generate release notes (REST API and MCP) | write | | React to a release | read (signed in) | - With a personal access token, the token needs the `write` scope (or `admin`) to create or change releases. A `read` token can list and download. - A workflow's `GITHUB_TOKEN` (the job token) can create, edit and delete releases and upload assets when the job has `contents: write`, as on GitHub. With only `contents: read` (the default for new repositories) it can read releases, and a write fails with `403` and `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: contents: write)`. Releases a job token creates or changes don't start `on: release` workflows (see [Workflows triggered by releases](#workflows-triggered-by-releases)). - On an archived repository, every release change (including assets) fails with `repository is archived (read-only)`. - Deleting a release never deletes its tag. Moving or deleting an existing tag needs the maintain role. See [Permissions](https://diffinery.com/docs/accounts/permissions) for the full role table and [Tokens](https://diffinery.com/docs/accounts/tokens) for scopes. ## Releases in the web UI ### Find releases - The repository home page shows a **Releases** section in the sidebar: the number of releases and the latest release with a green **Latest** badge. When there are none it says **No releases published.**, with a **Create a new release** link if you can write. - `https://diffinery.com/acme/website/releases` lists releases, newest first, ten per page. Each release shows its title (the tag name when it has no title), the badges **Draft**, **Pre-release** and **Latest** where they apply, the tag, who released it and when (**released this**, or **drafted this** for a draft), the notes and the assets. Long notes are shortened in the list; open the release to read them in full. - `https://diffinery.com/acme/website/releases/tag/v1.2.0` shows one release in full. - `https://diffinery.com/acme/website/releases/latest` redirects to the latest release, or to the list when there is none. - The **Tags** button on the list opens the repository's tags. Each release lists its **Assets** with their size and download count. Published releases also list **Source code (zip)** and **Source code (tar.gz)**, which download the repository at the tag (`/acme/website/archive/refs/tags/v1.2.0.zip` and `.tar.gz`). Drafts don't offer source archives. ### Create a release 1. Open `https://diffinery.com/acme/website/releases` and select **Draft a new release** (or go to `https://diffinery.com/acme/website/releases/new`; `?tag=v1.2.0` pre-fills the tag). 2. Under **Tag**, pick an existing tag from **Choose an existing tag**, or type a new one in the box below it (for example `v1.2.0`). A typed tag wins over the selected one. 3. For a new tag, choose the branch it should point at under **Target for a new tag**. It defaults to the default branch. The target is ignored when the tag already exists. 4. Enter a **Release title**. When you leave it empty, the tag name is shown instead. 5. Write the **Release notes** in Markdown, or leave them empty and keep **Generate release notes from merged pull requests (when the notes are empty)** checked to have Diffinery write them. 6. Optionally choose files under **Attach files**. All the files of one submission together can be up to 100 MB; upload larger files with the REST API. 7. Check **Set as a pre-release (not production ready)** if it applies. Keep **Set as the latest release** checked to make this the latest release, or uncheck it to keep the current one. 8. Select **Publish release**, or **Save draft** to keep it private to writers. After publishing you land on the release page. If the release was saved but a file could not be attached, you see **The release was saved, but a file couldn't be attached:** followed by the reason, and the edit form opens so you can try again. ### Edit, publish or delete a release - Select **Edit** on a release (`https://diffinery.com/acme/website/releases/edit/v1.2.0?id=7`). You can change the tag, target, title, notes, the pre-release and latest checkboxes, and attach more files. - On a draft, **Publish release** publishes it (creating the tag if needed) and **Save draft** keeps it a draft. On a published release the button is **Update release**. The web UI can't turn a published release back into a draft; use the API (`"draft": true`). - To remove an asset, open the release page and select the **×** next to the file. - Select **Delete** on a release and confirm **Delete the release v1.2.0? The tag stays.** (with the release's title, or its tag when it has none). You see **Release deleted. The tag was kept.** The release's assets and reactions are deleted with it. Delete the tag separately (for example `git push origin --delete v1.2.0`) if you no longer want it. ## Releases with the REST API The API is at `https://diffinery.com/api/v3` with GitHub's paths and JSON shapes. Authenticate with `Authorization: Bearer dfy_...`. Every endpoint is listed in the [REST API reference](https://diffinery.com/docs/api). | Method and path | What it does | Role | |---|---|---| | `GET /repos/{owner}/{repo}/releases` | List releases, newest first (`per_page` up to 100, `page`; `Link` header). Drafts only for writers. | read | | `POST /repos/{owner}/{repo}/releases` | Create a release | write | | `GET /repos/{owner}/{repo}/releases/latest` | The latest release | read | | `GET /repos/{owner}/{repo}/releases/tags/{tag}` | The published release of a tag | read | | `GET /repos/{owner}/{repo}/releases/{id}` | One release by id | read (write for a draft) | | `PATCH /repos/{owner}/{repo}/releases/{id}` | Edit, publish or unpublish a release | write | | `DELETE /repos/{owner}/{repo}/releases/{id}` | Delete a release and its assets (the tag stays); returns `204` | write | | `POST /repos/{owner}/{repo}/releases/generate-notes` | Generate release notes without creating anything | write | | `GET /repos/{owner}/{repo}/releases/{id}/assets` | List a release's assets | read | | `POST {upload_url}` | Upload an asset (see [Assets](#assets)) | write | | `GET /repos/{owner}/{repo}/releases/assets/{asset_id}` | One asset as JSON, or its bytes with `Accept: application/octet-stream` | read | | `PATCH /repos/{owner}/{repo}/releases/assets/{asset_id}` | Rename an asset or change its label | write | | `DELETE /repos/{owner}/{repo}/releases/assets/{asset_id}` | Delete an asset; returns `204` | write | | `GET`, `POST /repos/{owner}/{repo}/releases/{id}/reactions`, `DELETE .../reactions/{reaction_id}` | Reactions on a release (adding one needs a signed-in account) | read | ### Create a release with the API ```sh curl -sS -X POST \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/releases \ -d '{ "tag_name": "v1.2.0", "target_commitish": "main", "name": "Website 1.2.0", "generate_release_notes": true }' ``` | Field | Type | Default | Description | |---|---|---|---| | `tag_name` | string | required | The tag. Created at `target_commitish` when the release is published and the tag does not exist. | | `target_commitish` | string | the default branch | Branch, tag or commit SHA for a new tag. Stored on the release even when the tag already exists. | | `name` | string | none | The title. `null` or omitted means no title (the tag name is shown). With `generate_release_notes`, an empty title becomes the tag name. | | `body` | string | none | Release notes in Markdown, at most 125,000 characters. | | `draft` | boolean | `false` | Save as a draft, visible to writers only. No tag is created for a draft. | | `prerelease` | boolean | `false` | Mark as a pre-release. | | `make_latest` | `"true"`, `"false"`, `"legacy"` or boolean | `"true"` | Whether this release becomes the latest release. See [The latest release](#the-latest-release). | | `generate_release_notes` | boolean | `false` | Generate notes from merged pull requests. When `body` is also given, the generated notes are appended after it, separated by a blank line. | The response is `201` with the release: ```json { "url": "https://diffinery.com/api/v3/repos/acme/website/releases/7", "assets_url": "https://diffinery.com/api/v3/repos/acme/website/releases/7/assets", "upload_url": "https://diffinery.com/api/uploads/repos/acme/website/releases/7/assets{?name,label}", "html_url": "https://diffinery.com/acme/website/releases/tag/v1.2.0", "id": 7, "author": { "login": "diffy", "id": 7220136606554077, "node_id": "VXNlcjphMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MA", "avatar_url": "https://diffinery.com/api/avatars/diffy", "gravatar_id": "", "url": "https://diffinery.com/api/v3/users/diffy", "html_url": "https://diffinery.com/diffy", "repos_url": "https://diffinery.com/api/v3/users/diffy/repos", "organizations_url": "https://diffinery.com/api/v3/users/diffy/orgs", "type": "User", "user_view_type": "public", "site_admin": false }, "node_id": "UmVsZWFzZTo3", "tag_name": "v1.2.0", "target_commitish": "main", "name": "Website 1.2.0", "draft": false, "immutable": false, "prerelease": false, "created_at": "2026-10-13T10:00:00Z", "updated_at": "2026-10-13T10:00:00Z", "published_at": "2026-10-13T10:00:00Z", "assets": [], "tarball_url": "https://diffinery.com/api/v3/repos/acme/website/tarball/v1.2.0", "zipball_url": "https://diffinery.com/api/v3/repos/acme/website/zipball/v1.2.0", "body": "## What's Changed\n* Fix broken footer links by @diffy in https://diffinery.com/acme/website/pull/41\n\n**Full Changelog**: https://diffinery.com/acme/website/compare/v1.1.0...v1.2.0", "reactions": { "url": "https://diffinery.com/api/v3/repos/acme/website/releases/7/reactions", "total_count": 0, "+1": 0, "-1": 0, "laugh": 0, "confused": 0, "heart": 0, "hooray": 0, "rocket": 0, "eyes": 0 }, "latest": true } ``` `latest` is a Diffinery field: `true` for the repository's latest release. `name` and `body` are `null` when empty, and `published_at` is `null` for a draft. Creating a published release whose tag does not exist yet also creates the tag, which sends a `push` event and starts `on: push` workflows that match the tag, exactly as if you had pushed it. The same request with the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): ```sh cat > release.json <<'EOF' { "tag_name": "v1.2.0", "name": "Website 1.2.0", "generate_release_notes": true } EOF diffinery api POST /repos/acme/website/releases --input release.json ``` There is no dedicated `release` command in the CLI; `diffinery api` sends any request. Use `--input` with a JSON file for boolean fields: `-f key=value` sends every value as a string. ### Edit, publish and unpublish `PATCH /repos/{owner}/{repo}/releases/{id}` takes the same fields as create, except `generate_release_notes`. Fields you leave out, or send as `null`, keep their value; send `""` to clear `name` or `body`. ```sh # Publish a draft curl -sS -X PATCH -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/releases/7 -d '{ "draft": false }' # Turn a pre-release into a full release and make it the latest curl -sS -X PATCH -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/releases/7 -d '{ "prerelease": false, "make_latest": "true" }' ``` - Setting `draft` to `false` on a draft publishes it: the tag is created if missing, `published_at` is set, and the release becomes the latest unless you send `"make_latest": "false"`. - Setting `draft` to `true` on a published release unpublishes it. Its tag stays. - Changing `tag_name` on a published release points it at that tag, creating the tag at `target_commitish` if it does not exist. The old tag stays. - Only one published release can use a tag. Drafts can share a tag with each other and with a published release. ### Get releases ```sh # The latest release curl -sS -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/releases/latest # A release by tag curl -sS -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/releases/tags/v1.2.0 ``` For a public repository you can leave out the `Authorization` header. `GET .../releases/tags/{tag}` returns only published releases; a draft returns `404`. ## The latest release Diffinery picks the latest release like GitHub: 1. If a release was explicitly made latest (with `make_latest` set to `true`, or **Set as the latest release** in the web form) and it is still published and not a pre-release, it is the latest. 2. Otherwise, the most recently created published release that is not a pre-release is the latest. Drafts and pre-releases are never the latest release. | `make_latest` | Effect | |---|---| | `"true"` (the default when creating, and when publishing a draft) | This release becomes the latest, if it is published and not a pre-release. Otherwise nothing changes. | | `"false"` | This release does not become the latest. The release that was latest before keeps that place, even though this one is newer. If this release was the latest, it stops being the pinned one and rule 2 applies. When no other published full release exists, rule 2 still makes this one the latest. | | `"legacy"` | Clears the explicit choice, so the most recently created published full release is the latest. | Booleans `true` and `false` mean the same as the strings. Any other value fails with `make_latest must be true, false or legacy`. When you edit a release without `make_latest`, the latest release does not change (except when publishing a draft, which defaults to `"true"`). When the repository has no published full release, `GET .../releases/latest` returns `404` with `no published releases`. ## Generated release notes Diffinery generates release notes the way GitHub's **Generate release notes** does: 1. It finds the previous release: the most recent published release whose tag is an ancestor of the new tag (or of `target_commitish`, when the tag doesn't exist yet). You can name it yourself with `previous_tag_name`. 2. It collects the pull requests merged into the commits between the previous tag and the new one (by merge commit or head commit). 3. It groups them by the categories in `.github/release.yml`, if that file exists. 4. It lists people whose first merged pull request in the repository is in the range under **New Contributors**. 5. It ends with a **Full Changelog** link: a compare link from the previous tag, or the tag's commit history when there is no previous release. Each pull request becomes one line: `* <title> by @<author> in <link>`. ### Generate notes without creating a release ```sh curl -sS -X POST -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/releases/generate-notes \ -d '{ "tag_name": "v1.2.0", "target_commitish": "main", "previous_tag_name": "v1.1.0" }' ``` | Field | Required | Description | |---|---|---| | `tag_name` | yes | The tag the notes are for. It does not need to exist yet. | | `target_commitish` | no | Used when the tag doesn't exist: the commit the notes run up to. Defaults to the default branch. | | `previous_tag_name` | no | The tag to start from. It must exist, or the request fails with `previous tag '<name>' does not exist`. | | `configuration_file_path` | no | Path of the configuration file in the repository, instead of `.github/release.yml`. | The response has the suggested title (the tag name) and the Markdown body: ```json { "name": "v1.2.0", "body": "## What's Changed\n### New Features\n* Add dark mode by @diffy in https://diffinery.com/acme/website/pull/40\n\n### Bug Fixes\n* Fix broken footer links by @diffy in https://diffinery.com/acme/website/pull/41\n\n**Full Changelog**: https://diffinery.com/acme/website/compare/v1.1.0...v1.2.0" } ``` ### Configure categories with .github/release.yml Diffinery reads `.github/release.yml` (or `.github/release.yaml`) from the commit the notes are generated for. The file has the same format as on GitHub: ```yaml changelog: exclude: labels: - ignore-for-release authors: - review-bot categories: - title: Breaking Changes labels: - breaking-change - title: New Features labels: - enhancement - feature - title: Bug Fixes labels: - bug exclude: labels: - wontfix ``` These keys are supported, all under `changelog`: | Key | Type | Effect | |---|---|---| | `exclude.labels` | list of strings | Pull requests with any of these labels are left out of the notes. | | `exclude.authors` | list of logins | Pull requests by these authors are left out. | | `categories` | list | Sections of the notes, in order. | | `categories[].title` | string | The section heading (`### <title>`). A category without a title is ignored. | | `categories[].labels` | list of strings | A pull request with any of these labels goes into this category. | | `categories[].exclude.labels` | list of strings | Pull requests with any of these labels are not put into this category. | | `categories[].exclude.authors` | list of logins | Pull requests by these authors are not put into this category. | - `"*"` in a labels list matches every pull request, including ones without labels; `"*"` in an authors list matches every author. - Labels match exactly (case matters). Author logins match regardless of case. - A single string works where a list is expected. - A pull request goes into the first category it matches. Pull requests that match no category are listed under **Other Changes** at the end. Without `categories`, all pull requests are listed directly under **What's Changed**. - A file that is not valid YAML, or has no `changelog` key, is ignored and the notes are generated without categories. - **New Contributors** is not filtered by `exclude`. ## Assets ### Upload an asset Upload a file by sending its bytes as the raw request body to the release's `upload_url` (from the release JSON), with the file name in the `name` query parameter: ```sh curl -sS -X POST \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/gzip" \ --data-binary @dist/website-linux-amd64.tar.gz \ "https://diffinery.com/api/uploads/repos/acme/website/releases/7/assets?name=website-linux-amd64.tar.gz&label=Linux%20build" ``` | Part | Description | |---|---| | `name` query parameter | Required. The file name. Characters other than letters, digits and `. _ + @ ~ -` are replaced by `.`, leading and trailing dots are removed, and only the last path segment is kept. The result must be 1 to 255 characters. | | `label` query parameter | Optional. A label shown next to the file name. | | `Content-Type` header | Stored as the asset's content type and sent back on download. Defaults to `application/octet-stream`. | | Body | The file. Up to 100 MB (104,857,600 bytes). | The response is `201` with the asset: ```json { "url": "https://diffinery.com/api/v3/repos/acme/website/releases/assets/12", "id": 12, "node_id": "UmVsZWFzZUFzc2V0OjEy", "name": "website-linux-amd64.tar.gz", "label": "Linux build", "uploader": { "login": "diffy", "id": 7220136606554077, "node_id": "VXNlcjphMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MA", "avatar_url": "https://diffinery.com/api/avatars/diffy", "gravatar_id": "", "url": "https://diffinery.com/api/v3/users/diffy", "html_url": "https://diffinery.com/diffy", "repos_url": "https://diffinery.com/api/v3/users/diffy/repos", "organizations_url": "https://diffinery.com/api/v3/users/diffy/orgs", "type": "User", "user_view_type": "public", "site_admin": false }, "content_type": "application/gzip", "state": "uploaded", "size": 5242880, "digest": null, "download_count": 0, "created_at": "2026-10-13T10:01:00Z", "updated_at": "2026-10-13T10:01:00Z", "browser_download_url": "https://diffinery.com/acme/website/releases/download/v1.2.0/website-linux-amd64.tar.gz" } ``` The upload host is the same as the rest of Diffinery: `upload_url` is under `https://diffinery.com/api/uploads/`, not a separate uploads host. Clients that read `upload_url` from the release, as GitHub clients do, work unchanged. You can upload to drafts. A release can't have two assets with the same name: delete the old one first, or rename it. ### Download an asset - `browser_download_url`, `https://diffinery.com/{owner}/{repo}/releases/download/{tag}/{name}`, downloads the file. For a private repository, send your token (`curl -L -H "Authorization: Bearer dfy_..."`) or be signed in. - `GET /api/v3/repos/{owner}/{repo}/releases/assets/{id}` with `Accept: application/octet-stream` returns the bytes directly. Without that header it returns the asset JSON. ```sh curl -sSL -H "Authorization: Bearer dfy_..." -H "Accept: application/octet-stream" \ -o website-linux-amd64.tar.gz \ https://diffinery.com/api/v3/repos/acme/website/releases/assets/12 ``` Downloads are always served as attachments (`Content-Disposition: attachment`), so browsers save them instead of displaying them. Each download adds one to `download_count`. Assets of a draft can be downloaded only by writers. ### Rename or delete an asset ```sh # Rename and relabel curl -sS -X PATCH -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/releases/assets/12 \ -d '{ "name": "website-1.2.0-linux-amd64.tar.gz", "label": "Linux (amd64)" }' # Delete curl -sS -X DELETE -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/repos/acme/website/releases/assets/12 ``` ### Asset size limits | Upload path | Limit | |---|---| | REST API `upload_url` | 100 MB per file | | Web form (**Attach files**) | 100 MB for all files of one submission together | | MCP `upload_release_asset` | 25 MB per file | ## Releases over MCP Agents and MCP clients connected to `https://diffinery.com/mcp` get these tools. They apply the same permission checks as the REST API. Full input schemas are in the [MCP tool reference](https://diffinery.com/docs/reference/mcp-tools). | Tool | What it does | |---|---| | `list_releases` | List releases, newest first (`limit` default 30, at most 100). Drafts only for writers. | | `get_release` | Get a release by `tag` (drafts included for writers), by `id`, or the latest release when neither is given. | | `create_release` | Create a release: `tag_name` (required), `target_commitish`, `name`, `body`, `draft`, `prerelease`, `make_latest` and `generate_release_notes`. Creates the tag when it doesn't exist. | | `update_release` | Edit a release by `id`: tag, target, title, notes, draft and pre-release state, `make_latest`. Publishing a draft sends the release events. | | `upload_release_asset` | Attach a file of up to 25 MB to `release_id`: pass text as `content`, or bytes as `content_base64` (exactly one of them), with `name`, and optionally `label` and `content_type` (default `text/plain` for `content`, `application/octet-stream` for `content_base64`). | | `generate_release_notes` | Return generated notes for `tag_name` (with optional `target_commitish` and `previous_tag_name`) without creating anything. Needs write access. | There is no MCP tool to delete a release; use the REST API or the web UI. The tools return a compact release object: `id`, `tag_name`, `target_commitish`, `name`, `body`, `draft`, `prerelease`, `latest`, `author` (the login), `created_at`, `published_at`, `html_url` and `assets` (each with `id`, `name`, `label`, `content_type`, `size`, `download_count` and `download_url`). ## Workflows triggered by releases A workflow with `on: release` runs when a release is published or changed. It runs from the workflow files at the release's tag, with `GITHUB_REF` set to `refs/tags/<tag>` and `GITHUB_SHA` set to the tagged commit. ```yaml name: publish on: release: types: [published] permissions: contents: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./build.sh "${{ github.event.release.tag_name }}" - name: Attach the build to the release env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | curl -fsS -X POST \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/gzip" \ --data-binary @dist/website.tar.gz \ "https://diffinery.com/api/uploads/repos/${{ github.repository }}/releases/${{ github.event.release.id }}/assets?name=website.tar.gz" ``` The job's own `GITHUB_TOKEN` uploads the asset, which needs `contents: write` (set above for the whole workflow). Uploads go to the release's `upload_url`, under `/api/uploads/repos/...` (GitHub's `uploads.github.com`). To use a personal or agent token instead, store it as an Actions secret and put it in `TOKEN`. ### Activity types | Type | When | |---|---| | `created` | A release is created (published or not). | | `published` | A release is published: created as published, or a draft is published. | | `released` | A full release is published, or a published pre-release becomes a full release. | | `prereleased` | A pre-release is published, or a published release becomes a pre-release. | | `edited` | A published release is edited (without changing its draft state). | | `unpublished` | A published release is turned back into a draft. | | `deleted` | A published release is deleted. | Like GitHub: - Without `types`, every activity type starts the workflow. Creating a published release sends `created`, `published` and `released` (or `prereleased`), so a workflow without `types` runs three times. Use `types: [published]` for "run once per new release". - Changes to drafts (creating, editing or deleting a draft) never start workflows. They do send webhooks. - The workflow only runs when the release's tag exists. - Releases created or changed with a job's `GITHUB_TOKEN` don't start workflows, so a workflow can't trigger itself in a loop. Releases made with a personal or agent token do. ### The event payload `github.event` holds: | Field | Description | |---|---| | `action` | The activity type, for example `published` | | `release` | The release, in the same shape as the REST API (`id`, `tag_name`, `name`, `body`, `draft`, `prerelease`, `assets`, `upload_url`, `html_url`, `author`, ...) | | `repository` | GitHub's full repository object, as `GET /repos/{owner}/{repo}` returns it: `id`, `name`, `full_name`, `owner`, `private` and `visibility` (the repository's real visibility), `default_branch`, `html_url`, `clone_url`, the API URLs and so on | | `sender` | `login` and `type` (`User` or `Bot`) of who caused the event | ## Release webhooks Webhooks subscribed to `release` (or to every event) get one delivery per action, including actions on drafts: `created`, `published`, `released`, `prereleased`, `edited`, `unpublished` and `deleted`. The payload is `{ "action", "release", "repository", "sender" }`, with `release` in the REST API shape. Uploading or deleting an asset does not send an event. See [Webhook events](https://diffinery.com/docs/reference/webhook-events#release) for the full payload and [Webhooks](https://diffinery.com/docs/repositories/webhooks) to set one up. ## Agents and releases [Agents](https://diffinery.com/docs/agents/overview) have the write role at most, which is enough to create, edit and delete releases and upload assets, through the REST API or the MCP tools. The usual agent guardrails apply around them: - A tag an agent creates, by pushing it or by publishing a release, never matches an environment's deployment tag patterns. Deployments of agent-made releases to tag-restricted [environments](https://diffinery.com/docs/actions/environments) need a person to create the tag. - Workflow runs started by an agent's release get environment secrets only when the environment is limited to protected branches. - Agents can't change branch protection, secrets or environments, so they can't loosen those rules to ship a release. ## Differences from GitHub | GitHub | Diffinery | |---|---| | Uploads go to `uploads.github.com` | `upload_url` is on the same host, under `/api/uploads/`. | | Asset downloads through the API redirect to a storage URL | `Accept: application/octet-stream` returns the bytes directly with `200`. | | Assets up to 2 GiB | Assets up to 100 MB (25 MB over MCP). | | `make_latest: legacy` considers creation date and semantic version | `legacy` uses creation date only. | | Release ids are global | Release and asset ids are numbered per repository. | | `discussion_category_name`, immutable releases, asset digests | Not supported: `discussion_category_name` is ignored, `immutable` is always `false` and `digest` is always `null`. | | No `latest` field | Releases have a `latest` boolean. | ## Troubleshooting | Message | Cause and fix | |---|---| | `tag_name is required` | Send `tag_name`, or in the web form choose a tag or type one (`choose a tag or type a new one`). | | `a release for this tag already exists` | Only one published release per tag. Edit the existing release, or use another tag. | | `'<tag>' is not a valid tag name` | The tag isn't a valid git ref name (for example it contains spaces, `..` or ends with `.lock`). | | `target_commitish '<x>' does not exist` | The branch, tag or SHA for the new tag doesn't exist in the repository. `generate-notes` reports it as `target_commitish does not exist`. | | `tag was created concurrently; retry` | Someone created the tag at the same moment. Send the request again. | | `write access required` | Your role is below write, or the request used a workflow's `GITHUB_TOKEN` without any write permission. | | `Resource not accessible by integration (...)` | A workflow's `GITHUB_TOKEN` lacks `contents: write`. Add it to the job's or workflow's `permissions:`. | | `this token needs the write scope` | Your token has only the `read` scope. | | `release not found` | Wrong id, or the release is a draft and you can't write to the repository. | | `no published releases` | `/releases/latest` with no published full release. Publish one, or mark one as latest. | | `body is too long (maximum is 125000 characters)` | Shorten the notes, or attach the full changelog as an asset. | | `the name query parameter is required` | Add `?name=<file name>` to the upload URL (drop the `{?name,label}` template part). | | `release assets are limited to 100 MB` | Split the file or host it elsewhere and link to it from the notes. | | `an asset named <name> already exists` | Delete or rename the existing asset first. | | `asset name must be 1 to 255 characters` | The name is empty after sanitizing, or too long. | | `the web form takes up to 100 MB of files at once; upload larger files with the REST API` | Attach fewer files per submission, or use `upload_url`. | | `MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files` | Upload large files over REST. | | `previous tag '<name>' does not exist` | Check `previous_tag_name`, or leave it out to let Diffinery find the previous release. | | An `on: release` workflow runs three times | It has no `types` filter. Add `types: [published]`. | | An `on: release` workflow doesn't run for a draft | Draft changes never start workflows. Publish the release. | | Generated notes are missing a pull request | Only merged pull requests whose merge or head commit is between the previous tag and this one are listed, minus `exclude` matches in `.github/release.yml`. Pass `previous_tag_name` if the wrong previous release was picked. | --- # Actions overview > How Diffinery Actions runs GitHub Actions workflows on Cloudflare, and how to start, watch, cancel and re-run runs from the web UI, CLI, API and MCP. Source: https://diffinery.com/docs/actions/overview Diffinery Actions is the CI system built into every Diffinery repository. It reads GitHub Actions workflow files from your repository, runs their jobs in Linux containers on Cloudflare (or on your own machines), and reports the results as checks on commits and pull requests. Use it to build, test and deploy your code on push, on pull requests, on a schedule or on demand. ## Key concepts | Term | Meaning | | --- | --- | | Workflow | A YAML file in `.github/workflows/` or `.diffinery/workflows/` that says when to run (`on:`) and what to run (`jobs:`). The syntax is GitHub Actions syntax. See [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax). | | Event | Something that happens in the repository and can start a workflow: a push, a pull request update, a merge queue group, a manual dispatch, a schedule, another workflow's run, a published release or a `repository_dispatch` call. See [Events that trigger workflows](https://diffinery.com/docs/actions/events). | | Run | One execution of one workflow for one event. A run has a number, a status and a conclusion, and holds one or more jobs. | | Job | A set of steps that runs on one runner. Jobs run in parallel unless they depend on each other with `needs`. A matrix turns one job definition into several jobs. | | Step | One command (`run:`) or one action (`uses:`) inside a job. Steps run in order in the same workspace. | | Runner | The machine that executes a job. Diffinery-hosted runners are Cloudflare Containers; self-hosted runners are machines you connect with `diffinery runner`. See [Runners](https://diffinery.com/docs/actions/runners). | | Check | The result of one job, reported on the commit the run is about. Branch protection can require checks to pass before merging. | | Environment | A named deployment target, such as `production`, with its own secrets, variables and branch policy. A job that sets `environment:` gets them and records a deployment. See [Environments](https://diffinery.com/docs/actions/environments). | Workflows are compatible with GitHub Actions: the same triggers, expressions, matrices, `needs`, outputs, caches, artifacts, secrets, environments, OIDC, a `GITHUB_TOKEN` that follows `permissions:`, the official actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/github-script`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go` and the rest), and remote JavaScript, composite and Docker actions. Differences are called out on each page and collected in [GitHub Actions compatibility](https://diffinery.com/docs/actions/compatibility). ## Your first workflow You need write access to the repository so you can push. This example runs the tests of a Node.js project on every push to `main` and on every pull request. 1. In a clone of `acme/website`, create `.github/workflows/ci.yml`: ```yaml name: CI on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test - name: Write a summary run: echo "### Tests passed on \`${{ github.ref_name }}\`" >> "$GITHUB_STEP_SUMMARY" ``` 2. Check the file before you push. The CLI uses the same parser as the server: ```sh diffinery workflow validate .github/workflows/ci.yml ``` 3. Commit and push: ```sh git add .github/workflows/ci.yml git commit -m "Add CI" git push origin main ``` 4. Open `https://diffinery.com/acme/website/actions`. A run titled `Add CI` (the first line of the commit message) appears under **All workflows** and under **CI** in the sidebar. Click it to see its jobs, then click the `test` job to follow its log live. 5. Or follow it from your terminal: ```sh diffinery run list -R acme/website --limit 1 diffinery run watch <run-id> -R acme/website ``` When the job finishes, the commit shows a check named `CI / test`. On a pull request, the same check appears on the pull request's head commit. If you don't have the CLI yet, install it and sign in through your browser: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login ``` ## Where workflow files live Diffinery reads workflow files from two directories at the root of the repository: - `.github/workflows/` - `.diffinery/workflows/` Every file directly in one of these directories whose name ends in `.yml` or `.yaml` is a workflow. Files in subdirectories are ignored. There is no precedence between the two directories: workflows from both are loaded (`.github/workflows/` first), each one is its own workflow identified by its path, and each one that matches an event starts its own run. Use `.diffinery/workflows/` for workflows that only make sense on Diffinery, so a GitHub mirror of the repository doesn't try to run them. When you name a workflow by file name rather than path (for example `deploy.yml` in `diffinery workflow run deploy.yml`), Diffinery picks the first file whose path is, or ends with `/deploy.yml`, so `.github/workflows/deploy.yml` wins over `.diffinery/workflows/deploy.yml`. Pass the full path to pick the other one. Which version of a file runs depends on the event: | Event | Workflow files are read from | | --- | --- | | `push` | The pushed commit | | `pull_request` | The pull request's test merge commit (the head merged into the base) | | `pull_request_target` | The base branch's current commit | | `merge_group` | The merge group commit | | `workflow_dispatch` | The branch or tag you choose | | `schedule`, `workflow_run`, `repository_dispatch` | The default branch's current commit | | `release` | The commit the release's tag points to | The workflow list in the **Actions** sidebar and `GET /repos/{owner}/{repo}/actions/workflows` show the workflows on the default branch. ## How a run executes 1. **An event happens.** Diffinery loads the workflow files from the commit the event is about (see the table above) and checks each workflow's `on:` section, including branch, tag and path filters. 2. **A run is created** for each matching workflow, with status `queued`. It gets the next run number and a title: the `run-name` if the workflow sets one, otherwise the first line of the commit message (push), the pull request title (pull request events), `Merge group for #<n>: <title>` (merge groups), the release's name or tag (release), the `event_type` (repository_dispatch) or the workflow name (dispatch, schedule, workflow_run). If the workflow has a `concurrency` group, other runs in the group may be cancelled or this run may wait (see [Concurrency](#concurrency)). 3. **The run is orchestrated durably** by a Cloudflare Workflow. It walks the job graph from `needs`: each job starts as soon as the jobs it needs have finished, without waiting for unrelated jobs. The orchestration survives restarts of the platform, and each wait for a job lasts at most 6 hours. 4. **Each job is evaluated** just before it starts: its `if:` condition and its matrix. A job whose condition is false is recorded as `skipped`; without a status function in its `if:`, a job is also skipped when any job above it in the `needs` chain failed, was cancelled or was skipped. A matrix job becomes one job per combination (at most 256), started up to `max-parallel` at a time, and each combination gets its own `name`, `runs-on`, `timeout-minutes`, `continue-on-error` and job `concurrency` group, evaluated with its `matrix` values. A job with a `concurrency` group waits as `pending` while another job of the group runs. 5. **Each hosted job gets its own container.** A fresh Cloudflare Container (Ubuntu 24.04, linux/amd64) starts with a job token that is valid only for that job. The runner agent inside fetches the job definition (for a job with `environment:`, this is when the environment's branch policy is checked and its secrets and variables are added), runs the steps in order, streams the log back as it goes, and reports each step's result and the job's conclusion. The container stops when the job ends. Jobs for self-hosted runners wait until a `diffinery runner` with matching labels claims them instead. 6. **The run finishes** when every job is done. Its conclusion is `failure` if any job failed or timed out (except jobs with `continue-on-error: true`), otherwise `cancelled` if the run was cancelled or any job was cancelled, otherwise `skipped` if every job was skipped, otherwise `success`. Skipped jobs don't make a run fail. As on GitHub, a `workflow_run` job with `if: github.event.workflow_run.conclusion == 'success'` is skipped after a run whose jobs were all skipped. If the orchestration loses track of a run whose jobs have all finished (for example, a job's completion signal lost while the platform updates), Diffinery notices 5 minutes after the last job finished and signals the orchestration again. A run still unfinished 30 minutes after its last job, or whose orchestration has stopped, is finished by Diffinery itself, which frees its concurrency group. Jobs the run never started make it fail, and a `Set up workflow` job names them: `The run's workflow stopped after its jobs finished, so a job never started (deploy). Re-run the workflow.` ### Timeouts | What | Default | Maximum | | --- | --- | --- | | A job (`jobs.<id>.timeout-minutes`) | 360 minutes | 360 minutes. Larger values are capped at 360. | | A step (`steps[*].timeout-minutes`) | None (the job's timeout applies) | The job's timeout | | Waiting for a self-hosted runner, plus running the job | | 6 hours, after which the job is marked timed out | A job that runs past its `timeout-minutes` is stopped by its runner and fails with `The job exceeded the maximum execution time of <n> minutes.` If Diffinery hears nothing from a job for 6 hours (for example a self-hosted job that no runner picked up, or a runner that disappeared), it stops waiting, ends the job with `The job exceeded its timeout of <n> minutes.` and the conclusion `timed_out`, which the job page shows as **Timed out**. ### Run and job states | Status | Meaning | | --- | --- | | `queued` | The run or job has been created and hasn't started. | | `pending` | (Jobs) The job is held back by its job-level `concurrency` group until the group's running job finishes. Shown as **Pending**. | | `waiting` | (Jobs) The job is waiting for its container to start, or for a self-hosted runner to claim it. | | `in_progress` | The run is executing, or a runner has picked up the job. | | `completed` | Finished. See the conclusion. | | Conclusion | Meaning | | --- | --- | | `success` | Every step succeeded (steps with `continue-on-error: true` may have failed). | | `failure` | A step failed, the job could not start, or (for runs) a job without `continue-on-error: true` failed. | | `cancelled` | Someone cancelled the run, a newer run or job in the same concurrency group replaced it, or a sibling matrix job failed or timed out with `fail-fast` on. | | `skipped` | The job's `if:` condition was false, or (for runs) every job was skipped. | | `timed_out` | (Jobs) Diffinery waited 6 hours for the job without a result. | ## The Actions tab Anyone who can read the repository can see its runs and logs (anyone at all for a public repository). Starting, cancelling and re-running runs needs write access. ### Runs list `https://diffinery.com/acme/website/actions` shows the newest runs first, 25 per page. - The sidebar lists **All workflows** and each workflow on the default branch by name. A workflow with errors shows a failure icon, and its page shows a **This workflow file has errors** box with each error and its line. The **All workflows** page shows **Some workflow files have errors** when any file is invalid. - Filter by branch with the **Branch** menu (**All branches** by default) and by state with the **Status** menu: **Any status**, `queued`, `in progress`, `completed`, `success`, `failure` or `cancelled`. Click **Filter** to apply. - Each row shows the run's title, workflow name, run number, attempt (when greater than 1), triggering event, pull request, actor (with an **agent** badge for agents), ref, age and duration. The list updates live while you watch it. ### Run page Click a run to open `https://diffinery.com/acme/website/actions/runs/<run-id>`. The run number works in place of the run ID (for example `.../actions/runs/128`) and opens its latest attempt. It shows: - The run's **Status** and duration, what **Triggered** it and who, the **Commit** and ref (and pull request), and the **Workflow file** at that commit. - One box per job id, listing the jobs it produced (a matrix job says how many, for example `4 jobs (matrix)`), each with its status, duration and, for a failed job, the first line of the failure reason. - **Artifacts**, when jobs uploaded any that haven't expired, with their size and age. Click a name to download it: a `.zip` file, or the file itself for an artifact uploaded with `archive: false`. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). - **Cancel run** while the run is not completed (you confirm with **Cancel this run?**), and **Re-run all jobs** once it has completed. Both need write access. A cancelled job on a Diffinery-hosted runner first runs its `if: always()` and `if: cancelled()` steps, for up to 5 minutes (see [Status check functions](https://diffinery.com/docs/actions/expressions#status-check-functions)); cancelling again stops them. ### Job page and logs Click a job to open its log at `https://diffinery.com/acme/website/actions/runs/<run-id>/job/<job-id>`. - The log is split into collapsible sections: **Set up job**, then one per step, then one per post step (for example `Post Run actions/cache@v4`). Failed and running steps open automatically. - While the job runs, new lines stream in live. - A failed job shows a box with **Failed at** the failing step and the reason: the step's own `::error::` message if it printed one, otherwise the exit code and the last lines of its output. Cancelled and timed-out jobs show **Cancelled** or **Timed out**. - **Job summary** renders the Markdown that steps wrote to `$GITHUB_STEP_SUMMARY`. - **Outputs** lists the job's outputs. - **Raw logs** downloads the whole log as plain text, one line per log line prefixed with its timestamp. The page itself shows the first 20,000 lines and links to the full log when there are more. Secret values, and values registered with `::add-mask::`, are replaced by `***` before log lines leave the runner. ## Start a workflow manually A workflow with an `on: workflow_dispatch` trigger can be started on demand. You need write access. ```yaml name: Deploy on: workflow_dispatch: inputs: environment: description: Where to deploy type: choice options: [staging, production] default: staging dry-run: type: boolean default: true jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./deploy.sh "${{ inputs.environment }}" "${{ inputs.dry-run }}" ``` Input types, defaults and validation are described in [Events that trigger workflows](https://diffinery.com/docs/actions/events#workflow_dispatch). ### In the web UI 1. Open the **Actions** tab and click the workflow in the sidebar. 2. Expand **Run workflow** (shown when the workflow on the default branch has a `workflow_dispatch` trigger). 3. Pick the branch or tag under **Use workflow from**. 4. Fill in the inputs. Booleans are checkboxes, `choice` inputs are menus, `number` inputs are number fields, everything else is a text field. Required inputs are marked with `*`. 5. Click **Run workflow**. You land on the new run's page. The form is built from the input definitions on the default branch; the run itself uses the workflow file at the ref you pick. ### With the CLI ```sh diffinery workflow run deploy.yml -R acme/website --ref main \ -f environment=production -f dry-run=false ``` | Flag | Meaning | | --- | --- | | `-r, --ref REF` | Branch or tag to run on. Default: the repository's default branch. | | `-f, --field KEY=VALUE` | An input value. Repeat for more inputs. | | `-R, --repo OWNER/NAME` | Repository. Default: inferred from the `origin` remote. | | `--json` | Print the API response as JSON. | The command prints the run ID and URL, and suggests `diffinery run watch <run-id>` to follow it. ### With the API Use a token with the write scope (see [Tokens](https://diffinery.com/docs/accounts/tokens)): ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/workflows/deploy.yml/dispatches \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"ref": "main", "inputs": {"environment": "production", "dry-run": false}}' ``` The workflow can be a file name (`deploy.yml`) or a URL-encoded path (`.github%2Fworkflows%2Fdeploy.yml`). Unlike GitHub, which answers `204 No Content`, Diffinery answers `201 Created` with the new run: ```json { "run_id": "3f9c2a7d0b8e4c1f9a6d5e2b7c4a1f08", "html_url": "https://diffinery.com/acme/website/actions/runs/3f9c2a7d0b8e4c1f9a6d5e2b7c4a1f08" } ``` | Status | Message | Cause | | --- | --- | --- | | 403 | `write access is required to run workflows` | Your role or token scope is below write. | | 404 | `workflow deploy.yml not found at main` | No such file at that ref, or the file is invalid (the errors follow a colon). | | 422 | `unknown ref <ref>` | The branch or tag doesn't exist. | | 422 | `workflow does not have a workflow_dispatch trigger` | Add `on: workflow_dispatch` to the file at that ref. | | 422 | `input '<name>' is required` | A required input is missing or empty and has no default. | | 422 | `input '<name>' must be one of <a>, <b>` | A `choice` input got a value that is not in `options`. | ### With MCP The `dispatch_workflow` tool takes `owner`, `repo`, `workflow`, an optional `ref` (default: the default branch) and an optional `inputs` object. See [MCP tools](https://diffinery.com/docs/reference/mcp-tools). ## Follow runs and read logs ### From the CLI | Command | What it does | | --- | --- | | `diffinery run list` | Lists runs, newest first. Filter with `-b, --branch`, `-w, --workflow` (file name or path), `-s, --status`, `-e, --event`, `-c, --commit` and `-L, --limit` (default 20). | | `diffinery run view <run-id>` | Shows a run and its jobs, with job IDs and failure reasons. | | `diffinery run watch <run-id>` | Polls every 3 seconds (`-i, --interval SECONDS`) and prints each job's status changes. Exits 0 if the run succeeds and 1 otherwise, so you can use it in scripts. On failure it prints the `diffinery run logs` command for the failed job. | | `diffinery run logs <job-id>` | Prints a job's log. With `--json`, prints the log lines as JSON objects with `seq`, `t` (timestamp), `step` (step index, `-1` for job setup) and `text`. | | `diffinery run rerun <run-id>` | Re-runs a run; `--failed` re-runs only its failed jobs, and `--job <job-id>` one job (see [Re-run a run](#re-run-a-run)). | | `diffinery run cancel <run-id>` | Cancels a queued or running run. | | `diffinery workflow list` | Lists the workflows on the default branch, whether each is `active` or `invalid`, and their triggers. | | `diffinery workflow validate [files...]` | Checks workflow files locally (see [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax#validate-workflow-files)). | Every command takes `-R acme/website` when you're not inside a clone, and `--json` for machine-readable output. Full flags are in the [CLI reference](https://diffinery.com/docs/reference/cli). A typical loop for an agent or a script after pushing: ```sh run=$(diffinery run list -R acme/website --commit "$(git rev-parse HEAD)" --json | jq -r '.[0].id') diffinery run watch "$run" -R acme/website || { job=$(diffinery run view "$run" -R acme/website --json | jq -r '.jobs[] | select(.conclusion == "failure") | .id' | head -n1) diffinery run logs "$job" -R acme/website | tail -n 100 } ``` ### From the API | Request | Returns | | --- | --- | | `GET /api/v3/repos/{owner}/{repo}/actions/workflows` | Workflows on the default branch, with `state` (`active` or `invalid`), `errors` and `triggers`. | | `GET /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow}` | One workflow on the default branch, by file name or URL-encoded path. Its `id` is its path. | | `GET /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow}/runs` | That workflow's runs, newest first. Takes `branch`, `status`, `event`, `head_sha`, `per_page` (at most 100, default 30) and `page`. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs` | Runs, newest first. Query parameters: `workflow`, `branch`, `status` (a status or a conclusion), `event`, `head_sha`, `per_page` (at most 100, default 25), `page`. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}` | One run with its `jobs`. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt}` | Attempt `attempt` of the run (each attempt is its own run; see [Re-run a run](#re-run-a-run)). `404 run attempt not found` if there is no such attempt. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/jobs` | The run's jobs, paginated with `per_page` (default 30) and `page`. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/logs` | A zip file with one text file per job (`1_<job name>.txt`, `2_<job name>.txt`, ...), each line prefixed with its timestamp. Holds at most 24 MB of logs; jobs past that are left out. | | `DELETE /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/logs` | Deletes every job's log and step summary for good (`204`); the run, its jobs, checks and artifacts stay. Needs write, and the run must have finished. Agents can delete only the logs of runs that didn't run on the default branch or a protected branch. Audited as `repo.actions.logs.delete`. | | `GET /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}` | One job, with its steps, outputs, failure and summary. | | `GET /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/logs` | The log as text (`<timestamp> <line>` per line). Add `format=json` for JSON lines, and `after=<seq>` to get only lines after a sequence number. | | `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The run's artifacts (every attempt when `run_id` is a run number), expired ones included. See [Download artifacts with the API](https://diffinery.com/docs/actions/caching-and-artifacts#download-artifacts-with-the-api) for the other artifact and cache endpoints. | | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` | Cancels the run (`202`). Needs write. | | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` | Re-runs every job of the run (`201`, `{"run_id": ...}`, the new attempt's ID). Needs write. | | `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | Re-runs the run's failed jobs and the jobs that depend on them (`201`, `{"run_id": ...}`). See below. | | `POST /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}/rerun` | Re-runs one job and the jobs that depend on it (`201`, `{"run_id": ...}`). See below. | Run objects use GitHub's field names (`id`, `node_id`, `run_number`, `run_attempt`, `name`, `display_title`, `path`, `workflow_id`, `event`, `head_branch`, `head_sha`, `sha`, `ref`, `status`, `conclusion`, `actor` and `triggering_actor` as user objects, `created_at`, `updated_at`, `run_started_at`, `url`, `html_url`, `jobs_url`, `logs_url`, `artifacts_url`, `cancel_url`, `rerun_url`, `workflow_url`, `previous_attempt_url` and `pull_requests`) plus `pull_request_number`. Job objects carry GitHub's fields (`run_id`, `run_attempt`, `head_sha`, `head_branch`, `html_url`, `started_at`, `completed_at`, `workflow_name`, `steps` with `number`) next to Diffinery's own camelCase fields, which the CLI reads. `head_sha` is the commit checks are reported on (the pull request head for pull request runs) and `sha` is the commit the run checked out. Run and job IDs are 32-character hexadecimal strings. Wherever a path takes `{run_id}`, the run number works too and means its latest attempt; this is what `github.run_id` and `GITHUB_RUN_ID` hold inside a job. A job's `check_run_url` points at `GET /repos/{owner}/{repo}/check-runs/{job_id}`, which returns the job's check run. While a job is running, one logs request returns at most 5,000 lines; page with `after`. The runs, jobs and artifacts lists send a `Link` header with `next`, `prev`, `first` and `last` URLs, so Octokit's `paginate()` works. Errors have a `message` and a `documentation_url`. Every endpoint is listed in the [API reference](https://diffinery.com/docs/api). ### From MCP Agents can use `list_workflow_runs`, `get_workflow_run`, `get_job_logs` (the last 400 lines by default, set `tail` for more), `rerun_workflow` (with `failed_only` for the failed jobs only), `rerun_job`, `cancel_workflow` and `dispatch_workflow`. See [MCP tools](https://diffinery.com/docs/reference/mcp-tools). ## Cancel a run Click **Cancel run** on the run page, run `diffinery run cancel <run-id>`, call `POST /actions/runs/{run_id}/cancel`, or use the `cancel_workflow` MCP tool. You need write access. Cancelling stops every unfinished job's container at once and marks those jobs `cancelled` with the reason `Cancelled by <login>`. Jobs that haven't started yet and have no status function in their `if:` are skipped; a job with `if: always()` or `if: cancelled()` still runs, as on GitHub. The run's conclusion is `cancelled`. ## Re-run a run You can re-run a run once it has finished, in three ways. You need write access. | What runs again | Web | CLI | API | MCP | | --- | --- | --- | --- | --- | | Every job | **Re-run all jobs** on the run's page | `diffinery run rerun <run-id>` | `POST /actions/runs/{run_id}/rerun` | `rerun_workflow` | | The failed jobs | **Re-run failed jobs** on the run's page (shown when a job failed, was cancelled or timed out) | `diffinery run rerun <run-id> --failed` | `POST /actions/runs/{run_id}/rerun-failed-jobs` | `rerun_workflow` with `failed_only: true` | | One job | **Re-run this job** on the job's page | `diffinery run rerun --job <job-id>` | `POST /actions/jobs/{job_id}/rerun` | `rerun_job` | Each re-run is a new attempt of the run, with: - the same workflow file (read again from the original commit), commit, ref, event, event payload and inputs, - the same run number and an attempt number one higher (shown as `attempt 2`, and available as `github.run_attempt`), so `github.run_id` stays the same, - a new run ID in the API, and you as the actor, - whether its code came from an agent, kept from the original run whoever re-runs it (see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)). **Re-run failed jobs** runs again, as on GitHub, every job and matrix leg whose conclusion was `failure`, `cancelled` or `timed_out`, and every job that depends on one of them through `needs`, directly or not (all of a dependent job's matrix legs run again). A job that never started because the run was cancelled first runs too. Jobs inside a [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows) count like any other job, and a job that needs the calling job depends on every job inside the call. The other jobs and matrix legs are kept from the earlier attempt as they were: their conclusion, outputs, steps, log, summary and artifacts. They don't run again, start no runner and use no minutes, and they don't count toward `max-parallel`, so a matrix with `fail-fast` can't cancel a leg that already passed. Their check runs point at the new attempt with the same conclusion. On the run's page they're marked **Kept from an earlier attempt**, and in the API a kept job has `carriedFrom`, the ID of the job that ran. A run with no failed, cancelled or timed out job answers `409 this run has no failed jobs to re-run`. **Re-run this job** works the same way for one job (one matrix leg): it runs again with every job that depends on it, and every other job is kept, whatever its conclusion. The run's conclusion counts kept jobs like the others. The body field `enable_debug_logging` of the re-run endpoints is accepted and ignored. Failed-job and single-job re-runs need a finished run (`409 this run hasn't finished yet; ...` otherwise). If what failed was the workflow's set-up (the file couldn't be read or a reusable workflow couldn't be loaded), every job runs again. A workflow's own `GITHUB_TOKEN` can't re-run runs: `403 a workflow's GITHUB_TOKEN cannot re-run workflows`. It fails with `422 workflow file no longer valid at this commit` if the file can't be loaded from that commit any more. > **Note:** On GitHub a re-run keeps the same run ID in the API, and a kept job keeps its job ID and the attempt it ran in. On Diffinery the new attempt has a new `id` (the run number, `github.run_id`, stays the same), a kept job is a copy with its own ID in the new attempt, and its `run_attempt` is the new attempt's. ## Job summaries Append Markdown to the file named by `$GITHUB_STEP_SUMMARY` in any step: ```yaml - name: Report coverage run: | { echo "## Coverage" echo "" echo "| File | Lines |" echo "| --- | --- |" echo "| src/app.ts | 94% |" } >> "$GITHUB_STEP_SUMMARY" ``` Each step gets its own empty summary file. At the end of the job, the non-empty summaries of all steps are joined in step order, secrets are masked, and the result is shown under **Job summary** on the job page and returned as `summary` by `GET /actions/jobs/{job_id}`. ## Status checks on commits and pull requests Every job reports a check on the commit the run is about: - For `push`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release` and `repository_dispatch` runs, that is the run's commit. - For `pull_request` and `pull_request_target` runs, it is the pull request's head commit, even though the job checks out the test merge commit or the base commit. The check is named `<workflow name> / <job name>`, for example `CI / test`, or `CI / test (ubuntu-latest, 22)` for a matrix job without a `name`. It becomes `queued` when the job is launched, `in_progress` when a runner picks it up, and `completed` with the job's conclusion. Its details link opens the job's log. Pull requests show the checks of their head commit in the merge box and on the **Checks** tab. To block merging until checks pass, list them by these exact names under **Required status checks** in a branch protection rule; see [Branch protection](https://diffinery.com/docs/repositories/branch-protection). External CI systems can report results too, as check runs (`POST /repos/{owner}/{repo}/check-runs`) or as commit statuses (`POST /repos/{owner}/{repo}/statuses/{sha}`, where the status's `context` is its name). Both count as checks for required checks and the merge queue. Read them with `GET /repos/{owner}/{repo}/commits/{ref}/check-runs` and `GET /repos/{owner}/{repo}/commits/{ref}/status`. See [Checks and commit statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). How Diffinery decides a required check when several reporters use the same name: - When Diffinery Actions reports a check with that name, only its result counts. Another reporter can't satisfy or override it. - To have a required check decided by a specific reporter instead, bind it to that reporter in the branch protection rule through the API (`requiredCheckSources`, for example `{"ci/external": "ci-bot"}`, or `diffinery-actions` for Actions). - Results that agents report never satisfy a required check. - Of the reports that count, any failure fails the check, and any unfinished one keeps it pending. - Check runs created by Diffinery Actions can't be changed through the REST API. > **Important:** A job that is skipped by its `if:` condition, or that fails before it is launched (for example because no runner matches its `runs-on`), doesn't report a check. If such a job is a required check, the pull request shows `required check '<name>' has not run`. A check also appears only when its job is launched, so a required check for a job that `needs` others shows as not run until those finish. ## Concurrency Set `concurrency` at the top of a workflow to make runs in the same group exclusive: ```yaml concurrency: group: deploy-${{ github.ref }} cancel-in-progress: true ``` - The group is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. Groups are scoped to the repository, so two workflows can share a group. - With `cancel-in-progress: true`, creating a run cancels every unfinished run (queued or in progress) in the same group, with the reason `cancelled by a newer run in the same concurrency group`. - Without it, a new run waits while another run in the group is in progress, checking once a minute. After 6 hours of waiting it starts anyway. As on GitHub, a group has at most one waiting run: a new run cancels older runs still waiting in the group (not the one in progress), with the reason `Canceling since a higher priority waiting request for '<group>' exists`, so a deploy group runs the newest push next, never older ones one after another. A run cancelled while it waits finishes within a minute, with none of its jobs run. - A run or job on a protected branch is cancelled or replaced through its group only by runs on that same branch. A workflow on another branch that names the same group, `cancel-in-progress` or not, waits for it instead, so a branch can't cancel a protected branch's deploy. If the group expression fails to evaluate, the run has no concurrency group. ### Job concurrency `jobs.<id>.concurrency` limits a group to one running job at a time across the repository, whatever run or workflow the jobs belong to: ```yaml jobs: deploy: runs-on: ubuntu-latest concurrency: group: deploy-${{ matrix.env }} cancel-in-progress: false strategy: matrix: env: [staging, production] steps: - run: ./deploy.sh ${{ matrix.env }} ``` - The group is evaluated for each matrix combination, with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. - Without `cancel-in-progress`, a job whose group has a running job waits with the status `pending`. Only the newest pending job is kept: an older pending job of the group is cancelled. - With `cancel-in-progress: true`, the new job takes the group and cancels the group's running and pending jobs. - Jobs cancelled this way end as `cancelled` with the message `Canceling since a higher priority waiting request for '<group>' exists`. - When the running job finishes, the oldest pending job of the group starts, from whichever run it belongs to. ## Notifications When a run completes with `success` or `failure`, Diffinery notifies the person who triggered it (for a run an agent triggered, the agent's owner; for runs started by the scheduler or the merge queue, the repository's watchers). By default they get an email and a web notification when a run fails; in their notification settings they can turn off **Only failed runs** to get emails for successful runs too. Cancelled runs don't notify anyone. See [Notifications](https://diffinery.com/docs/accounts/notifications). ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | Nothing runs after a push | Check that the file is directly in `.github/workflows/` or `.diffinery/workflows/` and ends in `.yml` or `.yaml`, and that the `on:` filters match the branch, tag or paths. Run `diffinery workflow validate`. | | A run stays in progress though all its jobs finished, and newer runs in its concurrency group wait with no jobs | The run's orchestration lost track of it. Diffinery repairs it by itself: within 5 minutes of its last job the orchestration is signalled again, and by 30 minutes the run is finished, freeing the group. Nothing to do; re-run the workflow if it names jobs that never started. | | A run titled `Invalid workflow file: ...` appears | A workflow file in the pushed commit has errors. Its one job, **Invalid workflow file**, lists every error with its path and line. Pushes and merge groups create these runs; other events skip invalid files silently. | | A pull request's `pull_request` workflows don't run | The pull request conflicts with its base, so there is no test merge commit. Resolve the conflicts. `pull_request_target` workflows still run. | | `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | Hosted runners accept `ubuntu-*`, `linux`, `diffinery`, `diffinery-*`, `x64` and `amd64`. For other platforms, add `self-hosted` and start a runner with matching labels. See [Runners](https://diffinery.com/docs/actions/runners). | | A self-hosted job stays `waiting` | No runner with all the job's labels is polling. Start `diffinery runner` with those labels. After 6 hours the job times out. | | `The job was not started: this account has used its <n> hosted minutes for this month ...` | The account that owns the repository used its monthly hosted minutes. They renew on the 1st (UTC); until then use a self-hosted runner, or ask a site administrator to raise the quota. See [Account quotas](https://diffinery.com/docs/reference/limits#account-quotas). | | A hosted job stays queued, then fails with `The job waited 6 hours for a hosted runner (one of this account's hosted jobs to finish, or a free runner), and was not started.` | The account already runs as many hosted jobs at once as its quota allows (20 by default, at most 40), or every hosted runner of the instance was busy. Queued jobs start as running ones finish. See [Account quotas](https://diffinery.com/docs/reference/limits#account-quotas) and [hosted runner limits](https://diffinery.com/docs/reference/limits#actions). | | `The job was not started: Actions are suspended for this repository by a site administrator: <reason>.` (or `for every repository of <account>`) | A site administrator suspended Actions here. Nothing runs until they lift it, and a red banner on the Actions page shows the reason. See [Kill switches](https://diffinery.com/docs/admin/site-administration#kill-switches). | | `The job was not started: Hosted runners are paused on this instance by a site administrator: <reason>. Self-hosted runners still work.` | Hosted compute is paused on the whole instance. Use a self-hosted runner, or re-run once it is resumed. See [Kill switches](https://diffinery.com/docs/admin/site-administration#kill-switches). | | `Could not start a runner: ...` | The job's container failed to start with an error that trying again won't fix (passing platform errors are retried first; see [Limits](https://diffinery.com/docs/reference/limits)). Re-run the run. | | `The runner stopped unexpectedly (exit code <n>).` | The job's container exited before reporting a result. Re-run the run; if it repeats, check that the job doesn't exhaust memory or disk. | | `The platform is stopping this runner (a rollout replacing it with a new version, or the platform moving it). The job keeps running for up to 13 minutes; ...` | The platform sent the job's container its stop signal. Deploys leave running jobs alone, so this is rare and usually the platform moving the container. The signal reaches the running step as well, which then fails with "The platform stopped this step's processes while stopping the runner." A job that can only read starts again by itself; see Platform updates in [Runners](https://diffinery.com/docs/actions/runners#limits-and-timeouts). Otherwise re-run the job. | | `Error evaluating 'if': ...` | The job's `if:` expression failed at runtime. See [Expressions](https://diffinery.com/docs/actions/expressions). | | A step can't reach a secret | Secrets are configured in the repository or organization settings. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). | | `git push` from a job fails | The job's `GITHUB_TOKEN` needs `contents: write` (new repositories default to read-only tokens), can't change workflow files, and follows branch protection. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | Add the named scope to the job's `permissions:`. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | A job is `skipped` though its `if:` is true | A job above it in the `needs` chain failed, was cancelled or was skipped. Add a status function, for example `if: ${{ !cancelled() && !failure() }}`. | | `<branch> is not allowed to deploy to <environment> due to environment protection rules.` | The job sets `environment:` and the environment's branch policy doesn't allow the run's ref. Run it from an allowed branch or tag, or change the policy. See [Environments](https://diffinery.com/docs/actions/environments). | | `invalid environment name '<name>'` | The job's `environment` name is empty, longer than 255 characters, or contains `/`, `\` or control characters. | | An `on: release` workflow runs three times | Creating a published release sends `created`, `published` and `released`. Add `types: [published]`. See [release](https://diffinery.com/docs/actions/events#release). | | An `on: release` workflow doesn't run | Workflows don't run for draft releases, and the workflow file must exist at the commit the release's tag points to. | --- # Workflow syntax > Every workflow, job and step key Diffinery supports, how each behaves, which are ignored, and the exact errors you see when a file is invalid. Source: https://diffinery.com/docs/actions/workflow-syntax This page is the reference for the YAML in a Diffinery workflow file. Diffinery uses GitHub Actions workflow syntax, so most existing workflows run unchanged; for every key it says whether Diffinery supports it fully, partly or not at all, and what differs from GitHub. For when workflows run, see [Events that trigger workflows](https://diffinery.com/docs/actions/events). For `${{ }}` expressions and contexts, see [Expressions](https://diffinery.com/docs/actions/expressions). For how runs execute and how to watch them, see [Actions overview](https://diffinery.com/docs/actions/overview). ## Workflow files - Workflow files live directly in `.github/workflows/` or `.diffinery/workflows/` and end in `.yml` or `.yaml`. Files in subdirectories are ignored. - A file holds one YAML document whose top level is a mapping. Anchors, aliases and merge keys (`<<: *defaults`) are supported. - Keys are case-sensitive, as on GitHub. - A file that fails validation never runs. When a push or merge group commit contains an invalid file, Diffinery records a failed run titled `Invalid workflow file: <first error>` whose single job, **Invalid workflow file**, lists every error. A complete example that uses most features: ```yaml name: CI run-name: CI for ${{ github.ref_name }} by ${{ github.actor }} on: push: branches: [main, "release/**"] paths-ignore: ["docs/**"] pull_request: workflow_dispatch: permissions: contents: read packages: write env: NODE_ENV: test defaults: run: shell: bash concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run lint test: needs: lint name: Test (node ${{ matrix.node }}) runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false max-parallel: 2 matrix: node: [20, 22] include: - node: 22 coverage: true services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: ["5432:5432"] options: --health-cmd "pg_isready" --health-interval 5s --health-retries 10 outputs: version: ${{ steps.meta.outputs.version }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - id: meta run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" - run: npm ci - run: npm test env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres - if: ${{ matrix.coverage }} run: npm run coverage report: needs: test if: always() runs-on: ubuntu-latest steps: - run: echo "Tests ${{ needs.test.result }}, version ${{ needs.test.outputs.version }}" ``` ## Validate workflow files Check files before you push with the CLI. It uses the same parser as the server and needs no network access or token: ```sh diffinery workflow validate # every file in .github/workflows and .diffinery/workflows diffinery workflow validate .github/workflows/ci.yml diffinery workflow validate --json # machine-readable results ``` Each problem is printed as `<file>:<line>:<col>: error: <message> (<path>)` or `... warning: ...`, and the command exits with status 1 when any file has an error. - **Errors** make the file invalid; it doesn't run. - **Warnings** don't stop the file from running. You get one for every key Diffinery doesn't know (`unknown top-level key 'x'`, `unknown job key 'x'`, `unknown step key 'x'`, `unknown reusable-workflow job key 'x'`), for every event name it doesn't know (`unknown event 'x'`) and for unknown permission scopes (`unknown permission scope 'x'`). Unknown keys are ignored. The server doesn't show warnings, so run `validate` to see them. On the server, the **Actions** tab, `diffinery workflow list` and `GET /repos/{owner}/{repo}/actions/workflows` show each invalid workflow's errors as `<path>:<line>: <message>`, for example `jobs.build.steps[0]:12: step must have either 'run' or 'uses'`. ## Top-level keys | Key | Support | Notes | | --- | --- | --- | | `name` | Supported | Display name of the workflow. Default: the file's path. | | `run-name` | Supported | Title of each run. Evaluated with the `github`, `inputs` and `vars` contexts. | | `on` | Supported | The events that start the workflow. Required. See [Events that trigger workflows](https://diffinery.com/docs/actions/events). | | `permissions` | Supported | What the job token (`GITHUB_TOKEN`) may do, for jobs without their own `permissions`. See [permissions](#permissions). | | `env` | Supported | Environment variables for every job. | | `defaults` | Supported | `defaults.run.shell` and `defaults.run.working-directory` for every `run` step. | | `concurrency` | Supported | Groups runs and optionally cancels older ones. See [Concurrency](#concurrency). | | `jobs` | Supported | The jobs to run. Required, with at least one job. | Any other top-level key gives the warning `unknown top-level key '<key>'` and is ignored. ### name ```yaml name: CI ``` The name appears in the **Actions** sidebar, in run lists, in check names (`CI / test`) and as `github.workflow` and `GITHUB_WORKFLOW`. Without `name`, the file's path (for example `.github/workflows/ci.yml`) is used everywhere a name is needed. `on: workflow_run` matches other workflows by this name. ### run-name ```yaml run-name: Deploy ${{ inputs.environment }} by @${{ github.actor }} ``` Sets each run's title. It's evaluated once, when the run is created, with the `github`, `inputs` and `vars` contexts (as on GitHub). Without `run-name`, or if evaluation fails, the default title is used: the first line of the commit message for pushes, the pull request title for pull request events, `Merge group for #<n>: <title>` for merge groups, the release's name (or tag) for `release`, the `event_type` for `repository_dispatch`, and the workflow name for other events. ### on Required. Without it the file fails with `workflow is missing the 'on' trigger section`. It can be a single event name, a list of names, or a mapping of event names to their configuration: ```yaml on: push ``` ```yaml on: [push, pull_request] ``` ```yaml on: push: branches: [main] schedule: - cron: "0 3 * * 1-5" ``` An empty list or mapping fails with `'on' must define at least one event`. Event names, activity types, filters and the payload of each event are documented in [Events that trigger workflows](https://diffinery.com/docs/actions/events). ### env ```yaml env: NODE_ENV: production RETRIES: 3 VERBOSE: false ``` A mapping of variable names to strings, numbers or booleans (numbers and booleans become strings; an empty value becomes an empty string). Values may contain expressions; they're evaluated when each job starts. A value that is a list or mapping fails with `env.<name> must be a string, number or boolean`, and `env: ${{ fromJSON(...) }}` fails with `env must be a mapping (expressions producing whole mappings are not supported here)`. Workflow `env` is merged with each job's `env` (the job wins) and then with each step's `env` (the step wins). Variables named `GITHUB_*` or `RUNNER_*` that Diffinery sets can't be overridden; other defaults such as `CI` can. See [Default environment variables](https://diffinery.com/docs/actions/expressions#default-environment-variables). ### defaults ```yaml defaults: run: shell: bash working-directory: ./app ``` `defaults.run.shell` and `defaults.run.working-directory` apply to every `run` step that doesn't set its own. Job-level `defaults` override workflow-level ones key by key. They don't apply to `uses` steps or to the steps inside composite actions, as on GitHub. Values may contain expressions. ### permissions ```yaml permissions: contents: read id-token: write packages: write ``` `permissions` may be `read-all`, `write-all`, an empty mapping (`{}`), or a mapping of scopes to `read`, `write` or `none`. Recognised scopes: `actions`, `attestations`, `checks`, `contents`, `deployments`, `discussions`, `id-token`, `issues`, `models`, `packages`, `pages`, `pull-requests`, `repository-projects`, `security-events` and `statuses`. Another scope gives a warning; another level fails with `permission '<scope>' must be 'read', 'write' or 'none'`. They work as on GitHub: - A job's `permissions` replace the workflow's. A job with neither gets the repository's default: read-only (`contents` and `packages` read) for repositories created since the setting was added, read and write for older ones. - `read-all` gives every scope `read`; `write-all` gives every scope `write`, including `id-token`; `{}` gives nothing. - In a mapping, every scope you don't list is `none`. A job that pushes images and checks out a private repository needs both `packages: write` and `contents: read`. - Each REST endpoint and git operation needs its scope: `contents` for git fetch and push, files, releases and merges, `issues` for issues and labels, `pull-requests` for pull requests and reviews, `statuses`, `checks`, `deployments`, `actions` and so on. A request without it fails with `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: <level>)`. - `id-token: write` lets the job request OIDC tokens (see [OIDC](https://diffinery.com/docs/actions/oidc)); `id-token: read` grants nothing. `packages` decides what the token can do in the [container registry](https://diffinery.com/docs/packages/container-registry). Whatever `permissions` says, the token never administers the repository, never reads or changes secrets and variables, never creates or changes workflow files, and follows branch protection. [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions) has the full model, the scope each endpoint needs, and how an admin changes the repository default. ### concurrency ```yaml concurrency: deploy-production ``` ```yaml concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ``` `group` is evaluated when the run is created, with the `github`, `inputs` and `vars` contexts. `cancel-in-progress` is `true`, `false` (the default) or an expression evaluated with the same contexts. With `true`, a new run cancels every unfinished run in its group. With `false`, the new run waits while another run in the group is in progress, and cancels older runs still waiting there (as on GitHub, a group has one waiting run at most). Groups are shared by all workflows of the repository, but a run on a protected branch is only cancelled by runs on that same branch; runs from elsewhere wait for it. See [Concurrency](https://diffinery.com/docs/actions/overview#concurrency) for the differences from GitHub. A value that is neither a string nor a mapping with `group` fails with `concurrency must be a group string or a mapping with 'group'`. ## Jobs `jobs` is a mapping of job IDs to job definitions. It's required (`workflow is missing the 'jobs' section`), must be a mapping (`'jobs' must be a mapping of job id to job`) and must hold at least one job (`'jobs' must define at least one job`). A job ID must start with a letter or `_` and contain only letters, digits, `-` and `_`; otherwise the file fails with `job id '<id>' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`. ### Job keys | Key | Support | Notes | | --- | --- | --- | | `name` | Supported | Display name. May use `matrix`; evaluated for each matrix combination. | | `needs` | Supported | Jobs that must finish first. | | `if` | Supported | Condition evaluated before the job starts. | | `runs-on` | Supported | Hosted Linux labels or self-hosted labels, plus `diffinery-large`, `-medium`, `-small` or `-tiny` to pick a hosted [runner size](https://diffinery.com/docs/actions/runners#runner-sizes). May use `matrix`; evaluated for each matrix combination. `group` is ignored. | | `environment` | Supported | The deployment environment: its branch policy, required reviewers, wait timer, secrets and variables apply, and a deployment is recorded. See [environment](#environment). | | `outputs` | Supported | Values passed to dependent jobs through `needs`. | | `env` | Supported | Environment variables for every step of the job. | | `defaults` | Supported | `defaults.run` for the job's `run` steps. | | `steps` | Supported | Required for jobs that don't call a reusable workflow. | | `timeout-minutes` | Supported | Default and maximum 360. | | `strategy` | Supported | `matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`. | | `continue-on-error` | Supported | A failing job doesn't fail the run. See [continue-on-error (job)](#continue-on-error-job). | | `container` | Supported | Runs the steps in a Docker container. | | `services` | Supported | Starts service containers next to the job. | | `uses` | Supported | Calls a reusable workflow. | | `with` | Supported | Inputs for a reusable workflow. | | `secrets` | Supported | Secrets for a reusable workflow, or `inherit`. | | `permissions` | Supported | See [permissions (job)](#permissions-job). | | `concurrency` | Supported | One job of a group runs at a time. See [concurrency (job)](#concurrency-job). | | `snapshot` | Accepted, ignored | | Any other key gives the warning `unknown job key '<key>'`. ### name (job) ```yaml jobs: test: name: Test on node ${{ matrix.node }} ``` The job's display name in the UI and in check names (`<workflow name> / <job name>`). `GITHUB_JOB` and `github.job` stay the job's ID. It can use the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. Without `name`, a job is named after its ID, and each matrix job gets its values appended, for example `test (ubuntu-latest, 22)`. ### needs ```yaml jobs: build: runs-on: ubuntu-latest steps: [{ run: make }] test: needs: build runs-on: ubuntu-latest steps: [{ run: make test }] deploy: needs: [build, test] runs-on: ubuntu-latest steps: [{ run: make deploy }] ``` A job ID or list of job IDs. A dependent job starts only after the jobs it needs have finished, and reads their results and outputs through the `needs` context. Errors: - `Job '<id>' depends on unknown job '<other>'` - `Dependency cycle between jobs: a -> b -> a` A job starts as soon as every job it needs has finished, as on GitHub. It doesn't wait for unrelated jobs. By default a job runs only when every job above it in the `needs` chain succeeded (a job with `continue-on-error: true` that failed counts as a success). If any of them failed, was cancelled or was skipped, the job is skipped too, and so are the jobs that need it. `needs.<id>.result` is `skipped` for a skipped job. Use a status function in `if` to change that, for example `if: ${{ !cancelled() && !failure() }}` to run after a skipped job; see [Status check functions](https://diffinery.com/docs/actions/expressions#status-check-functions). ### if (job) ```yaml jobs: deploy: if: github.ref == 'refs/heads/main' && github.event_name == 'push' ``` The condition may be written with or without `${{ }}`. It's checked for syntax when the file is parsed (`Invalid expression: ...`) and evaluated just before the job starts, with the `github`, `needs`, `inputs`, `vars` and `env` (workflow-level) contexts. Without a status function the condition is treated as `success() && (<condition>)`, as on GitHub. A job whose condition is false is recorded as `skipped`. A runtime error fails the job with `Error evaluating 'if': <message>`. ### runs-on ```yaml runs-on: ubuntu-latest ``` ```yaml runs-on: [self-hosted, linux, gpu] ``` ```yaml runs-on: labels: [self-hosted, macos] ``` A label, a list of labels, or a mapping with `labels` (and `group`, which Diffinery ignores). Required for jobs that don't call a reusable workflow (`job '<id>' is missing 'runs-on'`). | Labels | Where the job runs | | --- | --- | | Only labels matching `ubuntu-*`, `linux`, `diffinery`, `diffinery-*`, `x64` or `amd64` (case-insensitive) | A Diffinery-hosted Linux container (Ubuntu 24.04, x64). `ubuntu-latest`, `ubuntu-22.04` and `ubuntu-24.04` all get the same image. `diffinery-large`, `diffinery-medium`, `diffinery-small` or `diffinery-tiny` picks its size; without one, the job's own earlier runs pick it. See [Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes). | | Any list that includes `self-hosted` | A self-hosted runner whose labels include every label in the list. See [Runners](https://diffinery.com/docs/actions/runners). | | Anything else (for example `windows-latest` or `macos-14` without `self-hosted`) | Nowhere. The job fails with `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | Labels may be expressions, evaluated for each matrix combination with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. An expression can give one label or a list of labels: ```yaml jobs: test: strategy: matrix: runner: [ubuntu-latest, [self-hosted, arm64]] runs-on: ${{ matrix.runner }} ``` If an expression fails, only that combination fails, with `Error evaluating the job for this matrix combination: <message>`. If it gives no labels, the job fails with `No Diffinery runner matches runs-on: (no labels). ...`. ### environment ```yaml environment: production ``` ```yaml environment: name: production url: https://www.example.com ``` The deployment environment the job deploys to: a name, or a mapping with `name` and an optional `url`. Anything else fails the file with `environment must be a name or a mapping with 'name'`. Environments, their branch policies, secrets and variables are managed through the REST API; see [Environments](https://diffinery.com/docs/actions/environments) for the full guide. ```yaml jobs: deploy: runs-on: ubuntu-latest environment: name: ${{ github.ref_name == 'main' && 'production' || 'staging' }} url: https://${{ github.ref_name == 'main' && 'www' || 'staging' }}.example.com steps: - uses: actions/checkout@v4 - run: ./deploy.sh env: API_KEY: ${{ secrets.DEPLOY_API_KEY }} # an environment secret REGION: ${{ vars.REGION }} # an environment variable ``` What happens when a job with `environment` starts: 1. `name` and `url` are evaluated with the `github`, `needs`, `inputs`, `vars` (repository variables), `matrix` and `strategy` contexts. A `url` that refers to `steps.` is ignored, because step outputs don't exist yet when the job starts. 2. The name is checked: it must be 1 to 255 characters without `/`, `\` or control characters, otherwise the job fails with `invalid environment name '<name>'`. Names are case-insensitive. An environment that doesn't exist yet is created, with no branch policy. 3. The environment's deployment branch policy is checked against the run's ref (`GITHUB_REF`). If the ref isn't allowed, the job fails before any step runs with `<branch or tag> is not allowed to deploy to <name> due to environment protection rules.` 4. A deployment is recorded for the run's commit and ref, with the task `deploy`, marked as a production deployment when the environment is named `production` (in any case), and a deployment status `in_progress` whose log URL is the job's log. When `url` is an `http://` or `https://` URL, it's stored as the status's environment URL. 5. The environment's secrets are added to the `secrets` context and its variables to the `vars` context. They win over repository and organization secrets and variables with the same names. `GITHUB_TOKEN` and `DIFFINERY_TOKEN` can't be overridden. When the job finishes, the deployment gets a final status: `success` when the job succeeded, `inactive` when it was cancelled, and `failure` otherwise (including timeouts). Each attempt of a re-run that runs the job again records its own deployment. When the job succeeds, older successful deployments to the same environment become `inactive` (newer ones are left alone, and production deployments are never made inactive this way). Read deployments with `GET /api/v3/repos/{owner}/{repo}/deployments`; webhooks subscribed to `deployment` get a `created` event for each deployment, and those subscribed to `deployment_status` get each status. Environment secrets aren't always handed out, even when the branch policy allows the ref: - Runs on pull request refs (`refs/pull/<n>/merge`, used by `pull_request` runs) never get environment secrets, because their code is whatever the pull request says. - Runs whose code came from an agent (an agent started the run, pushed the branch last, or opened the pull request, and re-runs and `workflow_run` followers of such runs) get environment secrets only when the environment's policy is "protected branches only". In those cases the job still runs, with the environment's variables but without its secrets (an unset secret is `''`). Differences from GitHub: - Required reviewers are people only (Diffinery has no teams), nobody bypasses the rules, and custom deployment protection rules aren't supported. See [Required reviewers and wait timers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers). - `url` can't use step outputs. - The environment doesn't change the job token's permissions. To create deployment statuses yourself, give the job `deployments: write`. A job that calls a reusable workflow (`uses`) can't set `environment` (you get the warning `unknown reusable-workflow job key 'environment'` and it's ignored). Set it on the jobs inside the called workflow instead. ### outputs ```yaml jobs: version: runs-on: ubuntu-latest outputs: tag: ${{ steps.tag.outputs.value }} steps: - id: tag run: echo "value=v$(date +%Y.%m.%d)" >> "$GITHUB_OUTPUT" release: needs: version runs-on: ubuntu-latest steps: - run: echo "Releasing ${{ needs.version.outputs.tag }}" ``` A mapping of names to strings, usually expressions over `steps`. They're evaluated after the job's last step, with every context available to steps. An output whose value contains a secret is dropped with the log warning `Skip output '<name>' since it may contain secret.`; one that fails to evaluate is dropped with `Failed to evaluate job output '<name>': <message>`. Outputs appear under **Outputs** on the job page. For a matrix job, the outputs of all its jobs are merged into `needs.<id>.outputs`; when two jobs set the same output, the one that was created later wins. ### env (job) Like the [top-level `env`](#env), for one job. Job values override workflow values. Values may use any context available to steps except `env` itself and `steps` (they're evaluated before the first step). ### defaults (job) Like the [top-level `defaults`](#defaults), for one job. Overrides the workflow's `defaults.run` key by key. ### steps (job) A list of steps, required for a job that doesn't call a reusable workflow (`job '<id>' must have a list of 'steps'`) and not empty (`job '<id>' must have at least one step`). See [Steps](#steps). ### timeout-minutes (job) ```yaml timeout-minutes: 20 ``` A number or an expression (which may use `matrix`; evaluated for each matrix combination). Default: 360. Values above 360 are capped at 360, the longest a job can run on Diffinery. When the time is up, the runner logs `The job running on runner has exceeded the maximum execution time of <n> minutes.`, stops the job's processes, marks the remaining steps cancelled, and the job fails with `The job exceeded the maximum execution time of <n> minutes.` ### strategy ```yaml strategy: fail-fast: true max-parallel: 3 matrix: os: [ubuntu-22.04, ubuntu-24.04] node: [20, 22] exclude: - os: ubuntu-22.04 node: 20 include: - node: 22 experimental: true - os: ubuntu-24.04 node: 23 ``` #### matrix `matrix` is a mapping of variables to lists. Diffinery creates one job for every combination, then applies `exclude` and `include` with GitHub's rules: 1. Every combination of the lists is built. 2. `exclude` entries remove every combination they match. An entry matches when each of its keys equals the combination's value (a partial match is enough). 3. Each `include` entry is merged into every remaining combination whose original values it doesn't change. It may add new keys and may overwrite values added by an earlier `include`, but never a value from the original lists. If it can't be merged into any combination, it becomes a new combination of its own. The example above produces `(ubuntu-22.04, 22, experimental)`, `(ubuntu-24.04, 20)`, `(ubuntu-24.04, 22, experimental)` and `(ubuntu-24.04, 23)`. Values are compared by their string form, so `18` equals `'18'`. - A matrix with only `include` produces exactly the `include` entries. - The whole matrix, any list, `include` or `exclude` can be a single `${{ }}` expression, for example `matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`. Such expressions are evaluated when the job starts, with the `github`, `needs`, `inputs`, `vars` and `env` contexts. - A matrix may produce at most 256 jobs. A larger one fails the job with `A matrix may produce at most 256 jobs.` - Before `exclude` is applied, the vectors may have at most 4,096 combinations, and `include` and `exclude` at most 256 entries each. A larger matrix is refused before it is expanded (at parse time, or at run time for one built with `fromJSON`). - The `matrix` and `strategy` contexts give each job its values (`strategy.job-index`, `strategy.job-total`, `strategy.fail-fast`, `strategy.max-parallel`). Parse and runtime errors: `matrix produces no job combinations`, `matrix.<key> must be a list (got <type>)`, `matrix.include must be a list of mappings`, `matrix.exclude[<n>] must be a mapping`, `matrix must be a mapping or a single ${{ }} expression`, `the matrix has <n> combinations before exclusions; at most 4096 are allowed (and a matrix may produce at most 256 jobs)`, `matrix.include has <n> entries; at most 256 are allowed`, and at runtime `Error expanding matrix: <message>`. #### fail-fast `true` (the default), `false` or an expression. With `true`, when one job of the matrix fails or times out (and doesn't have `continue-on-error: true`), its unfinished sibling jobs are stopped and marked `cancelled` with the reason `Cancelled because <job name> failed (fail-fast).` #### max-parallel A number or an expression. At most this many jobs of the matrix run at the same time; the others stay queued and start as running ones finish. Default: all at once. When `fail-fast` stops the matrix, queued jobs are cancelled too. ### continue-on-error (job) ```yaml jobs: test: runs-on: ubuntu-latest continue-on-error: ${{ matrix.experimental }} strategy: matrix: node: [20, 22] experimental: [false] include: - node: 24 experimental: true steps: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm ci && npm test ``` `true`, `false` or an expression, evaluated for each matrix combination (it may use `matrix` and `strategy`). When a job with `continue-on-error: true` fails or times out: - The job itself still shows as failed, but the run's conclusion isn't `failure` because of it. - It doesn't trigger `fail-fast`, so its matrix siblings keep running. - It counts as a success in `needs.<id>.result` and for the `success()` checks of the jobs that need it. ### container ```yaml container: image: node:22-bookworm credentials: username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_TOKEN }} env: NODE_ENV: test ports: [8080] volumes: ["/data:/data"] options: --cpus 2 ``` Runs every `run` step and JavaScript action of the job inside a container started from `image`. A string is short for `image:`. Keys: `image` (required), `credentials` (`username` and `password`, both required when present), `env`, `ports`, `volumes` and `options` (extra `docker run` flags; `--network` is rejected because every container of a job shares one network). Values may use expressions. Hosted runners have Docker; self-hosted runners need it installed. Details are in [Docker in workflows](https://diffinery.com/docs/actions/docker). ### services ```yaml services: redis: image: redis:7 ports: ["6379:6379"] options: --health-cmd "redis-cli ping" --health-interval 2s --health-retries 15 ``` A mapping of service names to the same keys as `container`. Each service starts before the first step, on the job's network, and Diffinery waits for its health check when the image or `options` define one. Steps in a job container reach a service by its name (`redis:6379`); steps on the runner reach it on `localhost` through its mapped ports. A service whose `image` evaluates to an empty string is skipped. See [Docker in workflows](https://diffinery.com/docs/actions/docker). ### uses, with and secrets (reusable workflows) ```yaml jobs: call-tests: uses: ./.github/workflows/tests.yml with: node-version: 22 secrets: inherit deploy: needs: call-tests uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 with: environment: production secrets: token: ${{ secrets.DEPLOY_TOKEN }} ``` A job with `uses` calls a reusable workflow instead of running steps. `uses` must be `./path/to/workflow.yml` (from the same commit) or `owner/repo/path/to/workflow.yml@ref` (from a repository on the same Diffinery instance); otherwise the file fails with `reusable workflow reference '<value>' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'`. Such a job may only have `name`, `uses`, `with`, `secrets`, `needs`, `if`, `permissions`, `concurrency` and `strategy`. `steps` and `runs-on` are errors (`a job that calls a reusable workflow ('uses') cannot have 'steps'`); other keys give the warning `unknown reusable-workflow job key '<key>'`. A `matrix` on a calling job isn't supported yet and fails the run with `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet`. Calls nest up to 4 levels deep, and one run can call at most 20 reusable workflows. Inputs, secrets, outputs and every error message are in [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). ### permissions (job) Same syntax as the [top-level `permissions`](#permissions). The job's value replaces the workflow's completely, as on GitHub: scopes the workflow grants but the job's mapping leaves out are `none` for this job. In a [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows), a job's permissions can't exceed the calling job's. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). ### concurrency (job) ```yaml jobs: deploy: runs-on: ubuntu-latest concurrency: group: deploy-${{ github.ref }} cancel-in-progress: true ``` Same syntax as the top-level `concurrency`. At most one job of a group runs at a time across the whole repository, whichever run or workflow it belongs to. `group` and `cancel-in-progress` are evaluated for each matrix combination, with the `github`, `needs`, `inputs`, `vars`, `matrix` and `strategy` contexts. - Without `cancel-in-progress`, a job waits with the status `pending` while another job of its group runs. Only the newest pending job is kept: a new pending job cancels the older one. - With `cancel-in-progress: true`, the new job starts and the group's running and pending jobs are cancelled. - A job cancelled by its group ends with the message `Canceling since a higher priority waiting request for '<group>' exists`. - When a running job finishes, the oldest pending job of the group starts. The concurrency of a job that calls a reusable workflow is ignored; set it on the jobs inside the called workflow. ## Steps Each item in `steps` is a mapping. A step runs either a shell script (`run`) or an action (`uses`), never both. | Key | Support | Notes | | --- | --- | --- | | `id` | Supported | Lets later steps read this step through `steps.<id>`. | | `if` | Supported | Condition evaluated before the step. | | `name` | Supported | Display name; may use expressions. | | `uses` | Supported | An action: `owner/repo@ref`, `owner/repo/path@ref`, `./path` or `docker://image`. | | `run` | Supported | A script run by `shell`. | | `shell` | Supported | `bash`, `sh`, `python`, `pwsh`, `powershell` or a custom command with `{0}`. `cmd` fails. | | `with` | Supported | Action inputs; scalar values only. | | `env` | Supported | Environment variables for this step. | | `continue-on-error` | Supported | `true`, `false` or an expression. | | `timeout-minutes` | Partly | A number works; an expression is accepted but ignored. | | `working-directory` | Supported | For `run` steps. | Any other key gives the warning `unknown step key '<key>'`. A step with neither `run` nor `uses` fails with `step must have either 'run' or 'uses'`; one with both fails with `step cannot have both 'run' and 'uses'`. ### id ```yaml - id: build run: echo "artifact=dist/app.tar.gz" >> "$GITHUB_OUTPUT" - run: echo "${{ steps.build.outputs.artifact }} ${{ steps.build.outcome }}" ``` Must start with a letter or `_` and contain only letters, digits, `-` and `_` (`step id '<id>' must start with a letter or '_' and contain only alphanumerics, '-' or '_'`), and be unique within the job (`duplicate step id '<id>'`). The step's ID is also `GITHUB_ACTION` while it runs (`__run` for a `run` step without an ID). ### if (step) ```yaml - if: failure() && steps.test.outcome == 'failure' run: ./collect-crash-logs.sh ``` With or without `${{ }}`. Evaluated just before the step, with every step context. Without a status function it's treated as `success() && (<condition>)`: the step is skipped once an earlier step has failed (and not set `continue-on-error`), or once the job is being cancelled. A skipped step's `outcome` and `conclusion` are `skipped`. An evaluation error fails the step with `Error evaluating 'if' condition: <message>`. ### name (step) The step's title in the log. Without it, Diffinery uses `Run <uses>` or `Run <first line of the script>`, as on GitHub. ### uses ```yaml - uses: actions/checkout@v4 # an action repository - uses: actions/cache/restore@v4 # an action in a subdirectory - uses: ./.github/actions/setup # a local action in the workspace - uses: docker://alpine:3.20 # a Docker image with: entrypoint: /bin/sh args: -c "echo hello" ``` - `actions/checkout`, `actions/cache` (and `cache/restore`, `cache/save`), `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python` and `actions/setup-go` are built into the runner for their current major versions. A commit SHA, a branch, a newer major version, an input the built-in doesn't know, or `DIFFINERY_BUILTINS: false` in `env` runs the real action instead (see [Built-in actions](https://diffinery.com/docs/actions/compatibility#built-in-actions)). - Other `owner/repo@ref` actions are downloaded from GitHub and run with their `action.yml`: JavaScript (`node*`), composite and Docker actions are supported. JavaScript actions run with the runner's Node.js 22 whatever `runs.using` says. - `./path` actions are read from the workspace, so check out the repository first. Otherwise the step fails with `Can't find 'action.yml' or 'action.yaml' under '<dir>'. Did you forget to run actions/checkout before running your local action?` - `docker://image` runs a container; `with.args` and `with.entrypoint` set its arguments and entrypoint. - `actions/github-script` and other actions that call the API work with the job token, within its `permissions`. - A reference without `@ref` fails with `Expected format {owner}/{repo}[/path]@ref. Actual '<value>'`. An unknown action or version fails with `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found`. What each built-in action supports is in [GitHub Actions compatibility](https://diffinery.com/docs/actions/compatibility) and [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). ### with ```yaml - uses: actions/setup-node@v4 with: node-version: 22 cache: npm ``` A mapping of input names to strings, numbers or booleans; lists and mappings fail with `<path> must be a string, number or boolean`. Values may contain expressions. JavaScript and Docker actions receive each input as `INPUT_<NAME>` (upper case, spaces replaced by `_`). Defaults from the action's `action.yml` fill in missing inputs; a missing required input without a default logs `Input required and not supplied: <name>`. ### run ```yaml - name: Build run: | npm ci npm run build ``` The script is written to a temporary file and run with the step's shell from `working-directory` (default: `GITHUB_WORKSPACE`). Expressions in it are replaced before it runs. The step fails when the script exits with a non-zero status: `Process completed with exit code <n>.` ### shell | Value | Command | Notes | | --- | --- | --- | | (not set) | `bash --noprofile --norc -eo pipefail {0}` | Falls back to `sh -e {0}` when `bash` isn't installed (for example in an Alpine job container). | | `bash` | `bash --noprofile --norc -eo pipefail {0}` | | | `sh` | `sh -e {0}` | | | `python` | `python {0}` | Uses `python3` when there is no `python`. | | `pwsh` | `pwsh -command ". '{0}'"` | PowerShell isn't installed on hosted runners; use a job container or self-hosted runner that has it. | | `powershell` | `powershell -command ". '{0}'"` | Uses `pwsh` when there is no `powershell`. | | `cmd` | | Fails with ``The `cmd` shell is not supported on Linux runners.`` | | A command containing `{0}` | As written, with `{0}` replaced by the script's path | For example `node {0}` or `perl {0}`. The script file gets `.py`, `.ps1`, `.js` or `.sh` depending on the command. | Anything else fails with `Invalid shell option '<value>'. Shell must be a valid built-in (bash, sh, python, pwsh) or a format string containing '{0}'`. A shell that isn't installed fails with `<command>: command not found. The shell '<value>' is not available on this runner.` (or `in the job container`). > **Note:** On GitHub, a step without `shell` runs `bash -e {0}`, and only an explicit `shell: bash` adds `--noprofile --norc -o pipefail`. Diffinery always uses the stricter form, so a pipeline like `false | true` fails a step even when `shell` isn't set. ### env (step) ```yaml - run: ./deploy.sh env: API_TOKEN: ${{ secrets.API_TOKEN }} TARGET: staging ``` Variables for this step only, on top of the workflow's, the job's and those added with `$GITHUB_ENV`. A step's values can refer to its earlier keys through `env`. ### continue-on-error (step) ```yaml - run: npm run flaky-e2e continue-on-error: true ``` `true`, `false` or an expression that evaluates to `true` or `false`. When the step fails, its `outcome` is `failure` but its `conclusion` is `success`, the log shows `Step failed but continue-on-error is set; continuing.`, and later steps run as if it had succeeded. ### timeout-minutes (step) ```yaml - run: npm test timeout-minutes: 10 ``` A number of minutes (fractions allowed). When it's up, the step's processes are stopped and the step fails with `The step has timed out after <n> minutes.` Without it, only the job's timeout applies. > **Note:** The parser accepts an expression here, but Diffinery currently ignores it and the step runs without its own timeout. Use a plain number. ### working-directory ```yaml - run: npm test working-directory: packages/api ``` The directory a `run` step starts in, relative to `GITHUB_WORKSPACE` unless absolute. It must exist: otherwise the step fails with `An error occurred trying to start process '<shell>' with working directory '<dir>'. No such file or directory`. ## Workflow commands Steps talk to the runner by printing lines in the form `::command key=value,key=value::message` to standard output or standard error, as on GitHub. | Command | Effect | | --- | --- | | `::add-mask::<value>` | Masks the value as `***` in every later log line. Values shorter than 3 characters are not masked. | | `::group::<title>` and `::endgroup::` | Folds the lines between them under a title in the log. | | `::error file=<f>,line=<n>,title=<t>::<message>` | Logs an error. When the step fails, the last error it printed becomes the job's failure reason. | | `::warning file=<f>,line=<n>,title=<t>::<message>` | Logs a warning. | | `::notice file=<f>,line=<n>,title=<t>::<message>` | Logs a notice. | | `::debug::<message>` | Logged only when debug logging is on: set the secret or variable `ACTIONS_STEP_DEBUG` to `true`. Debug logging also sets `RUNNER_DEBUG=1` and `runner.debug`. | | `::set-output name=<name>::<value>` | Sets a step output. Deprecated on GitHub but still works; prefer `$GITHUB_OUTPUT`. | | `::save-state name=<name>::<value>` | Saves state for an action's post step (`STATE_<name>`). | | `::stop-commands::<token>` ... `::<token>::` | Stops processing commands until the token line, so you can print untrusted text. | | `::echo::on` and `::echo::off` | Echoes processed commands into the log. | | `::set-env` and `::add-path` | Disabled, as on GitHub. They log ``The `set-env` command is disabled. Please upgrade to using Environment Files.`` | Annotations (`error`, `warning`, `notice`) are shown in the log with their file, line and title; only `file`, `line` and `title` are read, and other properties such as `col` are ignored. Diffinery doesn't attach annotations to files in pull requests. ## Environment files Each step gets fresh, empty files whose paths are in these variables. Write to them to pass data on: | Variable | Format | Effect | | --- | --- | --- | | `GITHUB_OUTPUT` | `name=value`, or `name<<EOF` ... `EOF` for multi-line values | Sets the step's outputs (`steps.<id>.outputs.<name>`). | | `GITHUB_ENV` | `NAME=value`, or `NAME<<EOF` ... `EOF` | Sets environment variables for the following steps. `NODE_OPTIONS` values containing `--require` or `-r` are ignored. | | `GITHUB_PATH` | One directory per line | Prepends the directories to `PATH` for the following steps. | | `GITHUB_STEP_SUMMARY` | Markdown | Adds to the job summary shown on the job page. | | `GITHUB_STATE` | `name=value` | Saves state for an action's post step. | ```yaml - id: info run: | echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" { echo "CHANGELOG<<EOF" git log -5 --oneline echo "EOF" } >> "$GITHUB_ENV" echo "$HOME/.local/bin" >> "$GITHUB_PATH" ``` A malformed line logs `Unable to process file command '<env|path|output|state>' successfully: Invalid file command format: <line>` (or `Matching delimiter not found '<delimiter>'`) and marks an error. ## Masking Every secret's value (each line separately for multi-line secrets), the job's token and every value passed to `::add-mask::` are replaced by `***` in log lines, step names, the job summary and the failure reason. Values shorter than 3 characters are never masked, because masking them would make logs unreadable. A job output that contains a masked value is dropped. ## Validation errors The most common errors from the parser, with what to do: | Message | Fix | | --- | --- | | `YAML syntax error: <detail>` | Fix the YAML at the reported line (indentation, quotes, tabs). | | `workflow file is empty` | Add content, or delete the file. | | `workflow is missing the 'on' trigger section` | Add `on:`. | | `workflow is missing the 'jobs' section` | Add `jobs:`. | | `job '<id>' is missing 'runs-on'` | Add `runs-on: ubuntu-latest`. | | `job '<id>' must have a list of 'steps'` | Add `steps:` with at least one step. | | `step must have either 'run' or 'uses'` | Give the step a `run` or `uses`. | | `'branches' and 'branches-ignore' cannot be used together for the same event` | Use one of them, with `!` patterns for exceptions. The same applies to `tags`/`tags-ignore` and `paths`/`paths-ignore`. | | `Invalid cron: <detail>` | Fix the `schedule` cron expression; see [schedule](https://diffinery.com/docs/actions/events#schedule). | | `Unclosed expression: '${{' at offset <n> has no matching '}}'` | Close the expression. | | `Invalid expression ${{ <expr> }}: <detail>` | Fix the expression syntax; see [Expressions](https://diffinery.com/docs/actions/expressions). | | `<path> must be a mapping (expressions producing whole mappings are not supported here)` | Write `env`, `with` or `outputs` as a mapping and use expressions in the values. | | `input '<name>' of type 'choice' must define 'options'` | Add `options:` to the `workflow_dispatch` input. | | `input '<name>' must declare a 'type'` | `workflow_call` inputs need a `type`. | ## Limits | Limit | Value | | --- | --- | | Job timeout | 360 minutes (default and maximum) | | Jobs per matrix | 256 | | Reusable workflow nesting | 4 levels | | Reusable workflows called per run | 20 | | Composite action nesting | 10 levels | | Characters per log line | 16,384 (longer lines are cut and end with `…`) | See [Limits](https://diffinery.com/docs/reference/limits) for the rest of Diffinery's limits. --- # Events that trigger workflows > Every event that can start a Diffinery workflow, its activity types and filters, the commit and ref it runs on, and its github.event payload. Source: https://diffinery.com/docs/actions/events The `on:` section of a workflow says which events start it. Diffinery supports the GitHub Actions events that matter for CI and automation: pushes, pull requests, merge queue groups, manual dispatch, schedules, other workflows' runs, published releases, external `repository_dispatch` events and reusable workflow calls. This page lists each one with its activity types, filters, the `GITHUB_SHA` and `GITHUB_REF` its runs get, and its `github.event` payload. ## Supported events | Event | Fires when | Activity types | Filters | | --- | --- | --- | --- | | [`push`](#push) | A branch or tag is created or updated | | `branches`, `branches-ignore`, `tags`, `tags-ignore`, `paths`, `paths-ignore` | | [`pull_request`](#pull_request) | A pull request is opened, updated, edited, reopened, closed, marked ready for review or converted to a draft | `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review`, `converted_to_draft` | `types`, `branches`, `branches-ignore`, `paths`, `paths-ignore` | | [`pull_request_target`](#pull_request_target) | Same as `pull_request`, but runs the base branch's workflow | Same as `pull_request` | Same as `pull_request` | | [`merge_group`](#merge_group) | The merge queue builds a merge group | `checks_requested` | `types`, `branches`, `branches-ignore` | | [`workflow_dispatch`](#workflow_dispatch) | Someone starts the workflow from the UI, CLI, API or MCP | | `inputs` | | [`schedule`](#schedule) | A cron schedule is due | | `cron` | | [`workflow_run`](#workflow_run) | A run of another named workflow is requested or completes | `requested`, `completed` | `workflows`, `types`, `branches`, `branches-ignore` | | [`release`](#release) | A release is published, unpublished, edited or deleted | `published`, `unpublished`, `created`, `edited`, `deleted`, `prereleased`, `released` | `types` | | [`repository_dispatch`](#repository_dispatch) | Someone calls `POST /repos/{owner}/{repo}/dispatches` | Any `event_type` you choose | `types` | | [`workflow_call`](#workflow_call) | Another workflow calls this one as a reusable workflow | | `inputs`, `outputs`, `secrets` | ### Other GitHub events The other GitHub event names (`issues`, `issue_comment`, `create`, `delete`, `pull_request_review`, `check_run`, `status`, `deployment`, `deployment_status`, `label`, `watch` and the rest of GitHub's list) are accepted in `on:` without an error or warning, so workflows that list them still parse, but Diffinery never starts a run for them. A name that isn't a GitHub event at all gives the warning `unknown event '<name>'` in `diffinery workflow validate`. ## Writing on ```yaml on: push # one event, no configuration ``` ```yaml on: [push, pull_request] # several events, no configuration ``` ```yaml on: # events with configuration push: branches: [main] tags: ["v*"] pull_request: types: [opened, synchronize, reopened, closed] paths: ["src/**"] workflow_dispatch: ``` An event's configuration must be a mapping (`on.<event> must be a mapping`), except `schedule`, which is a list. Each workflow file whose `on:` matches an event gets one run for it. ## Filter patterns Branch, tag and path filters use GitHub's filter pattern syntax: | Pattern | Matches | | --- | --- | | `*` | Any characters except `/`. `feature/*` matches `feature/login` but not `feature/a/b`. | | `**` | Any characters, including `/`. `feature/**` matches `feature/a/b`. | | `**/` at the start | Any directories, or none. `**/README.md` matches `README.md` and `docs/README.md`. | | `?` | Zero or one of the preceding character. `v1?` matches `v` and `v1`. | | `+` | One or more of the preceding character. `v1+` matches `v1` and `v111`. | | `[abc]`, `[0-9]` | One character from the set or range. | | `\` | Escapes the next character, for example `\*` for a literal `*`. | | `!pattern` | In a list, excludes what earlier patterns matched. | Patterns match the whole name: a branch name without `refs/heads/`, a tag name without `refs/tags/`, or a file path relative to the repository root. Quote patterns that start with `*`, `!` or `[` in YAML. A list is read in order, and the last pattern that matches decides: a value is included when it matches a positive pattern and no later `!` pattern excludes it. A `!` pattern can only exclude what an earlier pattern included, so a list of only `!` patterns matches nothing. ```yaml on: push: branches: - "releases/**" - "!releases/**-alpha" ``` This runs for `releases/10` and `releases/beta/mona`, but not `releases/10-alpha`. ### Include and ignore filters - `branches` runs the workflow only for matching branches; `branches-ignore` runs it for every branch except matching ones. The same goes for `tags` and `tags-ignore`, and `paths` and `paths-ignore`. - You can't use a filter and its `-ignore` form for the same event. The file fails with `'branches' and 'branches-ignore' cannot be used together for the same event` (or the same message for `tags` or `paths`). Use `!` patterns in the positive form instead. ### Path filters - With `paths`, the workflow runs when at least one changed file matches the list. - With `paths-ignore`, the workflow runs unless every changed file matches the list. - For a push, the changed files are the difference between the commit before the push and the pushed commit. When the push creates a new branch, or the previous commit isn't known, Diffinery can't compute the change and path filters pass. (GitHub compares a new branch with the default branch instead.) - For a pull request, the changed files are those the pull request changes, compared with the merge base of the head and the base branch. When a merged pull request is closed, path filters pass. - Path filters are never evaluated for tag pushes. - When branch or tag filters and path filters are both set, both must pass. ## push Runs when you push to a branch or tag, including pushes that create a branch or tag, force pushes, pushes by agents, and merges made through the web UI, CLI, API or merge queue. Deleting a branch or tag doesn't start runs. ```yaml on: push: branches: [main, "release/**"] tags: ["v*.*.*"] paths-ignore: ["docs/**", "**.md"] ``` How `branches` and `tags` combine, as on GitHub: | Filters set | Branch pushes | Tag pushes | | --- | --- | --- | | None | Run | Run | | Only `branches` or `branches-ignore` | Run when the branch matches | Don't run | | Only `tags` or `tags-ignore` | Don't run | Run when the tag matches | | Both kinds | Run when the branch matches | Run when the tag matches | | Only `paths` or `paths-ignore` | Run when the paths match | Run (paths aren't checked for tags) | The workflow file is read from the pushed commit, so a push that changes a workflow runs the new version. When a merge queue builds a merge group, it pushes a branch named `gh-readonly-queue/<base>/pr-<n>-<head sha>`, and `push` workflows run on it as on GitHub. Add `branches-ignore: ["gh-readonly-queue/**"]` (or list only the branches you want) to avoid running twice. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The pushed commit | | `GITHUB_REF` | The pushed ref, `refs/heads/<branch>` or `refs/tags/<tag>` | Payload (`github.event`): | Field | Value | | --- | --- | | `ref` | The pushed ref | | `before` | The commit the ref pointed to before the push (`0000000000000000000000000000000000000000` for a new ref) | | `after` | The pushed commit | | `created`, `deleted`, `forced` | Whether the push created the ref, deleted it (always `false` for runs) or was a force push | | `head_commit` | `id`, `message`, `timestamp` (committer time) and `author` (`name`, `email`) of the pushed commit | | `commits` | A list with the pushed commit only (`id`, `message`). GitHub lists up to 20 commits. | | `pusher` | `name`: the login of the account that pushed, and `email`: `null` | | `sender` | See [The sender object](#the-sender-object) | | `repository` | See [The repository object](#the-repository-object) | ## pull_request Runs for activity on a pull request, using the workflow and code of the pull request's test merge commit: the head branch merged into the current base branch, as on GitHub. ```yaml on: pull_request: types: [opened, synchronize, reopened] branches: [main] paths: ["src/**", "package.json"] ``` | Type | Fires when | | --- | --- | | `opened` | A pull request is created (drafts included) | | `synchronize` | New commits are pushed to the head branch | | `reopened` | A closed pull request is reopened | | `closed` | A pull request is closed or merged. Check `github.event.pull_request.merged` to tell them apart. | | `edited` | The title or description changed. Not for label changes, and not when the text saved is the same. | | `ready_for_review` | A draft is marked ready for review: **Ready for review** in the web UI, `diffinery pr ready`, or `PATCH /repos/{owner}/{repo}/pulls/{number}` with `"draft": false` | | `converted_to_draft` | An open pull request is turned back into a draft: `diffinery pr ready --undo`, or `PATCH` with `"draft": true` | Without `types`, the workflow runs for `opened`, `synchronize` and `reopened`, as on GitHub. GitHub's other types (`labeled`, `assigned`, `review_requested`, `locked` and so on) are accepted in `types` but never fire on Diffinery. A common use is to skip expensive jobs while a pull request is a draft and run them once it's ready: ```yaml on: pull_request: types: [opened, synchronize, reopened, ready_for_review] jobs: e2e: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run e2e ``` For `edited`, the payload has no `changes` object with the old title or body (GitHub sends one). `branches` and `branches-ignore` filter on the pull request's base branch. - The run checks out and runs `refs/pull/<n>/merge`. You can fetch that ref and `refs/pull/<n>/head` with git, but not push to them. - While a pull request conflicts with its base, there is no test merge commit and its `pull_request` workflows don't run. `pull_request_target` workflows still do. - For `closed` on a merged pull request, the run uses the merge commit instead. - The run's checks are reported on the pull request's head commit, so they appear on the pull request. - Every pull request comes from a branch of the same repository, so `pull_request` runs get the repository's secrets, like `push` runs. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The test merge commit (for a merged pull request's `closed` event, the merge commit) | | `GITHUB_REF` | `refs/pull/<n>/merge` | | `GITHUB_REF_NAME` | `<n>/merge` | | `GITHUB_HEAD_REF` | The head branch | | `GITHUB_BASE_REF` | The base branch | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | The activity type | | `number` | The pull request number | | `pull_request.number`, `pull_request.id` | The pull request number (both) | | `pull_request.node_id` | An opaque ID | | `pull_request.state` | `open` or `closed` | | `pull_request.locked` | `false` | | `pull_request.title`, `pull_request.body` | Title and description | | `pull_request.draft`, `pull_request.merged` | Booleans | | `pull_request.merged_at` | When it merged, or `null` | | `pull_request.merged_by` | `{ "login": ... }` or `null` | | `pull_request.merge_commit_sha` | The merge commit once merged, otherwise the test merge commit (or `null` while it conflicts) | | `pull_request.user` | The author, as a user object (see [The sender object](#the-sender-object)) | | `pull_request.labels` | `[{ "name": ... }]` | | `pull_request.assignees` | `[{ "login": ... }]` | | `pull_request.requested_reviewers` | `[]` | | `pull_request.author_association` | `MEMBER` | | `pull_request.head` | `label` (`<owner>:<branch>`), `ref` (branch), `sha` (head commit), `user` (the repository owner) and `repo` (a repository object) | | `pull_request.base` | The same fields for the base branch | | `pull_request.html_url` | `https://diffinery.com/acme/website/pull/<n>` | | `pull_request.url` | `https://diffinery.com/api/v3/repos/acme/website/pulls/<n>` | | `pull_request.diff_url`, `pull_request.patch_url` | `https://diffinery.com/acme/website/pull/<n>.diff` and `.patch` | | `pull_request.issue_url`, `pull_request.commits_url`, `pull_request.review_comments_url`, `pull_request.comments_url` | API URLs, as on GitHub | | `repository` | See [The repository object](#the-repository-object) | | `organization` | For repositories owned by an organization; see [The repository object](#the-repository-object) | | `sender` | Whoever caused the event; see [The sender object](#the-sender-object) | ## pull_request_target Same activity types, filters and payload as `pull_request`, but the run uses the base branch's own workflow file and code: the base branch's current commit. A pull request therefore can't change what runs with the repository's secrets. Check out the pull request's code (for example with `ref: ${{ github.event.pull_request.head.sha }}`) only if you trust it. ```yaml on: pull_request_target: types: [opened, reopened] jobs: welcome: runs-on: ubuntu-latest steps: - run: echo "Thanks for pull request #${{ github.event.number }}, ${{ github.event.pull_request.user.login }}" ``` Unlike `pull_request`, it also runs while the pull request conflicts with its base. Checks are reported on the pull request's head commit. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The base branch's current commit | | `GITHUB_REF` | `refs/heads/<base branch>` | | `GITHUB_HEAD_REF`, `GITHUB_BASE_REF` | The head and base branches | ## merge_group Runs when the merge queue builds a merge group for a pull request: a commit holding the base branch, every pull request ahead in the queue, and this pull request's changes. The queue merges the pull request once the group's required checks pass. See [Merge queue](https://diffinery.com/docs/collaboration/merge-queue). ```yaml on: pull_request: merge_group: types: [checks_requested] branches: [main] ``` - The only activity type is `checks_requested`, which is also the default. - `branches` and `branches-ignore` filter on the queue's base branch. - The queue reads the required checks from the merge group commit, so the workflows that produce them must run on it: list `merge_group` next to `pull_request`. (A `push` workflow without branch filters also runs there.) If a required check never reports, the pull request leaves the queue when the queue's check timeout expires. - `push` workflows also run on the merge group branch; see [push](#push). - A merge group commit with an invalid workflow file gets an `Invalid workflow file` run, like a push. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The merge group commit | | `GITHUB_REF` | `refs/heads/gh-readonly-queue/<base>/pr-<n>-<head sha>` | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | `checks_requested` | | `merge_group.head_sha` | The merge group commit | | `merge_group.head_ref` | The merge group ref | | `merge_group.base_sha` | The commit the group was built on | | `merge_group.base_ref` | `refs/heads/<base>` | | `merge_group.head_commit` | `id`, `tree_id`, `message`, `timestamp`, `author` and `committer` (`name`, `email`) | | `repository`, `sender` | As for other events | The run's title is `Merge group for #<n>: <pull request title>`. ## workflow_dispatch Lets people and agents with write access start the workflow on demand, on a branch or tag they choose, with typed inputs. How to start a run from the web UI, the CLI, the API and MCP is in [Actions overview](https://diffinery.com/docs/actions/overview#start-a-workflow-manually). ```yaml on: workflow_dispatch: inputs: environment: description: Target environment type: choice options: [staging, production] default: staging version: description: Version to deploy required: true replicas: type: number default: 2 dry-run: type: boolean default: false target: type: environment ``` Each input can have: | Key | Meaning | | --- | --- | | `description` | Label in the **Run workflow** form (the input's name is shown under it). | | `required` | `true` makes the input mandatory: an empty or missing value without a default is rejected with `input '<name>' is required`. Must be a boolean. | | `default` | Used when no value is given. | | `type` | `string` (the default), `boolean`, `number`, `choice` or `environment`. Another value fails with `input '<name>': invalid type '<type>' (expected one of string, boolean, number, choice, environment)`. | | `options` | For `choice`: the allowed values. A `choice` input without options fails with `input '<name>' of type 'choice' must define 'options'`. A value outside the list is rejected with `input '<name>' must be one of <options>`. | | `deprecationMessage` | Accepted and ignored. | Values are converted by type: `boolean` inputs become `true` when the value is `true` or `"true"` and `false` otherwise; `number` inputs become numbers; `string`, `choice` and `environment` inputs stay as given. Values for inputs the workflow doesn't declare are dropped. An `environment` input is a plain text field in the **Run workflow** form, and its value isn't checked against the repository's [environments](https://diffinery.com/docs/actions/environments). Read inputs with the `inputs` context (`${{ inputs.version }}`). `github.event.inputs` holds the same values with the same types; on GitHub, `github.event.inputs` values are always strings. The workflow must exist and have a `workflow_dispatch` trigger at the chosen ref. A ref given without `refs/` is a branch if a branch with that name exists, and a tag otherwise. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The latest commit of the chosen ref | | `GITHUB_REF` | `refs/heads/<branch>` or `refs/tags/<tag>` | Payload (`github.event`): `inputs`, `ref` (the full ref), `workflow` (the workflow file's path), `repository` and `sender`. Each accepted dispatch also sends a `workflow_dispatch` [webhook](https://diffinery.com/docs/reference/webhook-events#workflow_dispatch) event with the same `ref`, `workflow` and `inputs`, to webhooks subscribed to it. ## schedule Runs the workflow on a schedule, in UTC. ```yaml on: schedule: - cron: "30 5 * * 1-5" # 05:30 UTC, Monday to Friday - cron: "0 */6 * * *" # every 6 hours ``` `schedule` is a list of entries with a `cron` string. Each cron has 5 fields: | Field | Values | | --- | --- | | Minute | `0`-`59` | | Hour | `0`-`23` | | Day of month | `1`-`31` | | Month | `1`-`12` or `JAN`-`DEC` | | Day of week | `0`-`6` (Sunday is `0`; `7` also means Sunday) or `SUN`-`SAT` | Each field accepts `*`, a value, a range (`1-5`), a list (`1,15,30`) and a step (`*/15`, `0-30/10`, or `5/15` meaning from 5 to the end every 15). Names are case-insensitive. As in standard cron, when both day of month and day of week are restricted, a day matches if either one matches. How schedules run: - **Granularity is 5 minutes.** Diffinery checks schedules every 5 minutes and starts every schedule that was due at any minute since the last check. A schedule for `2 * * * *` starts a few minutes after :02, and one for `* * * * *` runs once per check, not every minute. GitHub's minimum interval is also 5 minutes. A check that comes late is caught up by the next one (up to an hour back), and a schedule starts once however many of its minutes it covers. - **Only the default branch counts.** Schedules are read from the workflow files on the default branch, and refreshed on every push to it. A `schedule` in a workflow on another branch does nothing. - Each run uses the workflow file on the default branch at that moment, and only if it still has that exact cron string. - Schedules of archived repositories don't run. - The run's actor is `diffinery-scheduler`. Notifications for scheduled runs go to the repository's watchers. Invalid crons fail the file with `Invalid cron: <detail>`, for example `Invalid cron: Cron '0 3 * *' must have exactly 5 fields (minute hour day-of-month month day-of-week)`, `minute value 60 out of range 0-59 in cron '<cron>'` or `Invalid step '0' in minute field of cron '<cron>'`. `schedule` must be a list (`on.schedule must be a list of { cron: '...' } entries`) and each entry needs a `cron` string (`on.schedule entries must have a 'cron' string`). | Variable | Value | | --- | --- | | `GITHUB_SHA` | The latest commit on the default branch | | `GITHUB_REF` | `refs/heads/<default branch>` | Payload (`github.event`): `schedule` (the cron string) and `repository`. ## workflow_run Runs after a run of another workflow is requested or completes. Use it to post-process another workflow's results with access to secrets, or to chain workflows. ```yaml name: Report on: workflow_run: workflows: [CI] types: [completed] branches: [main] jobs: report: if: ${{ github.event.workflow_run.conclusion == 'failure' }} runs-on: ubuntu-latest steps: - run: echo "CI failed on ${{ github.event.workflow_run.head_branch }}: ${{ github.event.workflow_run.html_url }}" ``` - `workflows` is required: a list of workflow names (each workflow's `name`, or its path when it has none). A `workflow_run` trigger without `workflows` never fires. - `types` can be `requested` (when the other run is created, including re-runs) and `completed`. Without `types`, both fire. GitHub's `in_progress` type never fires on Diffinery. - `branches` and `branches-ignore` filter on the other run's head branch: the pushed branch, or the pull request's head branch for pull request runs. - Like on GitHub, the triggered workflow is read from the default branch and runs there, whatever branch the other run was on. - Chains stop after three levels: if A triggers B, B triggers C and C triggers D, D's runs don't trigger anything. - If the run it follows came from an agent (for example CI on a branch an agent pushed), the `workflow_run` run counts as agent code too, although it runs on the default branch: its `GITHUB_TOKEN` acts as an agent and environment secrets follow the agent rules. A follower acts on what the other run produced (artifacts, outputs), so it can't be used to carry agent-written results past the agent guardrails. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent). | Variable | Value | | --- | --- | | `GITHUB_SHA` | The latest commit on the default branch | | `GITHUB_REF` | `refs/heads/<default branch>` | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | `requested` or `completed` | | `workflow_run.id` | The other run's number, which is also its `github.run_id` and what `download-artifact`'s `run-id` takes | | `workflow_run.name`, `workflow_run.path` | The other workflow's name and file path | | `workflow_run.display_title` | The other run's title | | `workflow_run.run_number`, `workflow_run.run_attempt` | Its number and attempt | | `workflow_run.event` | The event that started it | | `workflow_run.status`, `workflow_run.conclusion` | `queued` and `null` for `requested`; `completed` and the conclusion for `completed` | | `workflow_run.head_branch`, `workflow_run.head_sha` | The branch and the commit it reported checks on | | `workflow_run.actor`, `workflow_run.triggering_actor` | `{ "login": ... }` | | `workflow_run.pull_requests` | `[{ "number": ... }]` for pull request runs, otherwise empty | | `workflow_run.created_at`, `workflow_run.updated_at` | Timestamps | | `workflow_run.html_url` | Link to the other run | | `workflow` | `name` and `path` of the other workflow | | `repository`, `sender` | As for other events | ## release Runs when a release changes: when it's published, unpublished, edited or deleted. Use it to build and attach binaries, publish packages or deploy when you publish a release. Releases are managed on the repository's **Releases** page (`https://diffinery.com/acme/website/releases`), with `POST /api/v3/repos/{owner}/{repo}/releases` and `PATCH /api/v3/repos/{owner}/{repo}/releases/{release_id}`, or with the `create_release` and `update_release` MCP tools. All of them need write access. See [Releases](https://diffinery.com/docs/collaboration/releases). ```yaml name: Release on: release: types: [published] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make dist VERSION="${{ github.event.release.tag_name }}" ``` Each change sends one or more activity types, and a workflow with `on: release` and no `types` runs once for each of them. Creating a published release sends three (`created`, `published` and `released`), so you get three runs. Set `types` to the ones you need; `types: [published]` is the usual choice. | Change | Activity types sent to workflows | | --- | --- | | Create a release that isn't a draft (**Publish release** on a new release) | `created`, `published`, then `released` (or `prereleased` for a pre-release) | | Publish a draft (**Publish release** on a draft) | `published`, then `released` or `prereleased` | | Turn a published release back into a draft (`"draft": true` in the API) | `unpublished` | | Edit a published release (**Update release**) | `edited`, plus `released` or `prereleased` when the pre-release setting changed | | Delete a published release | `deleted` | | Create, edit or delete a draft | None. Webhooks still get `created`, `edited` and `deleted` for drafts, but workflows don't run for draft releases, as on GitHub. | How a release run is set up: - The run uses the commit the release's tag points to (the commit, for an annotated tag), and the workflow file at that commit. If the tag doesn't exist (for example it was deleted after the release was published), no run starts. - Publishing a release whose tag doesn't exist yet creates the tag at `target_commitish` (the default branch when it's not set). Creating the tag is a push of that tag, so `push` workflows that match the tag run too, as on GitHub. Use either `on: push: tags` or `on: release` for the same job, not both. - The run's title is the release's name, or its tag name when the release has no name. - The run's actor is whoever made the change. Its checks are reported on the tagged commit. - `types` is the only filter. `branches`, `tags` and `paths` aren't applied to `release`; check `github.event.release.tag_name` or `github.ref_name` in an `if:` instead. - Invalid workflow files are skipped without an `Invalid workflow file` run. | Variable | Value | | --- | --- | | `GITHUB_SHA` | The commit the release's tag points to | | `GITHUB_REF` | `refs/tags/<tag>` | | `GITHUB_REF_NAME` | The tag name | | `GITHUB_REF_TYPE` | `tag` | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | The activity type | | `release.id` | The release's ID, a number | | `release.tag_name` | The tag, for example `v1.2.0` | | `release.target_commitish` | The branch or commit the tag was created from | | `release.name`, `release.body` | Title and release notes, or `null` when empty | | `release.draft`, `release.prerelease` | Booleans | | `release.latest` | Whether this is the repository's latest release (a Diffinery field) | | `release.immutable` | Always `false` | | `release.created_at`, `release.updated_at`, `release.published_at` | Timestamps (`published_at` is `null` for drafts) | | `release.author` | The user object of whoever created the release (`login`, `id`, `type` and URLs) | | `release.assets` | The release's assets, each with `id`, `name`, `label`, `content_type`, `size`, `download_count`, `browser_download_url` and `url` | | `release.html_url` | `https://diffinery.com/acme/website/releases/tag/<tag>` | | `release.url`, `release.assets_url`, `release.upload_url` | API URLs. Upload assets to `upload_url` (with the `{?name,label}` template removed). | | `release.tarball_url`, `release.zipball_url` | Source archives of the tag | | `release.node_id`, `release.reactions` | As on GitHub | | `repository` | See [The repository object](#the-repository-object) | | `sender` | See [The sender object](#the-sender-object) | Uploading an asset needs `contents: write`. Give the job that permission and use its `GITHUB_TOKEN`, or use a personal or agent token stored as a secret (see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions)): ```yaml on: release: types: [published] jobs: upload: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: tar czf dist.tar.gz src - name: Attach dist.tar.gz to the release env: TOKEN: ${{ secrets.RELEASE_TOKEN }} UPLOAD_URL: ${{ github.event.release.upload_url }} run: | curl -fsS -X POST "${UPLOAD_URL%\{*}?name=dist.tar.gz" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/gzip" \ --data-binary @dist.tar.gz ``` ## repository_dispatch Runs when someone calls the `dispatches` endpoint of the repository with an event type. Use it to start workflows from outside Diffinery: another system's CI, a deployment tool, a chat bot or an agent. ```yaml on: repository_dispatch: types: [deploy, rollback] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./deploy.sh "${{ github.event.action }}" "${{ github.event.client_payload.version }}" ``` - `types` lists the `event_type` values that start the workflow. Without `types`, every `event_type` does. - Like on GitHub, the workflow is read from the default branch's latest commit and runs there. - The run's title is the `event_type`, and its actor is the account whose token sent the request. - Invalid workflow files are skipped without an `Invalid workflow file` run. ### Send a repository_dispatch event You need write access to the repository, with a token that has the write scope (see [Tokens](https://diffinery.com/docs/accounts/tokens)). People and agents can both send events. ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/dispatches \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"event_type": "deploy", "client_payload": {"version": "1.4.2", "environment": "staging"}}' ``` | Field | Required | Meaning | | --- | --- | --- | | `event_type` | Yes | A name for the event, 1 to 100 characters. Workflows match it against `types`, and it becomes `github.event.action`. | | `client_payload` | No | A JSON object with at most 10 top-level properties and at most 64 KB as JSON. Becomes `github.event.client_payload`. Default: `{}`. | The answer is `204 No Content`, as on GitHub, also when no workflow listens for the event type. The request doesn't return the runs it started; find them with `GET /api/v3/repos/acme/website/actions/runs?event=repository_dispatch` or `diffinery run list -R acme/website -e repository_dispatch`. Webhooks subscribed to `repository_dispatch` also get the event. | Status | Message | Cause | | --- | --- | --- | | 403 | `write access required` | Your role or token scope is below write. | | 403 | `repository is archived (read-only)` | Archived repositories don't accept events. | | 422 | `event_type is required (at most 100 characters)` | `event_type` is missing, empty, not a string or too long. | | 422 | `client_payload must be an object` | `client_payload` is an array or a scalar. | | 422 | `client_payload can have at most 10 top-level properties` | Nest the values in fewer top-level properties. | | 422 | `client_payload is too large` | The payload is over 64 KB as JSON. | | 422 | `the repository is empty` | The default branch has no commits yet. | | Variable | Value | | --- | --- | | `GITHUB_SHA` | The latest commit on the default branch | | `GITHUB_REF` | `refs/heads/<default branch>` | Payload (`github.event`): | Field | Value | | --- | --- | | `action` | The `event_type` | | `branch` | The default branch | | `client_payload` | The `client_payload` you sent | | `repository` | See [The repository object](#the-repository-object) | | `sender` | The account whose token sent the event; see [The sender object](#the-sender-object) | ## workflow_call Makes the workflow reusable: other workflows call it from a job with `uses:`. It never starts a run on its own. ```yaml on: workflow_call: inputs: node-version: type: string default: "22" secrets: npm-token: required: true outputs: version: value: ${{ jobs.build.outputs.version }} ``` `inputs` take `type` (required: `string`, `boolean` or `number`), `description`, `required` and `default`. `secrets` take `description` and `required`. `outputs` each need a `value`. Runs of a called workflow use the caller's event, ref, commit and `github` context. The full reference is in [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). ## GITHUB_SHA and GITHUB_REF by event | Event | `GITHUB_SHA` | `GITHUB_REF` | Workflow file read from | | --- | --- | --- | --- | | `push` | Pushed commit | Pushed branch or tag | Pushed commit | | `pull_request` | Test merge commit (merge commit once merged) | `refs/pull/<n>/merge` | Test merge commit | | `pull_request_target` | Base branch's latest commit | `refs/heads/<base>` | Base branch's latest commit | | `merge_group` | Merge group commit | `refs/heads/gh-readonly-queue/<base>/pr-<n>-<head sha>` | Merge group commit | | `workflow_dispatch` | Latest commit of the chosen ref | Chosen branch or tag | Chosen ref | | `schedule` | Default branch's latest commit | Default branch | Default branch | | `workflow_run` | Default branch's latest commit | Default branch | Default branch | | `release` | The commit the release's tag points to | `refs/tags/<tag>` | The tagged commit | | `repository_dispatch` | Default branch's latest commit | Default branch | Default branch | A re-run keeps the original run's commit, ref, event and payload. ## The repository object Every payload has a `repository` field with GitHub's full repository object, the same one the REST API returns for `GET /repos/{owner}/{repo}`. The fields workflows read most: | Field | Value | | --- | --- | | `id` | The repository's ID, a number | | `node_id` | An opaque ID | | `name` | `website` | | `full_name` | `acme/website` | | `private` | `true` for a private repository, `false` for a public one | | `visibility` | `public` or `private` | | `owner` | A user object for the owner (`login`, a numeric `id`, `type`: `User` or `Organization`, and URLs) | | `default_branch` | `main` | | `archived` | Whether the repository is archived | | `topics` | The repository's topics | | `html_url` | `https://diffinery.com/acme/website` | | `clone_url` | `https://diffinery.com/acme/website.git` | | `url` and the `*_url` templates | API URLs under `https://diffinery.com/api/v3/repos/acme/website` | | `created_at`, `updated_at`, `pushed_at` | Timestamps | For a repository owned by an organization, payloads also have an `organization` field with `login`, `id` (a number), `node_id`, `url`, `repos_url`, `avatar_url` and `description`. The payload is also written to the file at `GITHUB_EVENT_PATH` for tools that read it from disk, such as `@actions/github`'s `context.payload` in `actions/github-script`. ## The sender object `sender` is the account that caused the event, as a GitHub user object: | Field | Value | | --- | --- | | `login` | The account's login | | `id` | A number derived from the account's ID, like GitHub's numeric user IDs | | `node_id` | An opaque ID | | `type` | `User` for people, `Bot` for agents and Diffinery itself (for example `diffinery-scheduler`) | | `site_admin` | `false` | | `avatar_url`, `html_url`, `url` | The avatar, the profile page and the API URL | ## Runs started from inside a workflow As on GitHub, an event caused by a job's own `GITHUB_TOKEN` doesn't start new workflow runs, so workflows can't trigger themselves in a loop. A push, a pull request or a release made with a job token starts nothing. The two exceptions, as on GitHub, are `workflow_dispatch` (a job with `permissions: actions: write` can start another workflow with `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`) and `repository_dispatch` (a job with `contents: write` can send `POST /repos/{owner}/{repo}/dispatches`). Events caused by a personal or agent token stored as a secret are treated like any other event from that account and do start workflows: a push starts `push` workflows, a published release starts `release` workflows, and a dispatch starts `repository_dispatch` workflows. ```yaml - name: Ask the deploy workflow to run env: TOKEN: ${{ secrets.DISPATCH_TOKEN }} run: | curl -fsS -X POST "${{ github.api_url }}/repos/${{ github.repository }}/dispatches" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"event_type": "deploy", "client_payload": {"sha": "${{ github.sha }}"}}' ``` Guard against loops, for example with `paths-ignore`, `types`, or an `if:` that checks `github.actor` or the commit message. ## Differences from GitHub - Only the events in [Supported events](#supported-events) start runs. Other GitHub event names parse but never fire. - `pull_request` and `pull_request_target` fire for `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review` and `converted_to_draft` only, and the `edited` payload has no `changes` object. - `workflow_run` fires for `requested` and `completed`, not `in_progress`, and requires `workflows`. - `release` workflows get no `branches`, `tags` or `paths` filtering, and a published release whose tag doesn't exist any more starts no runs. - `deployment` and `deployment_status` events go to webhooks (a job with `environment:` sends both), but don't start workflow runs. - `push` payloads list only the pushed commit in `commits`. - Path filters pass when a push creates a branch. - `github.event.inputs` keeps input types. - `schedule` is checked every 5 minutes; a schedule runs within about 5 minutes after it is due. --- # Expressions and contexts > The ${{ }} expression language in Diffinery workflows: operators, functions, every context property Diffinery sets, and the default environment variables. Source: https://diffinery.com/docs/actions/expressions Expressions let a workflow compute values and conditions at run time: `${{ github.ref_name }}`, `if: failure()`, `${{ fromJSON(needs.plan.outputs.matrix) }}`. Diffinery implements the GitHub Actions expression language, with the same syntax, operators, functions and type rules, and fills in the same contexts. This page is the reference for all of it, including the default environment variables every step gets and every difference from GitHub. ## Syntax An expression is written between `${{` and `}}`: ```yaml env: IMAGE: diffinery.com/acme/website:${{ github.sha }} steps: - run: echo "Building ${{ github.ref_name }} for ${{ github.actor }}" ``` - In most values, each `${{ }}` is replaced by its result converted to a string, and the text around it is kept. - When a value is exactly one expression (for example `matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`), the result keeps its type: a list stays a list, a number stays a number. - In `if:` conditions of jobs and steps, `${{ }}` is optional: `if: github.event_name == 'push'` is the same as `if: ${{ github.event_name == 'push' }}`. - `}}` inside a single-quoted string doesn't end the expression. To write a literal `${{`, use `${{ '${{' }}`. - Expressions in `on:` are not evaluated. Every expression in a file is checked for syntax when the file is parsed. A mistake fails the file with `Invalid expression ${{ <expr> }}: <detail>` (or `Invalid expression: <detail>` in an `if:` without `${{ }}`), and an unclosed one with `Unclosed expression: '${{' at offset <n> has no matching '}}'`. Run `diffinery workflow validate` to catch these before you push. > **Note:** In an `if:` that mixes text and expressions, such as `if: ${{ a }} && ${{ b }}`, the result is a string, which is true unless it's empty. Write the whole condition as one expression instead: `if: a && b`. GitHub behaves the same way. ## Literals | Type | Examples | Notes | | --- | --- | --- | | null | `null` | | | Boolean | `true`, `false` | | | Number | `42`, `-1`, `3.14`, `2e3`, `0xff`, `0o17`, `NaN`, `Infinity` | Any JSON number, plus hexadecimal (`0x`) and octal (`0o`). A leading `-` or `+` is part of the number: there is no arithmetic and no unary minus. | | String | `'hello'`, `'it''s'` | Single quotes only; write `''` for a quote. Double quotes fail with `Unexpected double quote; string literals use single quotes`. | ## Operators | Operator | Meaning | | --- | --- | | `( )` | Grouping | | `[ ]` | Index: `github.event['pull_request']`, `matrix.include[0]` | | `.` | Property: `github.event.pull_request.title` | | `.*`, `[*]` | Object filter (see below) | | `!` | Not | | `<`, `<=`, `>`, `>=` | Comparison | | `==`, `!=` | Equality | | `&&` | And | The or operator is `||`. Precedence, from highest to lowest: property access and indexing, `!`, comparisons, equality, `&&`, `||`. There are no arithmetic operators, as on GitHub. `&&` and `||` return one of their operands, not a boolean: `a && b` is `a` when `a` is falsy and `b` otherwise; `a || b` is `a` when `a` is truthy and `b` otherwise. That gives you defaults: `${{ inputs.tag || 'latest' }}`. ### Property access - Property names are case-insensitive: `github.EVENT_NAME` reads `github.event_name`. - Names may contain `-`: `steps.build-app.outputs.path`, `matrix.node-version`. - Reading a property of something that doesn't have it gives `null` instead of an error, so `github.event.pull_request.title` is `null` on a push. An unknown context name is `null` too. - Array indexes start at 0. An index out of range gives `null`. ### Object filters `.*` collects a property from every item of an array or object and gives a list: ```yaml - run: echo "${{ toJSON(github.event.pull_request.labels.*.name) }}" - if: contains(github.event.pull_request.labels.*.name, 'deploy') run: ./deploy.sh ``` ### Types and conversion Comparisons convert values the way GitHub does: | Rule | Example | | --- | --- | | Values of different types are compared as numbers. `null` is `0`, `true` is `1`, `false` is `0`, `''` is `0`, numeric strings are their number, and anything else is `NaN`. | `'1' == 1` is true, `null == 0` is true | | Strings are compared case-insensitively. | `'Main' == 'main'` is true | | A comparison with `NaN` is false. | `'abc' < 1` is false | | Arrays and objects are only equal to themselves. | `fromJSON('[1]') == fromJSON('[1]')` is false | Truthiness: `false`, `0`, `-0`, `NaN`, `''` and `null` are falsy; everything else, including every array and object, is truthy. When a result is inserted into text: `null` becomes `''`, booleans become `true` or `false`, whole numbers have no decimals, and arrays and objects become JSON (indented by 2 spaces). ## Functions Function names are case-insensitive. Calling an unknown function fails with `Unrecognized function '<name>'`, and a wrong number of arguments with `Function '<name>' expects <n> argument(s) but got <m>`. | Function | Returns | | --- | --- | | `contains(search, item)` | If `search` is an array: `true` when an element equals `item` (with the equality rules above). Otherwise `true` when the string `search` contains `item`, ignoring case. | | `startsWith(string, prefix)` | `true` when `string` starts with `prefix`, ignoring case. | | `endsWith(string, suffix)` | `true` when `string` ends with `suffix`, ignoring case. | | `format(string, value0, value1, ...)` | `string` with `{0}`, `{1}`, ... replaced by the values. Write `{{` and `}}` for literal braces. | | `join(array, separator)` | The elements joined with `separator` (default `,`). A non-array is converted to a string. | | `toJSON(value)` | `value` as JSON, indented by 2 spaces. | | `fromJSON(string)` | The parsed value. Use it to turn strings into booleans, numbers, lists or objects. | | `hashFiles(pattern, ...)` | A SHA-256 hash of the matching files, or `''` when none match (see below). | | `success()`, `failure()`, `cancelled()`, `always()` | Status checks (see below). | Examples: ```yaml - run: echo "${{ format('{0}/{1}@{2}', github.repository_owner, github.event.repository.name, github.sha) }}" - if: startsWith(github.ref, 'refs/tags/v') run: ./release.sh - uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} ``` Runtime errors from functions fail the step or job that evaluates them: | Message | Cause | | --- | --- | | `fromJSON: invalid JSON "<text>": <detail>` | The argument isn't valid JSON. | | `format: invalid format string '<string>'` | A `{` isn't followed by a number and `}`. | | `format: invalid format string '<string>' (unescaped '}')` | A lone `}`. Write `}}`. | | `format: placeholder {<n>} has no matching argument in '<string>'` | Not enough values. | ### hashFiles `hashFiles` takes one or more glob patterns relative to `GITHUB_WORKSPACE` (`*`, `**`, `?`, `[...]`, and `!pattern` to exclude). It hashes each matching file with SHA-256, then hashes those hashes together, and returns the hex digest. Files outside the workspace and `.git` directories are ignored. It returns `''` when no file matches. `hashFiles` only works in places evaluated on the runner, that is in steps, job `env`, `container`, `services` and `outputs`. Elsewhere (job `if`, `runs-on`, `strategy`, `name`) it returns `''`. ### Status check functions In a job's `if:`, the functions look at all of the job's ancestors: the jobs in its `needs`, the jobs those need, and so on up the chain, as on GitHub. | Function | True when | | --- | --- | | `success()` | The run hasn't been cancelled and every ancestor succeeded. A skipped or cancelled ancestor makes it false. | | `failure()` | An ancestor failed or timed out. | | `cancelled()` | The run has been cancelled. | | `always()` | Always. | A matrix job's result combines its legs: any failed leg fails it, otherwise any cancelled leg cancels it, and it's skipped when every leg was skipped. A leg that failed with `continue-on-error: true` counts as a success. A reusable workflow call's result combines the jobs inside it the same way. In a step's `if:`: | Function | True when | | --- | --- | | `success()` | No earlier step of the job failed (steps with `continue-on-error: true` don't count) and the job isn't being cancelled or timing out. | | `failure()` | An earlier step failed, and the job isn't being cancelled or timing out. | | `cancelled()` | The job is being cancelled or has hit its timeout. | | `always()` | Always. | When a job on a Diffinery-hosted runner is cancelled or hits its `timeout-minutes`, the step that is running stops, and then, as on GitHub, the remaining steps whose `if:` is true still run: `if: always()`, `if: cancelled()` and post steps such as the cache save with `save-always`. They have 5 minutes together. Use them to clean up what the job created, for example a test environment or a temporary repository. Cancelling the run a second time stops them at once. The job still ends as `cancelled` (or failed, after a timeout). An `if:` that doesn't call any of these is treated as `success() && (<condition>)`. So `if: github.ref == 'refs/heads/main'` is skipped after a failure, while `if: always() && github.ref == 'refs/heads/main'` still runs. Inside a composite action, the functions look at the action's own steps. ```yaml jobs: test: runs-on: ubuntu-latest steps: - run: npm test - if: failure() run: echo "::error::Tests failed" notify: needs: test if: always() runs-on: ubuntu-latest steps: - run: echo "test finished with ${{ needs.test.result }}" ``` Because a job's default `if:` is `success()`, a skipped job skips every job that depends on it, directly or further down the chain. To run a job anyway, give it a status function, for example `if: always()` or `if: ${{ !cancelled() && needs.build.result != 'failure' }}`: ```yaml jobs: build: if: github.event_name == 'push' runs-on: ubuntu-latest steps: - run: make test: needs: build # skipped when build is skipped runs-on: ubuntu-latest steps: - run: make test report: needs: test if: ${{ !cancelled() }} # runs even when build and test were skipped runs-on: ubuntu-latest steps: - run: echo "test result was ${{ needs.test.result }}" ``` ## Contexts ### Where each context is available Some values are evaluated by Diffinery before a job starts, and the rest by the runner while the job runs, so not every context is available everywhere: | Where | Available contexts | | --- | --- | | `run-name`, top-level `concurrency` | `github`, `inputs`, `vars` | | `jobs.<id>.if` | `github`, `needs`, `inputs`, `vars`, `env` (workflow-level values, unevaluated), status functions | | `jobs.<id>.strategy` | `github`, `needs`, `inputs`, `vars`, `env` | | `jobs.<id>.name`, `jobs.<id>.runs-on`, `jobs.<id>.timeout-minutes`, `jobs.<id>.continue-on-error`, `jobs.<id>.concurrency` | `github`, `needs`, `inputs`, `vars`, `env`, `matrix`, `strategy`. These are evaluated for each matrix combination. | | `jobs.<id>.environment` (`name` and `url`) | `github`, `needs`, `inputs`, `vars`, `matrix`, `strategy`. A `url` that refers to `steps.` is ignored. | | `jobs.<id>.with` (reusable workflow call) | `github`, `needs`, `inputs`, `vars` | | `jobs.<id>.secrets` (reusable workflow call) | `github`, `needs`, `inputs`, `vars`, `secrets` | | `jobs.<id>.env`, `container`, `services` | `github`, `needs`, `inputs`, `vars`, `secrets`, `matrix`, `strategy`, `runner`, `job` | | Steps: `if`, `name`, `uses`, `run`, `with`, `env`, `shell`, `working-directory`, `continue-on-error`, `timeout-minutes` | `github`, `env`, `vars`, `secrets`, `matrix`, `needs`, `steps`, `runner`, `inputs`, `job`, `strategy` | | `jobs.<id>.outputs` | Same as steps | | `on.workflow_call.outputs` | `github`, `jobs`, `inputs`, `vars` | In places evaluated before the job starts, `github.token` is empty and `github.workspace` isn't set, and `vars` and `secrets` hold only repository (and organization) values: a job's [environment](https://diffinery.com/docs/actions/workflow-syntax#environment) variables and secrets are added when the job starts, so they're available in steps, `jobs.<id>.env`, `container`, `services` and `outputs`, but not in `jobs.<id>.if`, `runs-on`, `name` or `environment` itself. ### github Information about the run and the event. Most properties are also environment variables (see [Default environment variables](#default-environment-variables)). | Property | Value | | --- | --- | | `github.event_name` | The event that started the run, for example `push`, `pull_request`, `release` or `repository_dispatch` | | `github.event` | The event payload. See [Events that trigger workflows](https://diffinery.com/docs/actions/events) for each event's fields, for example `github.event.release.tag_name` for `release` and `github.event.client_payload` for `repository_dispatch`. | | `github.sha` | The commit the run checks out. See the table in [Events that trigger workflows](https://diffinery.com/docs/actions/events#github_sha-and-github_ref-by-event). | | `github.ref` | The full ref, for example `refs/heads/main`, `refs/tags/v1.2.0` (also for `release` runs) or `refs/pull/7/merge` | | `github.ref_name` | The short ref: `main`, `v1.2.0`, or `7/merge` for pull requests | | `github.ref_type` | `tag` for tags, `branch` otherwise | | `github.ref_protected` | Always `false` | | `github.head_ref` | The pull request's head branch for `pull_request` and `pull_request_target`, otherwise `''` | | `github.base_ref` | The pull request's base branch for `pull_request` and `pull_request_target`, otherwise `''` | | `github.repository` | `acme/website` | | `github.repository_id` | The repository's ID | | `github.repository_owner` | `acme` | | `github.repositoryUrl` | `https://diffinery.com/acme/website.git` | | `github.actor` | The login of the account that triggered the run (for a re-run, who re-ran it): for `release`, whoever published, edited or deleted the release; for `repository_dispatch`, the account whose token sent the event. Scheduled runs use `diffinery-scheduler`; merge queue groups use the login of whoever queued the pull request. | | `github.triggering_actor` | Same as `github.actor` | | `github.run_id` | The run number, as a string, for example `128`. It stays the same on a re-run, and the REST API accepts it wherever a run ID goes, so actions that parse it as a number work. | | `github.run_number` | The run's number, as a string | | `github.run_attempt` | `1`, or higher for re-runs, as a string | | `github.workflow` | The workflow's `name`, or its path when it has none | | `github.workflow_ref` | `acme/website/.github/workflows/ci.yml@refs/heads/main` | | `github.job` | The job's ID (inside a reusable workflow, its ID in that workflow) | | `github.server_url` | `https://diffinery.com` | | `github.api_url` | `https://diffinery.com/api/v3` | | `github.graphql_url` | `https://diffinery.com/api/graphql`. The job's token can't use the [GraphQL API](https://diffinery.com/docs/reference/graphql-api) yet | | `github.token` | The job's token (same as `secrets.GITHUB_TOKEN`) | | `github.workspace` | The workspace directory, `GITHUB_WORKSPACE` | | `github.retention_days` | `90` | | `github.secret_source` | `Actions` | | `github.action` | `''` | | `github.action_path` | Inside a composite action: the action's directory. Otherwise `''`. | | `github.action_repository`, `github.action_ref` | Inside a composite action: the action's repository and ref. | GitHub properties Diffinery doesn't set include `github.actor_id`, `github.repository_owner_id`, `github.workflow_sha`, `github.event_path` (use the `GITHUB_EVENT_PATH` variable), `github.env`, `github.path`, `github.output`, `github.state` and `github.step_summary` (use the environment variables of the same names). > **Note:** `github.run_number` counts runs of all workflows in the repository, not each workflow separately as on GitHub. Because `github.run_id` is the run number, the two are always equal. ### env Environment variables set in the workflow: the workflow's `env`, the job's `env`, the step's `env` and anything earlier steps wrote to `$GITHUB_ENV`. It doesn't include the default variables such as `GITHUB_SHA`; read those with `github.*` or in the shell. ```yaml env: STAGE: production jobs: deploy: runs-on: ubuntu-latest steps: - if: env.STAGE == 'production' run: echo "Deploying to $STAGE" ``` ### vars The repository's variables, set under **Settings** > **Secrets and variables** or with `PUT /repos/{owner}/{repo}/actions/variables/{name}`. In a job with `environment:`, the environment's variables are added once the job starts and win over repository variables with the same name. Names are stored in upper case, and lookups ignore case. An unset variable is `''`. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables) and [Environments](https://diffinery.com/docs/actions/environments). ### secrets | Property | Value | | --- | --- | | `secrets.<NAME>` | A repository secret, or an organization secret shared with the repository (by its `visibility`; see [Organization secrets](https://diffinery.com/docs/actions/secrets-and-variables#organization-secrets)). A repository secret wins over an organization secret with the same name. In a job with `environment:`, the environment's secrets win over both (pull request runs, and some runs whose code came from an agent, don't get environment secrets; see [environment](https://diffinery.com/docs/actions/workflow-syntax#environment)). | | `secrets.GITHUB_TOKEN` | The job's token. What it may do follows the job's `permissions:`; see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | `secrets.DIFFINERY_TOKEN` | The same job token, under a Diffinery name. | Secret names are stored in upper case and lookups ignore case; an unset secret is `''`. Inside a reusable workflow, `secrets` holds only what the caller passed (or everything with `secrets: inherit`), plus the two tokens. Secret values are masked in logs. ### matrix The values of the current matrix combination, for example `matrix.os` and `matrix.node`. Empty for jobs without a matrix. ### strategy | Property | Value | | --- | --- | | `strategy.fail-fast` | Whether `fail-fast` is on | | `strategy.job-index` | This job's position in the matrix, from 0 | | `strategy.job-total` | How many jobs the matrix produced | | `strategy.max-parallel` | The effective `max-parallel` | ### needs For each job in the job's `needs`: | Property | Value | | --- | --- | | `needs.<job_id>.result` | `success`, `failure`, `cancelled` or `skipped`. For a matrix job: `failure` if any of its jobs failed or timed out, otherwise `cancelled` if any was cancelled, `skipped` if all were skipped, otherwise `success`. | | `needs.<job_id>.outputs.<name>` | The job's outputs (strings). For a matrix job, the merged outputs of all its jobs. For a reusable workflow call, the called workflow's `on.workflow_call.outputs`. | ### steps For each earlier step with an `id` in the same job (or the same composite action): | Property | Value | | --- | --- | | `steps.<id>.outputs.<name>` | Outputs the step set with `$GITHUB_OUTPUT` or `::set-output` | | `steps.<id>.outcome` | `success`, `failure`, `cancelled` or `skipped`, before `continue-on-error` | | `steps.<id>.conclusion` | The same, after `continue-on-error` (a failed step with `continue-on-error: true` is `success`) | ### runner | Property | Value | | --- | --- | | `runner.os` | `Linux` | | `runner.arch` | `X64` on hosted runners (`ARM64`, `ARM` or `X86` on self-hosted machines with those processors) | | `runner.name` | `diffinery` | | `runner.environment` | `diffinery-hosted` | | `runner.temp` | A temporary directory for the job; `/workspace/_temp` on hosted runners, where every job starts in a fresh container | | `runner.tool_cache` | Where setup actions install tools; `/opt/hostedtoolcache` on hosted runners | | `runner.workspace` | The parent of `GITHUB_WORKSPACE`; `/workspace` on hosted runners | | `runner.debug` | `1` when debug logging is on (secret or variable `ACTIONS_STEP_DEBUG` set to `true`), otherwise `''`. `ACTIONS_RUNNER_DEBUG` doesn't change it. | ### job | Property | Value | | --- | --- | | `job.status` | `success`, `failure` or `cancelled`: the job's state so far | | `job.container.id`, `job.container.network` | The job container's ID and network, when the job has a `container` | | `job.services.<name>.id`, `.network`, `.ports` | Each service container's ID, network and port mappings (`ports['5432']` is the host port) | ### inputs For `workflow_dispatch` runs: the dispatch inputs, typed (booleans are booleans, numbers are numbers). Inside a reusable workflow: the inputs the caller passed with `with`, plus declared defaults. For other events: empty. ### jobs Only in `on.workflow_call.outputs` of a reusable workflow: `jobs.<job_id>.result` and `jobs.<job_id>.outputs.<name>` for the called workflow's jobs. ## Default environment variables Every step gets these variables. Workflows can't override the `GITHUB_*` and `RUNNER_*` ones; setting them in `env` has no effect. Paths are those of hosted runners, where the repository `acme/website` is checked out at `/workspace/website`. | Variable | Value | | --- | --- | | `CI` | `true` | | `GITHUB_ACTIONS` | `true` | | `DIFFINERY` | `true`. Use it to detect Diffinery in scripts. | | `GITHUB_REPOSITORY` | `acme/website` | | `GITHUB_REPOSITORY_OWNER` | `acme` | | `GITHUB_SHA` | The commit the run is about (see [Events that trigger workflows](https://diffinery.com/docs/actions/events#github_sha-and-github_ref-by-event)) | | `GITHUB_REF` | The full ref | | `GITHUB_REF_NAME` | The short ref (`main`, `v1.2.0`, `7/merge`) | | `GITHUB_REF_TYPE` | `branch` or `tag` | | `GITHUB_HEAD_REF` | The head branch for pull request events, otherwise empty | | `GITHUB_BASE_REF` | The base branch for pull request events, otherwise empty | | `GITHUB_RUN_ID` | The run number, as in `github.run_id` | | `GITHUB_RUN_NUMBER` | The run's number | | `GITHUB_RUN_ATTEMPT` | The attempt number | | `GITHUB_JOB` | The job's ID | | `GITHUB_ACTOR` | Who triggered the run | | `GITHUB_TRIGGERING_ACTOR` | Same as `GITHUB_ACTOR` | | `GITHUB_EVENT_NAME` | The event name | | `GITHUB_EVENT_PATH` | A JSON file holding the event payload, `/workspace/_temp/_github_workflow/event.json` | | `GITHUB_SERVER_URL` | `https://diffinery.com` | | `GITHUB_API_URL` | `https://diffinery.com/api/v3` | | `GITHUB_GRAPHQL_URL` | `https://diffinery.com/api/graphql`. The job's token can't use the [GraphQL API](https://diffinery.com/docs/reference/graphql-api) yet | | `GITHUB_CUSTOM_API_URL`, `GITHUB_CUSTOM_SERVER_URL` | The same as `GITHUB_API_URL` and `GITHUB_SERVER_URL`. Tools built for GitHub Enterprise, such as super-linter, read these; without them they call api.github.com with the job's token | | `GITHUB_WORKFLOW` | The workflow's name, or its path | | `GITHUB_WORKFLOW_REF` | `acme/website/.github/workflows/ci.yml@refs/heads/main` | | `GITHUB_WORKSPACE` | `/workspace/website` | | `GITHUB_RETENTION_DAYS` | `90` | | `GITHUB_ACTION` | The step's `id`, or `__run` for a `run` step without one (for actions, `__` plus the action's repository with other characters replaced by `_`) | | `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_OUTPUT`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | Paths of the step's [environment files](https://diffinery.com/docs/actions/workflow-syntax#environment-files) | | `RUNNER_OS` | `Linux` | | `RUNNER_ARCH` | `X64` on hosted runners | | `RUNNER_NAME` | `diffinery` | | `RUNNER_ENVIRONMENT` | `diffinery-hosted` | | `RUNNER_TEMP` | `/workspace/_temp` | | `RUNNER_TOOL_CACHE` | `/opt/hostedtoolcache` | | `RUNNER_WORKSPACE` | `/workspace` | | `AGENT_TOOLSDIRECTORY` | Same as `RUNNER_TOOL_CACHE` | | `ImageOS` | `ubuntu24`, as on GitHub's `ubuntu-24.04` image | | `ImageVersion` | `20250101.1` | Set only in some cases: | Variable | When | | --- | --- | | `RUNNER_DEBUG` | `1` when the secret or variable `ACTIONS_STEP_DEBUG` or `ACTIONS_RUNNER_DEBUG` is `true` | | `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RUNTIME_URL`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`, `ACTIONS_CACHE_SERVICE_V2` | In JavaScript actions, Docker container actions and the built-in actions only, never in `run:` steps. They give access to the job's cache and artifact services and stop working when the job ends. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts#the-runtime-services). | | `ACTIONS_ID_TOKEN_REQUEST_URL`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | When the job has `permissions: id-token: write`. Used by OIDC clients; see [OIDC](https://diffinery.com/docs/actions/oidc). | | `GITHUB_ACTION_PATH`, `GITHUB_ACTION_REPOSITORY`, `GITHUB_ACTION_REF` | In JavaScript and Docker actions, and in the steps of composite actions | | `INPUT_<NAME>` | In JavaScript and Docker actions: each input, with the name upper-cased and spaces replaced by `_` | | `STATE_<NAME>` | In an action's main and post scripts: values saved with `$GITHUB_STATE` or `::save-state` | Hosted runners also set `LANG=C.UTF-8`, `LC_ALL=C.UTF-8`, `PIP_BREAK_SYSTEM_PACKAGES=1` (so `pip install` works with Ubuntu's Python) and `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`. GitHub variables that Diffinery doesn't set include `GITHUB_ACTOR_ID`, `GITHUB_REPOSITORY_ID`, `GITHUB_REPOSITORY_OWNER_ID`, `GITHUB_WORKFLOW_SHA` and `GITHUB_REF_PROTECTED`. `GITHUB_TOKEN` isn't an environment variable unless you set it, as on GitHub: ```yaml - run: gh api repos/${{ github.repository }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ## Differences from GitHub - `jobs.<id>.environment.url` can't use step outputs. - Job-level `if:` can also read the workflow-level `env`. - `hashFiles()` returns `''` outside steps and other runner-evaluated values. - `github.event.inputs` keeps the types of dispatch inputs; on GitHub its values are strings. - `github.run_number` counts runs across all workflows of the repository, and `github.run_id` is the same number. - On a re-run, `github.actor` is whoever started the re-run (and `github.triggering_actor` matches it). - `github.ref_protected` is always `false`, and `secrets.DIFFINERY_TOKEN` is an extra name for the job token. - `GITHUB_*` and `RUNNER_*` defaults can't be overridden, but `CI` and `DIFFINERY` can. --- # Runners > Where workflow jobs run: hosted Linux runners in Cloudflare Containers (sizes, limits, late starts, restarts after a runner is replaced) and self-hosted runners via diffinery runner. Source: https://diffinery.com/docs/actions/runners A runner is the machine that executes one workflow job. Diffinery offers two kinds: **hosted runners**, which run each job in a fresh Ubuntu 24.04 container on Cloudflare, and **self-hosted runners**, which run jobs on a machine you control (a Mac, a GPU box, a server on a private network) using the `diffinery runner` command. You pick one per job with `runs-on`, the same key GitHub Actions uses. ## How runs-on picks a runner Diffinery reads the job's `runs-on` labels (case-insensitive) and decides: 1. If any label is `self-hosted`, the job waits for a self-hosted runner that offers **every** label in `runs-on`. See [Self-hosted runners](#self-hosted-runners). 2. Otherwise, if every label is one Diffinery hosts, the job runs on a hosted runner. 3. Otherwise the job fails at once with: ```text No Diffinery runner matches runs-on: windows-latest. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner. ``` | Label in `runs-on` | Runs on | |---|---| | `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04`, or any other `ubuntu-*` label | Hosted runner (always the same Ubuntu 24.04, x64 image) | | `linux` | Hosted runner | | `x64`, `amd64` | Hosted runner | | `diffinery`, or any `diffinery-*` label | Hosted runner | | `self-hosted` (plus any labels you add) | A self-hosted runner offering all the labels | | Anything else, for example `windows-latest`, `macos-latest`, `macos-14`, `gpu` without `self-hosted` | The job fails with the message above | Things to know: - Every `ubuntu-*` label gets the same Ubuntu 24.04 x64 image, including `ubuntu-22.04` and labels that ask for Arm, such as `ubuntu-24.04-arm`. - `runs-on` can be a string, a list, or a mapping with `labels`. A `group` in the mapping form is ignored; only `labels` count. - `runs-on` is evaluated for each matrix combination, so an entry can be an expression using `github`, `inputs`, `vars`, `needs`, `matrix` and `strategy`. An expression can give one label or a list of labels. Empty and `null` values are dropped. - If evaluating `runs-on` fails, only that matrix combination fails, with `Error evaluating the job for this matrix combination: <message>`. If no labels are left, the job fails with `No Diffinery runner matches runs-on: (no labels). ...`. ```yaml jobs: test: runs-on: ubuntu-latest # hosted steps: - run: echo "hosted" build: strategy: matrix: target: [linux, macos] # The linux leg runs on a hosted runner; the macos leg waits for a # self-hosted runner with the macos label. runs-on: ${{ matrix.target == 'macos' && fromJSON('["self-hosted","macos"]') || 'ubuntu-latest' }} steps: - run: uname -a gpu: runs-on: [self-hosted, linux, gpu] # waits for a runner started with --labels linux,gpu steps: - run: nvidia-smi ``` ## Hosted runners Each job on a hosted runner gets its own container on [Cloudflare Containers](https://developers.cloudflare.com/containers/), started for that job and thrown away when it finishes. Nothing carries over between jobs except what you save with caches and artifacts (see [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts)). ### Machine | Property | Value | |---|---| | Platform | Cloudflare Containers. A large runner (instance type `standard-4`) has 4 vCPU, 12 GiB of memory and 20 GB of disk; see [Runner sizes](#runner-sizes) for the smaller ones | | Architecture | `linux/amd64` (`runner.arch` is `X64`) | | Operating system | Ubuntu 24.04 (`runner.os` is `Linux`) | | User | `root`. `sudo` is installed and needs no password, so `sudo apt-get install ...` works as on GitHub | | Network | Outbound internet access (only HTTP and HTTPS on instances that set [`EGRESS=web`](https://diffinery.com/docs/reference/limits#egress)); no inbound connections | | Workspace | `/workspace/<repository name>` (`GITHUB_WORKSPACE`) | | Temp directory | `/workspace/_temp` (`RUNNER_TEMP`) | | Tool cache | `/opt/hostedtoolcache` (`RUNNER_TOOL_CACHE`, `AGENT_TOOLSDIRECTORY`) | | Locale | `LANG=C.UTF-8`, `LC_ALL=C.UTF-8` | | Concurrency | Up to 20 hosted jobs at once per account by default, across all its repositories, like GitHub's Free plan; further jobs stay queued and start, oldest first, as running ones finish. See [Limits](https://diffinery.com/docs/reference/limits) | ### Runner sizes Hosted runners come in four sizes. Every size runs the same image, so a job behaves the same on each; only the machine differs. | Size | `runs-on` label | vCPU | Memory | Disk | Cloudflare instance type | |---|---|---|---|---|---| | large | `diffinery-large` | 4 | 12 GiB | 20 GB | `standard-4` | | medium | `diffinery-medium` | 2 | 8 GiB | 16 GB | `standard-3` | | small | `diffinery-small` | 1/2 | 4 GiB | 8 GB | `standard-1` | | tiny | `diffinery-tiny` | 1/4 | 1 GiB | 4 GB | `basic` | A job with one of these labels in `runs-on` gets that size (with several, the largest): `runs-on: [ubuntu-latest, diffinery-small]`, or the label alone. A job without one gets **large**, unless its own earlier runs show a smaller size serves it just as well. That's how Diffinery keeps hosted minutes cheap: Cloudflare bills a container's memory and disk for what its size provides, used or not, so a deploy job that waits on another service for a minute costs a large runner's memory for that minute. How a size is picked for a job without a label: - The runner measures every hosted job: busy cores, memory and disk in use every quarter second, and processes the kernel killed for want of memory. The last lines of the job's log say what it used, for example `Resources: 0.1 of 4 vCPUs busy on average (0.3 at the 95th percentile), peak memory 0.3 GiB of 12.0 GiB, peak disk 1.6 GB of 19.3 GB`. The job API (`GET /repos/{owner}/{repo}/actions/jobs/{job_id}`) returns it as `usage`, and the size as `runner_size`. - The next run of the same job (the same workflow file and job name, so each matrix leg on its own) looks at up to its last 5 runs, and needs at least 3. It gets a smaller size only if every one of them would have fit it with room to spare: peak memory times 1.5, plus 0.5 GiB, and peak disk times 1.25, plus 1 GB, within the size; and at least half as many vCPUs again as the job kept busy in its busiest quarter second, so a job that bursts onto several cores even briefly, such as a test suite, keeps a runner with room for that. Busy cores count as on the slowest host processor runners land on: Cloudflare's hosts mix AMD EPYC generations whose cores differ about 2x in speed, so a job measured on a fast host gets cores to spare for a slow one (a run whose host processor isn't known counts as on the fastest). A job that kept its runner's CPU busy to the limit never gets a smaller size than that one. - A job stays on large when it has no measured runs yet, when one of those runs ran out of memory, when one failed or timed out on a smaller size, or when one took longer on a smaller size than the job takes on large allowing for its own run-to-run noise there: more than a tenth plus 2 seconds over the median of its large runs (on the same host processor where there is one), and slower than all but one of its last ten large runs. A job whose earlier runs were measured before quarter-second measuring (October 16, 2026) is sized again from new runs. After a smaller size ran a job out of memory, failed it or slowed it, the job stays on large for its next 20 runs, so it doesn't move back and forth. A re-run always runs on large, and so does a job that found no smaller runner free when it first started. So a smaller size is never the reason a job fails or slows down twice. - The job's log names the size it got and why, near the top: `Runner size: tiny (0.25 of a vCPU, 1 GiB of memory, 4 GB of disk), sized by this job's last 5 runs (peak memory 0.3 GiB)`. Set `runs-on: diffinery-large` to keep a job on large whatever it uses. ### What hosted jobs cost, and keeping it low A hosted runner exists only while its job runs: Diffinery starts a fresh container when the job starts and stops it when the job ends, and Cloudflare bills it by the 10 milliseconds, with no rounding up to whole minutes. Its memory and disk are billed for what the size provides, and its CPU for what the job uses. Nothing runs or bills between jobs. What that means for your workflows: - **CPU-bound jobs** (builds, test suites) are best on large, where they finish fastest; their cost follows the CPU they use, so a faster machine costs about the same per job. Automatic sizing keeps them there. - **Jobs that wait** (deploys, API calls, polling, `sleep`) pay mostly for memory. If you know a job waits, pin a size: `runs-on: [ubuntu-latest, diffinery-small]` costs about a third of large per minute and `diffinery-tiny` about a tenth. Automatic sizing moves a job only when its own measured runs leave room to spare even on the slowest hosts. A job that checks out and then calls an API and waits typically moves to medium after its third measured run, about a third less per minute at the same speed; steps such as `actions/setup-node` or installing a CLI keep a core busy for a few seconds, which keeps a job on large. Pin a size to go further. - **Superseded runs** keep running unless the workflow says otherwise, as on GitHub: a new push to a pull request doesn't cancel the run for the commit before it. To stop paying for runs nobody will look at, cancel them with a concurrency group per workflow and branch: ```yaml concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} ``` This cancels a pull request's older runs when a new commit arrives, and leaves runs on your default branch (such as deploys) to finish. It isn't on by default, because cancelling a run can cut off a deploy halfway or turn an older commit's checks into "cancelled". See [concurrency](https://diffinery.com/docs/actions/workflow-syntax#concurrency). ### The Ubuntu 24.04 image The hosted image is built from `containers/runner/Dockerfile` in the Diffinery source. It is deliberately small. This is the complete list of what is preinstalled: | Group | Installed | |---|---| | Base tools | `ca-certificates`, `curl`, `git`, `git-lfs`, `jq`, `zip`, `unzip`, `xz-utils`, `tar`, `gzip`, `zstd`, `sudo`, `pkg-config`, `openssh-client`, `gnupg` | | GitHub CLI | `gh`, from GitHub's apt repository | | Build tools | `build-essential` (gcc, g++, make and the C library headers) | | Libraries for Ruby and native extensions | `libyaml-dev`, `libssl-dev`, `libffi-dev`, `zlib1g-dev`, `libreadline-dev`, `libgmp-dev`, so `ruby/setup-ruby`'s prebuilt Rubies run and gems with C extensions build | | Python | Ubuntu's `python3` (3.12), `python3-pip`, `python3-venv`, `python-is-python3` (so `python` runs `python3`) | | Node.js | Node.js 22.22.0 with npm, plus corepack shims activated for pnpm 10.28.0 and yarn 1.22.22; nvm 0.40.7 in `/usr/local/nvm` (`NVM_DIR`), loaded in login shells such as `shell: bash -l {0}`, as on GitHub | | Docker | Docker Engine (`docker-ce`), the Docker CLI, `containerd.io`, the Buildx plugin, the Compose plugin, `iptables` | Image settings that affect your steps: - `git config --system safe.directory '*'` is set, and Git LFS is installed system-wide. - `PIP_BREAK_SYSTEM_PACKAGES=1` is set, so `pip install` works without a virtual environment, as on GitHub's hosted runners. - `COREPACK_ENABLE_DOWNLOAD_PROMPT=0` is set, so corepack never stops to ask. - `ImageOS=ubuntu24` and `ImageVersion=20250101.1` are set, as on GitHub's `ubuntu-24.04` image. `actions/setup-go` and `actions/setup-python` include them in their cache keys. - `zstd` is installed, so `actions/cache` compresses with zstd, as on GitHub. - The apt package lists are removed to keep the image small. Run `apt-get update` before `apt-get install`. Anything else (Go, Java, Ruby, Rust, .NET, PowerShell, browsers, cloud CLIs) is **not** preinstalled. Install it in a step: ```yaml steps: - uses: actions/setup-go@v5 # downloads Go from go.dev with: go-version: "1.23" - run: apt-get update && apt-get install -y --no-install-recommends openjdk-17-jdk-headless - run: pip install awscli ``` `actions/setup-node`, `actions/setup-python` and `actions/setup-go` use the preinstalled Node.js or Python when its version satisfies the one you ask for, and download the version otherwise. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility) for their supported inputs. **Go and Java start installing as the job begins.** When a job has an `actions/setup-go` step (v4 to v6) or an `actions/setup-java` step (v2 to v5, `distribution: temurin`, a major version such as `java-version: 21`), and the version is written in the workflow or comes from `matrix`, `inputs` or `vars`, the runner starts downloading and extracting that toolchain as the job begins, while earlier steps such as checkout run. The setup step then finds it in the tool cache instead of downloading it. If it's still being installed when the step starts, the step logs `Waiting for Go 1.23, which the runner started installing as the job began` and continues once it's there. The runner installs the same release the action would pick, checks it against its published SHA-256, and puts it where the action looks (`setup-java` reports `Resolved Java ... from tool-cache`, as it does on GitHub's runners), so nothing else about the step changes. Jobs without these steps don't pay for any of this. Versions read from a file (`go-version-file`, `java-version-file`), other Java distributions, and jobs that run in a `container:` install the usual way. > **Note:** Jobs run as `root`, and `sudo` runs commands without asking for a password, as on GitHub's runners. The image ships without apt package lists, so run `sudo apt-get update` before `sudo apt-get install ...`. ### Docker on hosted runners Docker works on hosted runners the same way as on GitHub's: job containers, service containers, Docker container actions, and `docker build`, `docker run` and `docker compose` in `run:` steps. Details: - **Started on first use.** The `docker` command is a wrapper that starts the Docker daemon the first time anything in the job needs it (a `docker` command in a step, a job `container:`, `services:`, or a Docker action). The first use prints `Starting the Docker daemon` and takes about 1 to 2 seconds (the daemon and the registry relay below start together); jobs that never touch Docker don't pay for it. A job that will use Docker (a job `container:`, `services:`, a `docker://` action, or a step whose `uses:` or `run:` names `docker`) gets the daemon started in the background as the job begins, so those seconds overlap checkout and setup. Docker commands that arrive while the daemon is starting wait for it. If the daemon can't start, or another command has been starting it for over 2 minutes, the step fails with `The Docker daemon did not start:` followed by the end of the daemon's log, rather than waiting until the job's timeout. - **Docker Hub through `mirror.gcr.io`.** The daemon uses `https://mirror.gcr.io` as its registry mirror, so pulls of Docker Hub images (`redis:7-alpine`, `postgres:16`) usually come from Google's public mirror and avoid Docker Hub's anonymous rate limits. Images the mirror doesn't have are pulled from Docker Hub as usual. - **DNS.** The daemon gives containers the DNS servers `1.1.1.1` and `8.8.8.8`. - **Pushing to the Diffinery registry.** The daemon and BuildKit builders reach `diffinery.com` through a local relay that splits image layer uploads into chunks, so layers larger than Cloudflare's 100 MB request limit push from hosted workflows. See [Container registry](https://diffinery.com/docs/packages/container-registry). - **Buildx builders.** `docker buildx create` with the `docker-container` driver (which `docker/setup-buildx-action` runs) is handled by the wrapper: it starts the BuildKit container on the host network and registers it as a remote builder, because the normal driver can't work inside a Cloudflare Container. `docker buildx rm` removes it. The full guide is [Docker in workflows](https://diffinery.com/docs/actions/docker). ### Environment variables Hosted and self-hosted runners set the GitHub-compatible variables (`CI=true`, `GITHUB_ACTIONS=true`, `GITHUB_REPOSITORY`, `GITHUB_SHA`, `GITHUB_REF`, `GITHUB_WORKSPACE`, `GITHUB_ENV`, `GITHUB_OUTPUT`, and so on) plus `DIFFINERY=true`. `GITHUB_SERVER_URL` is `https://diffinery.com` and `GITHUB_API_URL` is `https://diffinery.com/api/v3`. `RUNNER_NAME` is `diffinery` and `RUNNER_ENVIRONMENT` is `diffinery-hosted` on every runner. Workflows can override `CI` and `DIFFINERY`, but not `GITHUB_*` or `RUNNER_*` variables. Other variables a job gets: - `ImageOS` and `ImageVersion`, on hosted and self-hosted runners alike (`ubuntu24` and `20250101.1` unless the runner's environment sets them). - `RUNNER_DEBUG=1` when the secret or variable `ACTIONS_STEP_DEBUG` or `ACTIONS_RUNNER_DEBUG` is `true`. See [Expressions and contexts](https://diffinery.com/docs/actions/expressions#default-environment-variables). - `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RUNTIME_URL`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL` and `ACTIONS_CACHE_SERVICE_V2`, but only in JavaScript actions, Docker container actions and the built-in actions, never in `run:` steps. They let actions such as `actions/cache` and `actions/upload-artifact` use the cache and artifact services. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts#the-runtime-services). ### Limits and timeouts | Limit | Value | What happens | |---|---|---| | Job timeout | `timeout-minutes`, default 360 minutes, and values above 360 are capped at 360 | The job fails with "The job running on runner has exceeded the maximum execution time of N minutes." | | Total wait per job | 6 hours from when the job is scheduled, including time spent waiting for a runner | The job fails with "The job exceeded its timeout of N minutes." | | Step timeout | `steps[*].timeout-minutes`, no default | The step fails with "The step has timed out after N minutes." | | Runner that stops responding | 10 minutes past the job timeout | The runner ends a job at its timeout itself and reports within seconds. If it hasn't reported 10 minutes later (its agent froze or its machine vanished), Diffinery stops the container and the job fails as timed out with "The job exceeded its timeout of N minutes, and its runner stopped responding: Diffinery ended it 10 minutes later." So a stuck runner holds a hosted container, a slot of the account's hosted jobs and its minutes for at most the job's timeout plus 10 minutes. Set `timeout-minutes` on jobs that should never run long. | | Matrix size | 256 jobs per matrix | The job fails with "A matrix may produce at most 256 jobs." | | Log line length | 16,384 characters | Longer lines are cut and end with `…` | | Log size | 16 MiB of text per job | The log ends with "The log is cut off here: a job's log is limited to 16 MiB of text." Later lines are dropped; the job keeps running. | | Platform updates | A deploy is set up not to replace the container of a running job, so running jobs normally finish on the version they started with. The platform doesn't guarantee it, though. If it replaces a container anyway, its stop signal reaches the running step too. The same goes when the platform stops or loses a job's container without warning (it ends with no exit code, for example when its host fails). Diffinery then starts the job again from the beginning on a new runner, up to twice, if no step but `actions/checkout` had started or if the job can only read: it uses no secrets (other than `GITHUB_TOKEN`), no deployment environment, and its token can't write. What the stopped attempt uploaded (artifacts, caches) is dropped first, as a re-run would. A job that could change something outside the run fails instead once another step has started, because running that step twice could repeat what it did (a deploy, a publish). Re-run it from the run page | The log shows "The platform is stopping this runner" and, on the step, "The platform stopped this step's processes while stopping the runner." A restarted job's log starts over with "Diffinery started the job again from the beginning on a new runner (restart N of 2)". A failed one says "The platform replaced this job's runner with a new version while step … ran" when a deploy replaced it (the runner was on an older image), or "The platform stopped this job's container while step … ran" when it ran the deployed image (or, after the third replacement, "… 3 times") | If the container stops unexpectedly (for example it runs out of memory), the job fails with `The runner stopped unexpectedly (exit code N).` A container that stops before its runner fetched the job is replaced instead, since no step ran. The log's first lines name where the job ran, for example `Runner: image diffinery-runner:96dbbe31790f, in fra06, EEUR, container 6f1c…`. On hosted runners a `Host:` line names the host's processor (Cloudflare's hosts mix AMD EPYC generations, so the same job can run up to about twice as fast on one host as on another), and the line before the job's conclusion says how much of the vCPUs' time the host gave to other work (steal). The jobs API returns the same as `runner` (`image`, `location`, `instance`, and `cpu` on hosted runners), next to `runner_name`. `GET /meta` names the image the last deploy set as `runner_image`. While a deploy's rollout is updating instances, a new container can still start on the previous image. Diffinery turns that runner away before it runs anything and starts the job again on another container (after 30 s, then 1, 2 and 4 minutes). After four tries, the job runs on whatever image it got rather than wait for a rollout that has stalled. The jobs API's `placementTries` counts the times a job went back in line before a runner took it (each time, the job's log gets a line saying why: no machine was free, the container stopped before the runner started, or a runner on the previous image was turned away), and `restarts` counts the times it started again because the platform replaced its runner. ### Windows and macOS Cloudflare Containers run Linux only, so Diffinery has no Windows or macOS hosted runners. `runs-on: windows-latest` or `runs-on: macos-latest` fails with the "No Diffinery runner matches runs-on" message. For macOS builds, Windows-specific tooling or GPUs, use a [self-hosted runner](#self-hosted-runners) on your own machine with `runs-on: [self-hosted, ...]`. ## Self-hosted runners A self-hosted runner is the `diffinery runner` command running on a machine you control. It polls one or more repositories for waiting jobs whose `runs-on` includes `self-hosted`, claims one, runs it with the Diffinery runner agent (the same program that runs inside hosted containers), streams the logs to Diffinery, and reports the result. Use it for: - **macOS**: build and sign iOS or macOS apps with Xcode on a Mac. - **GPUs**: run CUDA tests or model evaluation on a machine with a GPU. - **Private networks**: deploy to or test against servers that Cloudflare can't reach (a database behind a VPN, an on-premises cluster). - **Long-lived caches and big toolchains**: the runner's work directory and tool cache persist between jobs. ### Requirements - **Node.js 22 or later** on the machine. - **The `diffinery` CLI.** Install it with: ```sh curl -fsSL https://diffinery.com/install | bash ``` - **The runner agent**, a single Node.js file named `agent.mjs`. Build it from a checkout of the Diffinery source: ```sh pnpm install pnpm --filter @diffinery/runner build # produces containers/runner/dist/agent.mjs ``` Copy `containers/runner/dist/agent.mjs` to the runner machine (for example to `/opt/diffinery/agent.mjs`) and pass it with `--agent` or the `DIFFINERY_RUNNER_AGENT` environment variable. - **A registration token** for each repository the runner serves, from a person with the **admin** role on it. A registration token works for an hour, and the runner trades it for its own **runner token**, which can claim that repository's jobs and do nothing else. Agents, job tokens and people without admin can't create one (`403 only repository admins can add self-hosted runners`). - **Bash** (the default shell for `run:` steps; `sh` is used when bash is missing), `git` and `tar`, which the built-in actions use. - **Docker**, only if jobs use `container:`, `services:` or Docker actions. ### Start a runner 1. On your own computer, as a repository admin, create a registration token (it works for an hour): ```sh diffinery api POST /repos/acme/website/actions/runners/registration-token ``` ```json {"token": "drg_…", "expires_at": "2026-10-13T19:45:00.000Z"} ``` 2. On the runner machine, register and start the runner. Set `DIFFINERY_URL` if you don't use `https://diffinery.com`. No login is needed: ```sh diffinery runner -R acme/website --registration-token drg_… --labels macos,arm64 --agent /opt/diffinery/agent.mjs ``` It registers, saves the runner's credentials, prints the labels it offers and waits: ```text Registered runner my-mac with acme/website (saved to ~/.config/diffinery/runners/diffinery.com/acme/website/my-mac.json) Runner my-mac offering [self-hosted, macos, arm64] for acme/website Waiting for jobs. Press Ctrl-C to stop. ``` Later starts use the saved registration: `diffinery runner -R acme/website --agent /opt/diffinery/agent.mjs`. Its labels are the ones it registered with. To change them, delete the saved file and register again. On a machine where you're signed in as a repository admin (`diffinery login`), `diffinery runner` registers itself with your token and claims jobs with the runner's own token. Prefer a registration token on shared or dedicated runner machines, so no personal token is kept where jobs could read it. 3. Target it from a workflow: ```yaml jobs: ios: runs-on: [self-hosted, macos, arm64] steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' test ``` When a job arrives the runner prints `→ acme/website: running job ios (run <run id>, job <job id>)`, echoes the step output prefixed with the job name, and ends with `✓ job ios finished` or `X job ios finished with exit code 1`. ### diffinery runner options | Flag | Short | Default | Description | |---|---|---|---| | `--repo OWNER/NAME` | `-R` | The repository of the current directory's git `origin` remote | Repository to take jobs from. Repeat the flag to serve several repositories. | | `--labels a,b` | `-l` | none | Extra labels this runner offers, comma separated. `self-hosted` is always offered. | | `--name NAME` | `-n` | The machine's host name | Runner name. Shown on claimed jobs and used for the default work directory. | | `--once` | | off | Run at most one job, then exit with its exit code. Prints "No jobs waiting." and exits 0 if nothing is waiting. | | `--agent PATH` | `-a` | `DIFFINERY_RUNNER_AGENT` | Path to the runner agent (`agent.mjs`). Required one way or the other. | | `--work-dir DIR` | `-w` | `~/.cache/diffinery/runner/<name>` (or `$XDG_CACHE_HOME/diffinery/runner/<name>`) | Working directory for jobs. | | `--interval SECONDS` | | `5` | Seconds between polls when idle. | | `--quiet` | `-q` | off | Don't echo step output locally. Logs still go to Diffinery. | | `--registration-token TOKEN` | | `DIFFINERY_RUNNER_REGISTRATION_TOKEN` | Register this runner with one repository, using a registration token from a repository admin. | | `--ephemeral` | | off | Register for a single job: the runner's token stops working once it takes a job, the runner exits with the job's exit code, and nothing is saved. Use it in throwaway VMs or containers. | The full generated reference is at [CLI reference](https://diffinery.com/docs/reference/cli). ### Labels and job matching - A job is eligible when its `runs-on` contains `self-hosted` and **every** one of its labels is offered by the runner. The runner offers `self-hosted` plus everything in `--labels`. Comparison is case-insensitive. - A runner with more labels than a job asks for can still take the job: `--labels linux,gpu,cuda12` takes `runs-on: [self-hosted, gpu]`. - `runs-on: [self-hosted]` alone can be taken by any runner serving the repository. - When several jobs are waiting, a runner takes the oldest run's jobs first. - Labels have no meaning to Diffinery beyond matching. Choose names that describe the machine (`macos`, `arm64`, `gpu`, `vpn-prod`). ### How jobs are claimed 1. When a self-hosted job is ready to start, Diffinery marks it **waiting** and creates a queued check on the commit. 2. Each runner polls `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` with its runner token, every `--interval` seconds per repository while idle. It offers the labels it registered with. The response is `204 No Content` when nothing matches, or the job with a fresh job token: ```http POST /api/v3/repos/acme/website/actions/runners/claim Authorization: Bearer drn_... Content-Type: application/json {} ``` ```json {"job_id": "…", "run_id": "…", "name": "ios", "token": "djt_…", "api_url": "https://diffinery.com"} ``` 3. A claimed job belongs to that runner; no other runner can take it. The runner starts the agent with `DIFFINERY_API_URL`, `DIFFINERY_JOB_ID` and `DIFFINERY_JOB_TOKEN`, and the agent fetches the job, runs it and reports back. 4. A runner runs one job at a time. To run jobs in parallel on one machine, start several runners with different `--name` values (each gets its own work directory). A job that no runner claims fails after 6 hours. While a job waits, the run page shows it as queued. If the runner can't reach Diffinery it prints `warning: ... (will retry)` and keeps polling. A `401`, `403` or `404` from the claim request stops the runner, because retrying won't help. For a removed runner it says `runner <name> was removed from <owner>/<repo>; delete <file> and register it again`. Only registered runners can claim jobs. A personal token, even a repository admin's, gets `403` with `personal tokens can't claim jobs; register this runner: ...`. Runners used to claim that way before registration. If an older runner stops with that message, upgrade the CLI and start it again while signed in, which registers it, or register it with `--registration-token`. ### Manage runners | Endpoint | Does | Who | |---|---|---| | `POST /repos/{owner}/{repo}/actions/runners/registration-token` | A registration token, valid for an hour and for any number of runners: `{"token": "drg_…", "expires_at": "…"}` | Repository admins (people) | | `POST /repos/{owner}/{repo}/actions/runners/register` | Register a runner: send `{"registration_token": "drg_…", "name": "my-mac", "labels": ["macos"], "ephemeral": false}` and get `{"id", "name", "labels", "ephemeral", "token": "drn_…"}`. The runner token is shown once. A wrong or expired registration token, or a repository that doesn't exist, gets `401 invalid or expired registration token`. | The registration token is the credential | | `GET /repos/{owner}/{repo}/actions/runners` | GitHub's runner list: `id`, `name`, `os`, `status` (`online` when it polled in the last 90 seconds), `busy`, `ephemeral` and `labels` | Repository admins | | `DELETE /repos/{owner}/{repo}/actions/runners/{id}` | Remove a runner. Its token stops working at once; a job it's running carries on with its own job token. | Repository admins (people) | A repository can have at most 100 registered runners, each with at most 20 labels. An ephemeral runner is removed when its job finishes. Registering a runner, creating a registration token and removing a runner are recorded in the audit log. ### What a job sees on a self-hosted runner - The job runs **directly on the machine**, as the user that started `diffinery runner`, with that user's files, network and permissions. - The job's environment is the runner's environment, minus `DIFFINERY_TOKEN`, `DIFFINERY_URL`, `DIFFINERY_RUNNER_AGENT`, `GH_TOKEN` and `GITHUB_TOKEN`, plus the usual `GITHUB_*` and `RUNNER_*` variables. `DIFFINERY_GITHUB_TOKEN` (see below) is used by the agent to download actions but is removed from every step's environment. - The workspace is `<work dir>/<repository name>` and is **not** wiped between jobs. `actions/checkout` cleans it by default (`clean: true`). - The tool cache (`RUNNER_TOOL_CACHE`) is `<work dir>/_tool` unless the runner's environment sets `RUNNER_TOOL_CACHE`. Tools that `setup-node`, `setup-python` and `setup-go` download stay there for later jobs. - `runner.os` reports `Linux` and `RUNNER_ENVIRONMENT` reports `diffinery-hosted` on every machine, including Macs. Use your own labels, or check `uname` in a step, if a workflow needs to know the platform. - Remote actions (`uses: owner/repo@ref`) are downloaded from `codeload.github.com`. Set `DIFFINERY_GITHUB_TOKEN` in the runner's environment to authenticate those downloads (useful against GitHub's rate limits), or `DIFFINERY_ACTIONS_DOWNLOAD_URL` to a URL template containing `{owner}`, `{repo}` and `{ref}` to download from a mirror instead. If the download from `codeload.github.com` fails, the agent tries a shallow `git fetch` of the ref from `https://github.com/<owner>/<repo>` before failing the step with `Failed to download action '<owner>/<repo>@<ref>': ...`. There is no `git` fallback when `DIFFINERY_ACTIONS_DOWNLOAD_URL` is set. - The common official actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`) run as built-in implementations when the version and inputs allow it. Set `DIFFINERY_DISABLE_BUILTINS=1` in the runner's environment to always download and run the real actions on that runner. See [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility#built-in-actions). - `setup-node`, `setup-python` and `setup-go` download their tools from `nodejs.org/dist`, the `actions/python-versions` manifest and `go.dev`. Override the sources with `DIFFINERY_NODE_MIRROR`, `DIFFINERY_PYTHON_MANIFEST` and `DIFFINERY_GO_INDEX` in the runner's environment if the machine can't reach them. ### Run as a service with systemd On Linux, run the runner as a systemd service under a dedicated user so it starts at boot and restarts after failures. 1. Create a user and put the CLI and agent where it can read them: ```sh sudo useradd --system --create-home --shell /bin/bash diffinery-runner sudo install -m 0755 ~/.local/bin/diffinery /usr/local/bin/diffinery sudo install -D -m 0644 containers/runner/dist/agent.mjs /opt/diffinery/agent.mjs ``` 2. Register the runner once as that user, with a registration token from a repository admin. Stop it with Ctrl-C once it says it's waiting: ```sh sudo -u diffinery-runner -H env DIFFINERY_URL=https://diffinery.com DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs \ diffinery runner -R acme/website --registration-token drg_… --labels linux,gpu --name gpu-box-1 ``` Its credentials are saved in that user's `~/.config/diffinery/runners`, so the service needs no token. 3. Create `/etc/systemd/system/diffinery-runner.service`: ```text [Unit] Description=Diffinery self-hosted runner After=network-online.target Wants=network-online.target [Service] User=diffinery-runner WorkingDirectory=/home/diffinery-runner Environment=DIFFINERY_URL=https://diffinery.com Environment=DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs ExecStart=/usr/bin/node /usr/local/bin/diffinery runner -R acme/website --labels linux,gpu --name gpu-box-1 --quiet Restart=always RestartSec=10 [Install] WantedBy=multi-user.target ``` Adjust the path to `node` (`which node`) and the repositories and labels. Pass `-R` explicitly: a service has no git checkout to infer the repository from. 4. Start it and follow its output: ```sh sudo systemctl daemon-reload sudo systemctl enable --now diffinery-runner journalctl -u diffinery-runner -f ``` Stopping the service sends the stop signal to the runner and the agent, which cancels the running job and reports it as cancelled. On macOS, use a `launchd` agent with the same command line and environment variables. For a clean machine per job, run `diffinery runner --ephemeral --registration-token drg_…` inside a fresh virtual machine or container and throw it away afterwards. It exits with the job's exit code, and its token can't be used again. ### Docker on self-hosted runners Job containers, service containers and Docker container actions work on a self-hosted runner that has Docker installed and running, with the `docker` CLI on the runner's `PATH` and permission to use the daemon (for example, membership in the `docker` group). Without Docker, those jobs fail with: ```text Job containers (jobs.<id>.container) need Docker, and this runner has no docker CLI. Use runs-on: [self-hosted, ...] on a machine with Docker installed. ``` or, when the CLI exists but the daemon isn't running: ```text Service containers (jobs.<id>.services) need Docker, and the Docker daemon on this runner is not reachable: ... ``` The daemon must be local to the machine, because the job container mounts the runner's work directory at the same path. Self-hosted runners don't have the registry relay that hosted runners have, so pushing an image layer larger than 100 MB to the Diffinery registry fails from a self-hosted runner. See [Docker in workflows](https://diffinery.com/docs/actions/docker) and [Container registry](https://diffinery.com/docs/packages/container-registry). ### macOS, Windows and GPU machines - **macOS.** The runner and agent run on macOS with Node.js 22 or later. Steps run with `/bin/bash`. Because the built-in `setup-node`, `setup-python` and `setup-go` download Linux builds, install toolchains on the Mac yourself (Homebrew, Xcode) and either skip those actions or ask for a version that's already installed (the installed one is used when it satisfies the request). Docker-based features are designed for Linux hosts. - **Windows.** The runner agent runs steps with bash or sh and rejects `shell: cmd` with "The `cmd` shell is not supported on Linux runners." It reports `runner.os` as `Linux`. Native Windows isn't a supported host in this version; the practical route is to run the runner inside WSL 2 and call Windows programs from steps through WSL interop (for example `powershell.exe`). - **GPUs.** Start the runner with a label such as `gpu` and run steps directly on the machine. To use the GPU from a job container, install the NVIDIA Container Toolkit on the machine and pass the Docker option through: `container: { image: nvidia/cuda:12.4.1-base-ubuntu22.04, options: --gpus all }`. ### Security considerations A self-hosted runner runs code from your repository's workflows on your machine. Before you start one: - **Anyone who can push can run code on the machine.** People and agents with write access can push a branch or open a pull request whose workflow targets `self-hosted`, and `push` and `pull_request` runs use the workflow file from that commit. Only serve repositories whose writers you trust with the machine. - **Jobs run as the runner's user.** A job can read anything that user can, including the runner's saved registration and other repositories' checkouts in the work directory. A runner registered with a registration token keeps only its runner token, which can claim that repository's jobs and nothing else: it can't read the repository, push, or call the API. Someone who steals it can still take that runner's future jobs, with their secrets, until you remove the runner. So use a dedicated OS user with no other access, don't sign in with a personal token on the runner machine, and prefer `--ephemeral` in a throwaway VM for anything sensitive. - **Jobs get the repository's secrets**, just like on hosted runners, and can reach whatever the machine can reach on your network. A job with `environment:` also gets that environment's secrets when its branch policy and the rules for environment secrets allow it, so a runner that serves deploy jobs handles production credentials. See [Deployments and environments](https://diffinery.com/docs/actions/environments). - **State persists.** The work directory, tool cache and anything a job writes elsewhere stay on the machine. A malicious job can leave something behind for the next job. For untrusted workloads, use `--ephemeral` in a throwaway VM: its token ends when it takes its one job. - **Cancelling isn't instant on your machine.** When a run is cancelled, Diffinery marks the job cancelled and revokes its job token right away, but the agent finishes executing the current steps locally (their logs and results are discarded). Stop the runner (Ctrl-C) to stop the work immediately; a second Ctrl-C kills the agent. ## Troubleshooting | Symptom | Cause and fix | |---|---| | `No Diffinery runner matches runs-on: ...` | A label isn't hosted and the job doesn't include `self-hosted`. Use an `ubuntu-*` label, or add `self-hosted` and start a runner with matching `--labels`. | | `No Diffinery runner matches runs-on: (no labels)` | Every `runs-on` entry evaluated to an empty value, for example a `matrix` key that the combination doesn't set. Check the expression. | | A self-hosted job stays queued | No runner serving that repository offers every label. Compare the job's `runs-on` with the labels the runner printed at start. The job fails after 6 hours. | | `403 admin access required` | The runner's token or account lacks admin on the repository, or the token isn't `admin`-scoped. | | `no runner agent given` or `runner agent not found at ...` | Pass `--agent /path/to/agent.mjs` or set `DIFFINERY_RUNNER_AGENT`. Build it with `pnpm --filter @diffinery/runner build`. | | `E: Unable to locate package ...` | Run `apt-get update` first; the image ships without package lists. | | A hosted job starts a minute or more late, and its log begins with `No hosted runner could start the job yet: ...` | No machine was free (`there is no container instance that can be provided`), the start failed on the platform's side for a passing reason (`internal error; reference = ...`, a dropped connection), the container stopped before the runner started, or a deploy was rolling out and the runner came up on the previous image. Diffinery puts the job back in line and tries again (after 30 s, then 1, 2, 4 and 5 minutes, up to 8 times; a first container stop before the runner started, or a first internal error, is retried after 5 s and not counted; a runner on the previous image during a deploy is retried every 20 s for up to 10 minutes, not counted, and then runs on whichever image it got); `placementTries` in the jobs API counts the waits. Nothing to fix for an occasional wait. If a large matrix often waits, cap it with `strategy.max-parallel`. | | `The platform replaced this job's runner with a new version while step ... ran` or `The platform stopped this job's container while step ... ran` | A deploy replaced the job's container mid-step (rare), or the platform stopped or moved it. Re-run the job. A job that can only read starts again by itself instead: give jobs that only build and test `permissions: contents: read`, no `environment:` and no secrets other than `GITHUB_TOKEN`. See Platform updates under [Limits and timeouts](#limits-and-timeouts). | | `The runner stopped unexpectedly (exit code N).` | The hosted container exited, often from running out of memory. Reduce parallelism in the job (for example `make -j2`) or move it to a self-hosted runner. | | `Connection refused` or `Connection timed out` on SSH (`git@...`), SMTP or a database port, on a hosted runner | The instance sets [`EGRESS=web`](https://diffinery.com/docs/self-hosting/configuration#egress), which lets hosted jobs use only HTTP and HTTPS. Use an `https://` remote or API, or run the job on a self-hosted runner. It's unset by default. | | `unable to get local issuer certificate`, `x509: certificate signed by unknown authority` or another certificate error on a hosted runner | Under [`EGRESS=web`](https://diffinery.com/docs/self-hosting/configuration#how-a-blocked-connection-looks) HTTPS is re-signed with a Cloudflare CA. Most tools trust it already; a tool with its own CA list, or a Docker container the job starts, needs `/etc/cloudflare/certs/cloudflare-containers-ca.crt` added. | --- # Docker in workflows > Run jobs in containers, start service containers, use Docker container actions, and build and push images from Diffinery workflows. Source: https://diffinery.com/docs/actions/docker Diffinery workflows support Docker the same way GitHub Actions does: a job can run inside a container image, start service containers such as databases next to it, use actions packaged as Docker images, and run `docker build`, `docker run` and `docker compose` in steps. This page covers each feature, the differences from GitHub, and complete examples. ## Where Docker is available | Runner | Docker | |---|---| | Hosted (`runs-on: ubuntu-latest` and other hosted labels) | Always available. The Docker daemon starts the first time the job uses it. | | Self-hosted (`runs-on: [self-hosted, ...]`) | Available when the machine has Docker installed and running, with the `docker` CLI on the runner's `PATH`. | On hosted runners: - The `docker` command starts the daemon on first use and prints `Starting the Docker daemon`. Jobs that never use Docker don't start it. A job that will use Docker (a job `container:`, `services:`, a `docker://` action, or a step whose `uses:` or `run:` names `docker`) gets the daemon started in the background as the job begins, so it is usually ready by the time checkout and setup are done. A docker command that has waited over 2 minutes for another one to start the daemon fails the step with `The Docker daemon did not start:` instead of hanging. - Docker Hub images are pulled through the registry mirror `https://mirror.gcr.io`, which avoids Docker Hub's anonymous pull limits for official images. Images the mirror doesn't have come from Docker Hub directly. - Containers use the DNS servers `1.1.1.1` and `8.8.8.8`. - Bridge networks, service names, port publishing and image builds all work, as on GitHub's hosted runners. See [Runners](https://diffinery.com/docs/actions/runners) for the hosted image and for setting up Docker on a self-hosted runner. ## Job containers `jobs.<id>.container` runs the job's steps inside a container instead of directly on the runner. ```yaml jobs: test: runs-on: ubuntu-latest container: image: node:22-bookworm env: NODE_ENV: test options: --cpus 1 steps: - uses: actions/checkout@v4 - run: node --version && npm ci && npm test ``` `container` is either an image name (`container: node:22`) or a mapping: | Key | Description | |---|---| | `image` | Image to run. Expressions are allowed. If it evaluates to an empty string, the job runs without a container. | | `credentials.username`, `credentials.password` | Registry login for a private image. Both are required when either is set ("Registry credentials for IMAGE need both a username and a password."). The password is masked in logs. | | `env` | Environment variables for the container. | | `ports` | Ports to publish, as for `docker run -p`. | | `volumes` | Volumes to mount, as for `docker run -v`. | | `options` | Extra `docker run` options, as one string. `--network` is not allowed. | How it works: 1. In the "Initialize containers" section of the job log, Diffinery creates a bridge network for the job, starts any service containers, pulls the image and starts the job container on that network. 2. The container's entrypoint is replaced with `tail -f /dev/null` to keep it running, so **the image needs a `tail` command** (almost every Linux image has one). If it exits right away, the job fails with "The job container (IMAGE) exited right after starting. The image needs a 'tail' command to stay up." 3. The runner's work directory is mounted into the container at the same path, so `GITHUB_WORKSPACE`, `$GITHUB_OUTPUT`, `$GITHUB_ENV` and the other file commands work unchanged. `HOME` is `/github/home` inside the container. 4. Each `run:` step is executed with `docker exec` in the container, with the step's environment. The default shell is `bash` if the image has it, otherwise `sh`. `PATH` comes from the image unless the workflow sets `PATH`. 5. JavaScript actions run inside the container with the runner's own Node.js binary, mounted at `/__e/node`. That binary needs glibc, so JavaScript actions don't work in musl-based images such as Alpine (the same limitation GitHub has). 6. The built-in actions (`actions/checkout`, `actions/cache`, `actions/upload-artifact`, `actions/download-artifact`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`) run on the runner itself and work on the shared workspace. When the runner uses the real action instead (for a version or input the built-in doesn't cover, see [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility#built-in-actions)), it runs like any other JavaScript action, inside the container. 7. The Docker socket is mounted into the job container when the runner has one at `/var/run/docker.sock`, so a step in the container can use Docker if the image includes the `docker` CLI. 8. When the job ends, every container the job started and its network are removed. The `job.container` context has `id` and `network`. ## Service containers `jobs.<id>.services` starts containers next to the job, for databases, caches and other dependencies your tests need. Each key is the service's name. ```yaml services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: ["5432:5432"] options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30 ``` Each service takes the same keys as a job container (`image`, `credentials`, `env`, `ports`, `volumes`, `options`). A service whose `image` evaluates to an empty string is skipped, as on GitHub, which lets you turn a service on or off with an expression. ### Reaching a service How a step reaches a service depends on where the step runs: | The job... | Reach the service at | Notes | |---|---|---| | Has a `container:` | `<service name>:<container port>`, for example `postgres:5432` | Every container of the job joins one network, and each service is registered under its key as a network alias. No `ports` needed. | | Has no `container:` (steps run on the runner) | `localhost:<published port>` | Publish the port with `ports`. `"5432:5432"` publishes on the same port; `"5432"` picks a free host port, which you read from `job.services.<name>.ports['5432']`. | ```yaml - run: redis-cli -p ${{ job.services.redis.ports['6379'] }} ping ``` The `job.services.<name>` context has `id`, `network` and `ports` (a map from container port to host port). The job log lists the mappings, for example `Service redis is running (6379 -> localhost:32768)`. ### Health checks If a service image (or its `options`, through `--health-cmd`) defines a Docker health check, Diffinery waits for the service to become healthy before running the first step, checking with increasing intervals for up to 15 minutes. Services without a health check are considered ready as soon as they start. The job fails when one of these happens (for the first two, the log shows the last 50 lines of the service's output): | Message | Meaning | |---|---| | `Service container NAME failed its health check.` | Docker marked the container unhealthy. | | `Service container NAME exited before it became healthy.` | The container stopped, for example because of a bad environment variable. | | `Service container NAME did not become healthy in time (status: starting).` | It was still starting after 15 minutes. | Add a health check to every service your tests connect to straight away; without one, the first step can start before the database accepts connections. ### The --network option Every container of a job shares one network, so `--network` (or `--net`) in `options` is rejected: ```text services.cache: the --network option is not supported; every container of the job shares one network. ``` ## Docker container actions Two kinds of steps run an action in its own container: - `uses: docker://IMAGE` runs a public or mirrored image directly. - An action whose `action.yml` has `runs.using: docker`, with `runs.image` set to a `Dockerfile` in the action (built for the job) or to `docker://IMAGE`. An action directory with a `Dockerfile` and no `action.yml` is treated the same way. ```yaml steps: - uses: actions/checkout@v4 # An image straight from a registry; `args` and `entrypoint` drive the container. - uses: docker://alpine:3.20 with: entrypoint: /bin/sh args: -c "ls -la /github/workspace" # A Docker action in this repository (./.github/actions/lint/action.yml + Dockerfile). - uses: ./.github/actions/lint with: strict: true ``` ```yaml # .github/actions/lint/action.yml name: Lint inputs: strict: default: "false" runs: using: docker image: Dockerfile args: - --strict=${{ inputs.strict }} ``` Behaviour, matching GitHub's: - For `docker://` steps, `with.args` is split like a shell command line into the container's arguments and `with.entrypoint` replaces its entrypoint. Any other `with` keys become `INPUT_<NAME>` environment variables. - For `runs.using: docker` actions, inputs become `INPUT_<NAME>` variables, and `runs.args`, `runs.env`, `runs.entrypoint`, `runs.pre-entrypoint`, `runs.post-entrypoint`, `runs.pre-if` and `runs.post-if` are honoured. - A `Dockerfile` action is built with `docker build` before it runs. A build failure fails the step with "Building the action's Dockerfile failed (exit code N)." - The action container runs on the job's network with the workspace mounted at `/github/workspace` (the working directory and `GITHUB_WORKSPACE`), `HOME` at `/github/home`, and the runner's temp directory mounted so `$GITHUB_OUTPUT` and the other file commands work. The Docker socket is mounted when the runner has one. - A non-zero exit fails the step with "Docker run failed with exit code N". Remote Docker actions (`uses: owner/repo@ref`) are downloaded from github.com, like other remote actions. ## docker build, run and compose in steps On hosted runners the full Docker CLI is installed with the Buildx and Compose plugins, so ordinary commands work in `run:` steps: ```yaml jobs: integration: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: docker build -t website:test . - run: docker run --rm website:test npm test - run: docker compose -f compose.test.yml up -d --wait - run: ./scripts/integration-tests.sh - if: always() run: docker compose -f compose.test.yml logs && docker compose -f compose.test.yml down -v ``` `docker/setup-buildx-action`, `docker/login-action`, `docker/metadata-action` and `docker/build-push-action` also work. On hosted runners, `docker buildx create` with the `docker-container` driver (what `docker/setup-buildx-action` runs) starts BuildKit on the host network and registers it as a remote builder, because the regular driver can't run inside a Cloudflare Container. You don't need to change your workflow for this. BuildKit's `type=gha` cache works with `docker/build-push-action`. The action runs with the job's cache service credentials and hands them to BuildKit, and the layers are stored in the repository's Actions cache, with the same scope, size limit and eviction as `actions/cache`. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts). ```yaml - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . push: false cache-from: type=gha cache-to: type=gha,mode=max ``` > **Note:** The cache service credentials (`ACTIONS_RUNTIME_TOKEN` and `ACTIONS_RESULTS_URL`) are given only to JavaScript actions, Docker container actions and the built-in actions, never to `run:` steps. `docker buildx build --cache-to type=gha` in a `run:` step has no credentials, so use `docker/build-push-action` for the gha cache. ## Push images to the Diffinery registry Diffinery hosts a container registry at the instance's own address (`diffinery.com` here), in place of ghcr.io. A workflow signs in with its own `GITHUB_TOKEN`; the job's `packages` permission decides what the token can do: | `permissions` in the workflow or job | `GITHUB_TOKEN` can | |---|---| | No `permissions` block | What the repository default allows: pull for repositories with the `read` default (the default for new repositories), push and pull for those with `write` | | `write-all`, or `packages: write` | Push and pull | | `packages: read`, or `read-all` | Pull | | A mapping without `packages` | Nothing | A job's own `permissions:` replaces the workflow's. To push from a repository with the `read` default, add `packages: write` to the job or the workflow. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). With write, the token pushes only to packages linked to the workflow's own repository, and creates new packages under the repository's owner (linked to the repository). Other packages of the same owner, including ones that aren't linked to any repository, are pull-only for it. Image names are `diffinery.com/<owner>/<name>` and must be lowercase. `github.repository` keeps the owner's and repository's capitals, so lowercase it in a shell step with `${GITHUB_REPOSITORY,,}`. On hosted runners, image layers of any size push, because the Docker daemon and BuildKit builders reach the registry through a relay that splits large uploads. Self-hosted runners don't have the relay, so from them (as from a laptop or a server) a layer larger than 100 MB fails to push. The registry, its permissions and its API are documented in [Container registry](https://diffinery.com/docs/packages/container-registry). ## Examples ### Postgres service for tests Steps run on the runner and reach Postgres on `localhost`: ```yaml name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: website_test ports: ["5432:5432"] options: >- --health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 5s --health-retries 30 env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/website_test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run db:migrate - run: npm test ``` The same job in a container reaches the service by name instead: ```yaml jobs: test: runs-on: ubuntu-latest container: node:22-bookworm services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30 env: DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres steps: - uses: actions/checkout@v4 - run: npm ci && npm test ``` ### Redis with a random host port ```yaml jobs: cache-tests: runs-on: ubuntu-latest services: redis: image: redis:7-alpine ports: ["6379"] options: --health-cmd "redis-cli ping" --health-interval 1s --health-retries 30 steps: - uses: actions/checkout@v4 - run: npm ci && npm test env: REDIS_URL: redis://localhost:${{ job.services.redis.ports['6379'] }} ``` ### Build and push an image on every push to main With plain Docker commands: ```yaml name: Image on: push: branches: [main] jobs: image: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Sign in to the Diffinery registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin - name: Build and push run: | image="diffinery.com/${GITHUB_REPOSITORY,,}" docker build -t "$image:${{ github.sha }}" -t "$image:latest" . docker push "$image:${{ github.sha }}" docker push "$image:latest" ``` With the `docker/*` actions most ghcr.io workflows use: ```yaml jobs: image: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - run: echo "IMAGE=diffinery.com/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: diffinery.com username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ env.IMAGE }}:${{ github.sha }} ``` A later job (or another workflow of the same owner) can use the image as its job container: ```yaml smoke: needs: image runs-on: ubuntu-latest permissions: packages: read container: image: diffinery.com/acme/website:${{ github.sha }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - run: /app/healthcheck ``` A deploy job can then hand the image to your platform, using credentials stored as [environment](https://diffinery.com/docs/actions/environments) secrets so that only allowed branches reach them. The job records a deployment of the commit to `production`: ```yaml deploy: needs: smoke runs-on: ubuntu-latest environment: name: production url: https://www.example.com steps: - name: Tell the platform to roll out the new image env: DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }} run: curl -fsS -X POST "$DEPLOY_HOOK_URL" -d "image=diffinery.com/acme/website:${{ github.sha }}" ``` ## Troubleshooting | Message | Fix | |---|---| | `... need Docker, and this runner has no docker CLI.` | The self-hosted runner has no `docker` on its `PATH`. Install Docker, or run the job on a hosted runner. | | `... need Docker, and the Docker daemon on this runner is not reachable: ...` | Start the Docker daemon on the self-hosted machine, and make sure the runner's user may use it. | | `The Docker daemon did not start:` | A hosted runner couldn't start Docker. Re-run the job; if it keeps failing, report the log lines that follow. | | `Could not pull IMAGE (docker pull exited with N).` | The image name or tag is wrong, or it's private and needs `credentials`. | | `The job container (IMAGE) exited right after starting.` | The image has no `tail` command, or its startup fails. Use an image with a shell and coreutils. | | `Service container NAME failed its health check.` | Check the service log printed above the error, usually a missing environment variable such as `POSTGRES_PASSWORD`. | | `the --network option is not supported` | Remove `--network` from `options`. Services are already on the job's network. | | A JavaScript action fails inside an Alpine job container | The runner's Node.js needs glibc. Use a Debian or Ubuntu based image, or run the action outside the container. | | A registry error ending in `the workflow's GITHUB_TOKEN has no packages permission; add permissions: packages: write` | The job's `permissions` mapping leaves out `packages`. Add `packages: write` to push, or `packages: read` to pull. | | A push fails with a registry error ending in `the workflow's GITHUB_TOKEN has packages: read; add permissions: packages: write to push` | The job has only read access to packages, often because it has no `permissions:` and the repository default is `read`. Add `packages: write`. | --- # Reusable workflows > Call one workflow from another with jobs.<id>.uses, pass inputs and secrets, read outputs, and share workflows across repositories. Source: https://diffinery.com/docs/actions/reusable-workflows A reusable workflow is a workflow file with an `on: workflow_call` trigger that other workflows call as a job, with `jobs.<id>.uses`. Use one to keep a build, test or deploy pipeline in one place and call it from many workflows or repositories. Diffinery implements GitHub's syntax and behaviour; the differences are listed in [Differences from GitHub](#differences-from-github). ## A minimal example The called workflow declares its inputs, secrets and outputs under `on.workflow_call`: ```yaml # .github/workflows/build.yml name: Build on: workflow_call: inputs: node-version: type: string default: "22" run-e2e: type: boolean default: false secrets: NPM_TOKEN: required: true outputs: version: description: The version that was built value: ${{ jobs.build.outputs.version }} jobs: build: runs-on: ubuntu-latest outputs: version: ${{ steps.meta.outputs.version }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - run: npm ci env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - id: meta run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT" - if: ${{ inputs.run-e2e }} run: npm run test:e2e ``` The caller uses it as a job: ```yaml # .github/workflows/ci.yml name: CI on: push jobs: build: uses: ./.github/workflows/build.yml with: run-e2e: true secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} report: needs: build runs-on: ubuntu-latest steps: - run: echo "Built version ${{ needs.build.outputs.version }}" ``` ## Calling a workflow A calling job has `uses` instead of `runs-on` and `steps`. It accepts these keys: | Key | Description | |---|---| | `uses` | The workflow to call (required). See the formats below. | | `with` | Values for the called workflow's inputs. | | `secrets` | A mapping of secrets, or `inherit`. | | `needs` | Jobs to wait for, as for any job. | | `if` | Skip the call (and every job inside it) when false. | | `name` | Display name, used as the prefix of the jobs inside the call. | | `strategy` | Accepted, but a `matrix` isn't supported yet (see [Matrix on the calling job](#matrix-on-the-calling-job)). | | `permissions` | The most the jobs inside the call may have. They inherit these permissions, and anything the called workflow declares is lowered to them. See [Permissions in called workflows](#permissions-in-called-workflows). | | `concurrency` | Accepted but has no effect on a calling job. Jobs inside the called workflow can use their own job-level `concurrency`. | Any other key produces a warning (`unknown reusable-workflow job key '...'`) and is ignored. That includes `environment`: as on GitHub, a calling job can't use an environment, so put `environment:` on the jobs inside the called workflow (see [Environments in called workflows](#environments-in-called-workflows)). `steps` or `runs-on` on a calling job is an error. ### Local workflows ```yaml uses: ./.github/workflows/build.yml ``` The path starts with `./`, is relative to the repository root, names a file in `.github/workflows/` or `.diffinery/workflows/` and ends in `.yml` or `.yaml`. The file is read from the **same commit** as the calling workflow, so a branch or pull request can change both together. Workflows under `.diffinery/workflows/` can be called the same way (`./.diffinery/workflows/build.yml`). ### Workflows in other repositories ```yaml uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 ``` The format is `<owner>/<repo>/<path>@<ref>`, where `<ref>` is a branch, a tag or a commit SHA, and the repository is on the same Diffinery instance (here, diffinery.com). A workflow on github.com can't be called; copy it into a Diffinery repository first. Which repositories you can call: | Called repository | Can be called from | |---|---| | Public | Any repository on Diffinery | | Private | Only repositories with the **same owner** (the same user or organization) | Access is decided by the two repositories' owners, not by who pushed the commit. A call that isn't allowed fails with `repository acme/shared-workflows not found, or its workflows are not shared with acme/website`. Inside a workflow called from another repository, a local `./` reference resolves in that other repository, at the commit the called workflow came from. A branch or tag `<ref>` is resolved once, when the run is planned: every job of the run, including one that waits for an environment's approval, uses the called workflow from that commit, even if the branch moves during the run. ## Inputs Declare inputs under `on.workflow_call.inputs`. Each input must have a `type`: | Key | Description | |---|---| | `type` | `string`, `boolean` or `number` (required). | | `required` | `true` to make the call fail when the input is missing or empty. | | `default` | Value used when the caller doesn't pass one. Expressions are allowed. | | `description` | Free text. | The caller passes values with `with`. Rules: - Every key in `with` must be a declared input. An unknown key fails the run with `jobs.<id>.with.<key>: <uses> has no input named '<key>'`. - `with` values can use expressions with the `github`, `needs` (the calling job's `needs`), `inputs` (when the caller is itself inside a reusable workflow) and `vars` contexts. - Values are converted to the declared type: a `boolean` input is true for `true` or `"true"`; a `number` input goes through `Number()`. - A missing optional input with no default is `""`, `false` or `0` depending on its type. - A missing required input fails the jobs inside the call with `jobs.<id>: input '<name>' is required by <uses>`. Inside the called workflow, read inputs with `${{ inputs.<name> }}`. ## Secrets The called workflow only sees the secrets the caller passes, plus `GITHUB_TOKEN`, which is always the job's own token, plus, in a job with `environment:`, that environment's secrets (see [Environments in called workflows](#environments-in-called-workflows)). Pass specific secrets with a mapping. Each value is an expression evaluated against the caller's secrets: ```yaml secrets: DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` Or pass everything the caller has with `inherit`: ```yaml secrets: inherit ``` At the top level, "everything the caller has" is the calling repository's secrets plus its organization's secrets. In a nested call, it's whatever the enclosing call received. Declare the secrets a workflow needs under `on.workflow_call.secrets`. A secret declared with `required: true` that the caller doesn't provide fails the jobs inside the call with `jobs.<id>: secret '<NAME>' is required by <uses>`. Secret names are case-insensitive and are stored uppercase. Secrets for a cross-repository call always come from the **calling** repository and its organization, never from the repository that holds the workflow. ## Outputs A called workflow exposes results through `on.workflow_call.outputs`. Each output has a `value`, an expression that can read the `jobs` context of the called workflow (`jobs.<job id>.outputs.<name>` and `jobs.<job id>.result`) and its `inputs`: ```yaml on: workflow_call: outputs: image: value: ${{ jobs.publish.outputs.image }} ``` The caller reads it as `${{ needs.<calling job id>.outputs.image }}`. Values are strings; objects are JSON-encoded. Only declared outputs are visible to the caller. `needs.<calling job id>.result` summarizes the jobs inside the call: `failure` if any failed or timed out, otherwise `cancelled` if any was cancelled, `skipped` if all were skipped, and `success` otherwise. ## Contexts inside a called workflow | Context | Value inside the called workflow | |---|---| | `github` | The caller's run: the same event, `ref`, `sha`, `repository` and actor. `github.job` is the job's own key inside the called workflow. | | `inputs` | The resolved inputs. | | `secrets` | The passed secrets and `GITHUB_TOKEN`, plus the environment's secrets in a job with `environment:`. | | `vars` | The calling repository's variables, plus the environment's variables in a job with `environment:`. | | `env` | The called workflow's own `env` (workflow and job level). The caller's `env` is not passed. | | `needs` | Job ids as written inside the called workflow. | Because `github.repository` is the caller's, `actions/checkout` with no inputs checks out the **calling** repository, as on GitHub. To check out the repository that holds the workflow, pass `repository:`. The job's `GITHUB_TOKEN` can read that repository when it's public; when it's private, also pass a `token:` from a secret. ### Permissions in called workflows The jobs inside a call get their `GITHUB_TOKEN` permissions like this, as on GitHub: 1. Start from the calling job's effective permissions: its own `permissions:`, or else the calling workflow's, or else the repository default. 2. If the called workflow or the called job declares `permissions:`, use that instead, but lowered scope by scope to what the calling job has. A called workflow can never get more than its caller. 3. If neither declares anything, the called jobs get exactly the calling job's permissions. ```yaml # .github/workflows/release.yml in acme/website jobs: deploy: permissions: contents: read id-token: write uses: ./.github/workflows/deploy.yml ``` Here the jobs in `deploy.yml` can have at most `contents: read` and `id-token: write`. If `deploy.yml` declares `permissions: write-all`, its jobs still get only those two. If it declares `packages: write`, its jobs get no `packages` access, because the calling job has none. This applies to `id-token` for [OIDC](https://diffinery.com/docs/actions/oidc), to `packages` for the [container registry](https://diffinery.com/docs/packages/container-registry), and to every other scope. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#reusable-workflows). ## Environments in called workflows A job inside a called workflow can deploy to an [environment](https://diffinery.com/docs/actions/environments) with `environment:`, and the environment's name and `url` can use the called workflow's `inputs`: ```yaml jobs: deploy: runs-on: ubuntu-latest environment: name: ${{ inputs.environment }} url: https://${{ inputs.environment }}.example.com steps: - run: ./deploy.sh ``` How it works: - The environment belongs to the **calling** repository, the one whose run it is, even when the workflow file lives in another repository. Its branch policy is checked against the caller's ref. - The job gets the environment's secrets and variables without the caller passing them in `secrets:`, as on GitHub. An environment secret replaces a passed secret with the same name. - A secret declared with `required: true` under `on.workflow_call.secrets` must still be passed by the caller, even if the environment has it. Don't declare secrets that come from the environment. - The job records a deployment like any other job with `environment:`, and the usual rules for who receives environment secrets apply (pull request refs never do; runs whose code came from an agent only from environments set to protected branches only). ## How calls appear in runs and checks The called workflow's jobs become jobs of the caller's run. A calling job `build` that calls a workflow with jobs `compile` and `test` produces the jobs **build / compile** and **build / test** (the calling job's `name`, if it has one, replaces `build`). Checks on the commit are named `<workflow name> / <calling job> / <job>`. Ordering follows GitHub: - Jobs inside the call that have no `needs` wait for the calling job's `needs`. - A job that `needs` the calling job waits for every job inside the call. - If the calling job's `if` is false, or (with the default `if`) any job it depends on, directly or through other jobs, failed or was skipped, every job inside the call is reported as skipped. ## Nesting A called workflow can itself call reusable workflows, up to **four levels** below the top-level workflow, as on GitHub. A fifth level fails the run with: ```text jobs.a/b/c/d/e: reusable workflows can be nested at most 4 levels deep ``` A run can call at most **20** reusable workflows in total (counting every call at every level); more fails with `a run can call at most 20 reusable workflows`. ## Matrix on the calling job A `strategy.matrix` on a job that calls a reusable workflow isn't supported yet. The run fails with: ```text jobs.deploy: a matrix on a job that calls a reusable workflow is not supported yet ``` Put the matrix on the jobs inside the called workflow instead, passing the list as an input: ```yaml # caller jobs: test: uses: ./.github/workflows/test.yml with: versions: '["20", "22"]' # .github/workflows/test.yml on: workflow_call: inputs: versions: type: string required: true jobs: test: runs-on: ubuntu-latest strategy: matrix: node: ${{ fromJSON(inputs.versions) }} steps: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: node --version ``` Or write one calling job per value. ## Example: a shared deploy pipeline An organization keeps its deploy workflow in `acme/shared-workflows` (private) and calls it from `acme/website` (same owner, so allowed): ```yaml # acme/shared-workflows: .github/workflows/deploy.yml name: Deploy on: workflow_call: inputs: environment: type: string required: true dry-run: type: boolean default: false secrets: CLOUDFLARE_API_TOKEN: required: true outputs: url: value: ${{ jobs.deploy.outputs.url }} jobs: deploy: runs-on: ubuntu-latest environment: ${{ inputs.environment }} # acme/website's staging or production environment outputs: url: ${{ steps.deploy.outputs.url }} steps: - uses: actions/checkout@v4 # checks out acme/website, the caller - run: npm ci && npm run build - id: deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: | if [ "${{ inputs.dry-run }}" = "true" ]; then npx wrangler deploy --dry-run else npx wrangler deploy --env "${{ inputs.environment }}" fi echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT" ``` ```yaml # acme/website: .github/workflows/release.yml name: Release on: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test staging: needs: test uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 with: environment: staging secrets: inherit production: needs: staging uses: acme/shared-workflows/.github/workflows/deploy.yml@v2 with: environment: production secrets: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} announce: needs: production runs-on: ubuntu-latest steps: - run: echo "Live at ${{ needs.production.outputs.url }}" ``` The `deploy` job uses `acme/website`'s `staging` and `production` environments, so each call is checked against that environment's branch policy and records a deployment there. If those environments have their own `CLOUDFLARE_API_TOKEN` secret, it replaces the one the caller passes. ## Differences from GitHub | Area | Diffinery | |---|---| | Where workflows come from | Only repositories on the same Diffinery instance. | | Private repositories | Callable only from repositories with the same owner. | | Matrix on the calling job | Not supported yet. | | `concurrency` | The top-level workflow's `concurrency` and job-level `concurrency` on the jobs inside a call apply. A called workflow's top-level `concurrency` and a calling job's `concurrency` are ignored. | | Number of calls per run | At most 20. | | OIDC `job_workflow_ref` claim | The top-level workflow's reference, not the called workflow's. See [OpenID Connect (OIDC)](https://diffinery.com/docs/actions/oidc). | | Branch references | Re-resolved whenever a job of the run is planned or started. | ## Errors Errors in the **calling workflow's syntax** make the workflow file invalid. On `push`, Diffinery records a failed run titled "Invalid workflow file: ..."; the workflow list in the API reports it as `invalid`. | Message | Fix | |---|---| | `reusable workflow reference '...' must be './path/to/workflow.yml' or 'owner/repo/path/to/workflow.yml@ref'` | Fix the `uses` value. Cross-repository references need `@<ref>`; local ones need `./`. | | `'./...' must be a file in ./.github/workflows or ./.diffinery/workflows` | Move the called workflow into a workflow directory. Files there count as workflow changes, which agents can't merge on their own and job tokens can't push. | | `a job that calls a reusable workflow ('uses') cannot have 'steps'` (or `'runs-on'`) | Move the steps into the called workflow. | | `input '<name>' must declare a 'type'` | Add `type: string`, `boolean` or `number` to the input under `on.workflow_call.inputs`. | | `input '<name>': invalid type '<t>' (expected one of string, boolean, number)` | `choice` and `environment` exist only for `workflow_dispatch`. | | `workflow_call output '<name>' must be a mapping with a 'value'` | Give each output a `value:` expression. | Errors found while **loading the called workflows** fail the run before any job starts. The run shows one failed job, **Set up workflow**, with the message: | Message | Fix | |---|---| | `jobs.<id>.uses: <uses> not found` | The file doesn't exist at that path and ref. | | `jobs.<id>.uses: unknown ref <ref> in <owner>/<repo>` | The branch, tag or SHA doesn't exist in the called repository. | | `jobs.<id>.uses: repository <owner>/<repo> not found, or its workflows are not shared with <owner>/<repo>` | The repository doesn't exist, or it's private and has a different owner. | | `jobs.<id>.uses: <uses> is not a reusable workflow (it has no 'on: workflow_call' trigger)` | Add `workflow_call` to the called workflow's `on`. | | `jobs.<id>.uses: <uses> is invalid: ...` | The called workflow has a syntax error; the details follow. | | `jobs.<id>.with.<key>: <uses> has no input named '<key>'` | Remove the key from `with`, or declare the input. | | `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet` | Move the matrix into the called workflow. | | `jobs.<id>: reusable workflows can be nested at most 4 levels deep` | Flatten the chain of calls. | | `a run can call at most 20 reusable workflows` | Combine calls. | Errors in the **values passed** fail the jobs inside the call when they're about to start: | Message | Fix | |---|---| | `jobs.<id>: input '<name>' is required by <uses>` | Pass the input with `with`, or give it a default. | | `jobs.<id>: secret '<NAME>' is required by <uses>` | Pass the secret in `secrets`, use `secrets: inherit`, or create the secret in the calling repository or its organization. | For the full workflow syntax, see [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax). For expressions, see [Expressions and contexts](https://diffinery.com/docs/actions/expressions). --- # Secrets and variables > Store credentials as encrypted secrets and settings as variables for workflows, per repository, organization or environment, and who can manage them. Source: https://diffinery.com/docs/actions/secrets-and-variables Secrets hold sensitive values such as API tokens and deploy keys; variables hold plain configuration such as a region or a feature flag. Workflows read them as `${{ secrets.NAME }}` and `${{ vars.NAME }}`, exactly as on GitHub. Secrets are encrypted at rest and masked in logs; variables are stored and shown in plain text. ## What you can store | Kind | Scope | Manage in the web UI | Manage with the CLI and API | Who can manage | |---|---|---|---|---| | Repository secret | One repository | Yes | Yes | People with the admin role on the repository | | Organization secret | Every repository the organization owns, its private ones, or the ones you select | No | Yes | Organization owners and admins, and site admins (people only) | | Repository variable | One repository | Yes | Yes | People with the admin role on the repository | | Organization variable | Every repository the organization owns | No | API only | Organization owners and admins, and site admins (people only) | | Environment secret | Jobs of one repository that use the environment | Yes | API only | People with the admin role on the repository | | Environment variable | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository | Agents can't create, change or delete secrets or variables anywhere, whatever their role (see [Agents, secrets and variables](#agents-secrets-and-variables)). Environment secrets and variables are described in [Environment secrets and variables](#environment-secrets-and-variables) below and in full in [Deployments and environments](https://diffinery.com/docs/actions/environments). ## Names - Names use letters, digits and underscores, and don't start with a digit (`^[A-Za-z_][A-Za-z0-9_]*$`). For repository variables, only the web form checks this (the organization variables API checks it too); the variables API accepts other names, which expressions may not be able to read, so keep to the same format. - Names are stored in uppercase: saving `api_key` creates `API_KEY`. In expressions, `secrets.api_key` and `secrets.API_KEY` both find it. - Repository and environment secrets, and environment and organization variables, can't start with `GITHUB_`. Saving one fails with `invalid secret name` (or `invalid variable name`). - Don't name a secret `DIFFINERY_TOKEN` or `GITHUB_TOKEN`: in a job, both always hold the job's own token, which replaces any secret with the same name. ## Repository secrets ### Secrets in the web UI 1. Open the repository and go to **Settings → Secrets and variables**. 2. Under **Repository secrets**, enter a **Name** and a **Value**. 3. Click **Save secret**. Setting an existing name replaces its value. The page lists each secret's name and when it was last updated. Values are never shown again. Click **Delete** next to a secret to remove it. ### Secrets with the CLI The CLI needs to act as a person with admin access to the repository, with the `admin` scope. Install it and sign in: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login --scope admin ``` The value comes from `--body`, from stdin when piped, or from a hidden prompt: ```sh diffinery secret set CLOUDFLARE_API_TOKEN -R acme/website # prompts for the value diffinery secret set NPM_TOKEN -R acme/website --body "$NPM_TOKEN" diffinery secret set DEPLOY_KEY -R acme/website < ~/.ssh/deploy_key # multi-line values from a file diffinery secret list -R acme/website diffinery secret delete NPM_TOKEN -R acme/website ``` Without `-R`, the CLI uses the repository of the current directory's git `origin` remote. Passing a value with `--body` can leave it in your shell history; prefer stdin or the prompt. See [CLI reference](https://diffinery.com/docs/reference/cli) for every flag. ### Secrets with the API ```sh # Create or replace a secret (204 No Content) curl -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"value": "npm_..."}' # List names (values are never returned) curl https://diffinery.com/api/v3/repos/acme/website/actions/secrets \ -H "Authorization: Bearer dfy_..." # Delete (204 No Content) curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \ -H "Authorization: Bearer dfy_..." ``` The list response is `{"total_count": 1, "secrets": [{"name": "NPM_TOKEN", "updated_at": "..."}]}`. Unlike GitHub, the value is sent as plain JSON over HTTPS; there's no public key to encrypt it with first. A body that only has GitHub's sealed-box `encrypted_value` fails with ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest``, and there's no `public-key` endpoint, so tools that encrypt the value first don't work; send `value` instead. The token needs the `admin` scope and its owner needs the admin role on the repository. See [API reference](https://diffinery.com/docs/api) for the full endpoint list. ## Organization secrets Organization secrets are available to workflows in the organization's repositories. As on GitHub, each secret has a `visibility` that says which repositories get it: - `all`: every repository the organization owns (the default for a new secret). - `private`: its private and internal repositories, not its public ones. - `selected`: only the repositories you list. They're managed with the CLI or the API; there's no web page for them. ```sh diffinery secret set SENTRY_AUTH_TOKEN --org acme diffinery secret set DEPLOY_KEY --org acme --repos website,api # only these two repositories diffinery secret set NPM_TOKEN --org acme --visibility private diffinery secret list --org acme diffinery secret delete SENTRY_AUTH_TOKEN --org acme ``` ```sh curl -X PUT https://diffinery.com/api/v3/orgs/acme/actions/secrets/SENTRY_AUTH_TOKEN \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"value": "sntrys_..."}' ``` With the API, send `visibility` and, for `selected`, `selected_repository_ids` (the repositories' numeric `id`s) with the value: `{"value": "…", "visibility": "selected", "selected_repository_ids": [123, 456]}`. An update that leaves them out keeps the secret's selection. An id that isn't one of the organization's repositories gets `422`. | Endpoint | Does | |---|---| | `GET /api/v3/orgs/{org}/actions/secrets` | Lists names, update times and `visibility` | | `GET /api/v3/orgs/{org}/actions/secrets/{name}` | One secret's name, update time and `visibility` (never the value) | | `GET /api/v3/orgs/{org}/actions/secrets/{name}/repositories` | The selected repositories | | `PUT /api/v3/orgs/{org}/actions/secrets/{name}/repositories` | Replaces the selection: `{"selected_repository_ids": [123, 456]}` | | `PUT` or `DELETE /api/v3/orgs/{org}/actions/secrets/{name}/repositories/{repository_id}` | Adds or removes one repository | | `DELETE /api/v3/orgs/{org}/actions/secrets/{name}` | Removes the secret | Changing the selection of a secret whose visibility isn't `selected` gets `409 the secret's visibility isn't selected`. A repository that leaves the selection stops getting the secret from its next job. Only organization owners and admins (and site admins) can use these endpoints; anyone else gets `403 organization admins only`. All three endpoints, listing included, are account-level actions: the CLI or API needs a personal token with the `admin` scope that isn't restricted to repositories. A `read` or `write` token gets `403 this token needs the admin scope`, and a token restricted to repositories gets `403 this token is limited to repositories and can't change account settings`. ## Variables ### Variables in the web UI On **Settings → Secrets and variables**, the **Variables** section lists every variable with its value. Enter a **Name** and a **Value** and click **Save variable** to add or replace one, or click **Delete** to remove one. ### Variables with the CLI ```sh diffinery variable set DEPLOY_REGION -R acme/website --body eu-west-1 echo "https://staging.example.com" | diffinery variable set STAGING_URL -R acme/website diffinery variable list -R acme/website ``` There is no `variable delete` command; delete variables in the web UI or with the API. ### Variables with the API The API has GitHub's endpoints (`POST` to create, `PATCH` to update) plus a Diffinery `PUT` that creates or replaces in one call: | Request | Needs | Does | |---|---|---| | `GET /api/v3/repos/{owner}/{repo}/actions/variables` | write role | Returns `{"total_count": 1, "variables": [{"name": "...", "value": "..."}]}`. Takes `per_page` (default 30, at most 100) and `page`. | | `GET /api/v3/repos/{owner}/{repo}/actions/variables/{name}` | write role | Returns `{"name", "value", "created_at": null, "updated_at": null}`, or `404 Not Found` | | `POST /api/v3/repos/{owner}/{repo}/actions/variables` with `{"name": "...", "value": "..."}` | admin role, people only | Creates a variable (201). An existing name fails with `409 Already exists - Variable already exists`; a missing field with `422 name and value are required`. | | `PATCH /api/v3/repos/{owner}/{repo}/actions/variables/{name}` with `{"value": "..."}` and optionally a new `"name"` | admin role, people only | Updates or renames a variable (204), or `404 Not Found`. Renaming onto a name that already exists fails with `409 Already exists - Variable already exists`. A body with only `name` keeps the value. | | `PUT /api/v3/repos/{owner}/{repo}/actions/variables/{name}` with `{"value": "..."}` | admin role, people only | Creates or replaces a variable (204) | | `DELETE /api/v3/repos/{owner}/{repo}/actions/variables/{name}` | admin role, people only | Deletes a variable (204) | Changing variables needs a token with the `admin` scope. An agent's token gets `403 agents cannot manage variables` for every change, whatever its role. ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "DEPLOY_REGION", "value": "eu-west-1"}' ``` Variable names are stored in uppercase, and every endpoint, `DELETE` included, matches them without regard to letter case. ## Organization variables Organization variables are available as `${{ vars.NAME }}` to every workflow in every repository the organization owns, including repositories created later. Use them for settings that many repositories share, such as a registry URL or a cloud region. A repository or environment variable with the same name wins (see [Precedence](#precedence)). They're managed with the REST API only: there's no web page for them and no CLI command (`diffinery variable` works on one repository). A [GitHub organization import](https://diffinery.com/docs/migrate-from-github) also adds the GitHub organization's variables that the Diffinery organization doesn't have yet, and leaves existing ones unchanged. Imported variables reach every repository, whatever repository selection they had on GitHub. ### Who can manage organization variables | Action | Who | Token | |---|---|---| | List variables and read their values | Organization owners and admins, and site admins | Any scope | | Create, update or delete a variable | Organization owners and admins, and site admins (people only) | The `admin` scope, not restricted to repositories | Other members get `403 organization admins only`, and accounts outside the organization get `404 not found`. Agents get `403 organization admins only` (or `404 not found` when neither the agent nor its owner belongs to the organization), whatever their role: agents can't read, create, change or delete organization variables through these endpoints. ### Organization variables with the API | Request | Does | |---|---| | `GET /api/v3/orgs/{org}/actions/variables` | Returns `{"total_count": 1, "variables": [{"name": "...", "value": "...", "visibility": "all", "created_at": "...", "updated_at": "..."}]}`, sorted by name. The list isn't paged. | | `GET /api/v3/orgs/{org}/actions/variables/{name}` | Returns one variable in the same shape, or `404 variable not found`. The name is matched case-insensitively. | | `POST /api/v3/orgs/{org}/actions/variables` with `{"name": "...", "value": "..."}` | Creates a variable, or replaces the value of an existing one. Returns `201` with an empty body. | | `PATCH /api/v3/orgs/{org}/actions/variables/{name}` with `{"value": "..."}` | Sets the value, creating the variable if it doesn't exist. Returns `204`. | | `DELETE /api/v3/orgs/{org}/actions/variables/{name}` | Deletes the variable (case-insensitive name). Returns `204`, or `404 variable not found` when there's no such variable. | ```sh # Create or replace curl -X POST https://diffinery.com/api/v3/orgs/acme/actions/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "NPM_REGISTRY", "value": "https://registry.npmjs.org"}' # List with values curl https://diffinery.com/api/v3/orgs/acme/actions/variables \ -H "Authorization: Bearer dfy_..." # Delete curl -X DELETE https://diffinery.com/api/v3/orgs/acme/actions/variables/NPM_REGISTRY \ -H "Authorization: Bearer dfy_..." ``` Names must match `^[A-Za-z_][A-Za-z0-9_]*$` and can't start with `GITHUB_`; otherwise the request fails with `422 invalid variable name`. Names are stored in uppercase. The value must be a string of at most 48 KB (49,152 characters); otherwise the request fails with `422 value must be a string of at most 48 KB`. In the responses, `created_at` is the time of the last change, the same as `updated_at`. Differences from GitHub: - There is no per-repository selection: `visibility` is always `all`, a `visibility` or `selected_repository_ids` field in the request is ignored, and there are no `/repositories` endpoints for a variable. - `POST` on an existing name replaces the value instead of failing with `409`, and `PATCH` creates a missing variable instead of failing with `404`. - `PATCH` can't rename a variable; a `name` in the body is ignored. Delete it and create it under the new name. - The list isn't paged; `per_page` and `page` are ignored. ### Organization and repository variables with the same name A repository's variables (the **Variables** section of **Settings → Secrets and variables**, `diffinery variable list`, and the `/api/v3/repos/{owner}/{repo}/actions/variables` endpoints) are only the repository's own, never the organization's. `GET` and `PATCH` on a name only the organization has answer `404 Not Found`, and `POST` creates a repository variable even when the organization has one of that name; in workflows, the repository's value wins. `DELETE` on a repository variable never removes an organization variable. ## Environment secrets and variables An [environment](https://diffinery.com/docs/actions/environments) such as `production` can have its own secrets and variables. Only jobs that name the environment with `environment:` receive them: ```yaml jobs: deploy: runs-on: ubuntu-latest environment: production steps: - run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # the production secret DEPLOY_URL: ${{ vars.DEPLOY_URL }} # the production variable ``` Environment secrets are managed on the environment's page (**Settings → Environments → (environment) → Environment secrets**) or with the REST API, and environment variables with the REST API only. Creating and deleting environment secrets needs a person (not an agent) with the admin role and an `admin`-scoped token; listing their names needs the admin role. Environment variables are listed with the write role and changed by a person (not an agent) with the admin role. ```sh # Environment secret: 201 when new, 204 when replaced curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"value": "cf_..."}' # Environment variable: 201 curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}' ``` The environment must exist first (`404 environment not found` otherwise); create it with `PUT /api/v3/repos/{owner}/{repo}/environments/{name}`. The full set of endpoints is in [Deployments and environments](https://diffinery.com/docs/actions/environments#environment-secrets-and-variables). ### Rules for environment secrets Environment secrets are meant for credentials that only reviewed code should use, so they're handed out more carefully than repository secrets: - **Branch policy.** A job can only use the environment from a ref its deployment branch policy allows; otherwise the job fails with `<ref> is not allowed to deploy to <env> due to environment protection rules.` - **Protected branches by default.** Adding the first secret to an environment that has no branch policy sets it to **protected branches only**. - **Never on pull request refs.** Environment secrets never go to runs on `refs/pull/*` (`pull_request` runs), whatever the policy. - **Agents only under "protected branches only".** A run started by an agent (for example by its push or workflow dispatch) only gets environment secrets when the environment is set to protected branches only. - **Agents' tags don't count.** A tag that an agent created or moved never matches an environment's tag patterns. When a rule withholds the secrets, the job still runs and still gets the environment's variables; the withheld secrets evaluate to empty strings. See [Who receives environment secrets](https://diffinery.com/docs/actions/environments#who-receives-environment-secrets). ## Using secrets and variables in workflows ```yaml name: Deploy on: push: branches: [main] env: REGION: ${{ vars.DEPLOY_REGION }} jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: npx wrangler deploy --env production ``` - Pass a secret to a step through `env` and read it from the environment, rather than writing `${{ secrets.X }}` into the script text. - A secret or variable that doesn't exist evaluates to an empty string. - Values are read when each job starts, so changing a secret affects jobs that start afterwards, including later jobs of a run that's in progress. - A reusable workflow only receives the secrets its caller passes (a mapping or `secrets: inherit`). See [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). - A job's runner receives only the secrets the job names in its expressions (`secrets.NAME` or `secrets['NAME']`, in any letter case, anywhere in the job or in the workflow's `env`), as on GitHub, plus `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG`. Other secrets never reach the runner, so a compromised action or tool in one job can't read them. A job that uses `secrets` as a whole, such as `toJSON(secrets)` or an index computed at run time, gets all of them. ### Precedence From highest to lowest, for a name that exists in several places: 1. `GITHUB_TOKEN` and `DIFFINERY_TOKEN`: always the job's own token. 2. Environment secrets, in a job that uses the environment. 3. Repository secrets (in a called reusable workflow: the secrets the caller passed). 4. Organization secrets shared with the repository (see [Organization secrets](#organization-secrets)). For variables, from highest to lowest: 1. Environment variables, in a job that uses the environment. 2. Repository variables. 3. Organization variables (repositories owned by an organization only). In places evaluated before a job starts, such as `jobs.<id>.if` and `runs-on`, `vars` holds only repository and organization variables; environment variables are added when the job starts. A change to an organization variable reaches the organization's repositories right away, so jobs that start afterwards see the new value. If some repository can't be updated, the others still are and the request fails with `503`; repeat it to finish. ### Which runs receive secrets Every workflow run in the repository receives its repository and organization secrets, whatever the event (the one exception, pull requests from forks, is below): `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release` and `repository_dispatch`. Environment secrets follow stricter rules (see [Rules for environment secrets](#rules-for-environment-secrets)). This is a difference from GitHub worth understanding. GitHub withholds secrets from `pull_request` runs of pull requests that come from forks. Diffinery has no forks: every pull request comes from a branch in the same repository, pushed by someone with write access, so `pull_request` runs get the repository's secrets, and they run the workflow files from the pull request's own test merge commit. The exception is a pull request [imported from GitHub](https://diffinery.com/docs/migrate-from-github) whose head was in a fork (its head shows as `owner:branch`): its runs get no secrets and a read-only token, as on GitHub. `pull_request_target` runs the base branch's workflow on the base commit, also with secrets, so a pull request can't change what it runs. Runs of code an agent wrote are the other exception. A run whose trigger was an agent, whose branch or tag an agent pushed last, whose pull request an agent opened or pushed to, or (off the default branch) whose code includes a commit an agent pushed that hasn't landed on the default branch yet gets no repository or organization secrets, only its job token. A person pushing on top of an agent's commits, tagging them or dispatching a run on them doesn't change that; see [Runs whose code came from an agent](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent). An agent with write access could otherwise push a workflow that sends them anywhere. A repository admin who is a person can allow it under **Settings → Actions** (**Give runs of agent code the repository's and organization's secrets**), or with `PUT /repos/{owner}/{repo}/actions/permissions/workflow` and `"agent_runs_use_secrets": true`. The change is audited. Environment secrets follow the environment's own rules. This includes the default branch once agents land changes on it. When an agent's pull request lands through [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge), the merge is the agent's push, so the branch was last pushed by an agent: every run on it from then on is agent code until a person pushes or merges there. That covers `push` runs and the `workflow_run` runs that follow them, such as a Deploy workflow that runs after CI. Such a run gets empty strings for repository and organization secrets, and the job log doesn't say they were withheld, so a deploy typically fails on its first missing value (`CLOUDFLARE_ACCOUNT_ID is required`, say). If agents land on your default branch, choose one of these: - **Keep deploy credentials in an environment** limited to protected branches, add `environment: <name>` to the deploying job, and protect the branch. Runs of agent code receive that environment's secrets on protected branches only, and never on pull request refs, so no other agent run sees them. This is the tighter option. See [Environments](https://diffinery.com/docs/actions/environments#who-receives-environment-secrets). - **Turn on Give runs of agent code the repository's and organization's secrets.** Every run of agent code then gets them, branches and pull requests included, so treat write access for an agent as access to those secrets. In practice: **anyone who can push a branch can run a workflow that uses the repository's secrets**, and so can agents with write access where an admin allows it. Give write access only to people and agents you trust with the repository secrets, and keep production credentials in an [environment](https://diffinery.com/docs/actions/environments) set to protected branches only (see [Branch protection](https://diffinery.com/docs/repositories/branch-protection)) rather than in repository secrets. Pull request runs and branches that aren't protected never receive them. ## Masking in logs The runner replaces every secret value with `***` before log lines leave the runner, so secrets don't appear in job logs, step names, step summaries or failure messages. - Every secret passed to the job is masked, including `GITHUB_TOKEN`. - Values shorter than 3 characters aren't masked, because masking them would make logs unreadable. Don't use very short secrets. - Multi-line secrets are masked line by line as well as whole. - Masking matches the literal text only. A transformed value (base64-encoded, URL-encoded, split across lines, printed one character at a time) is not masked. Don't print secrets, even in debugging. - A job output whose value contains a secret is dropped, with the warning `Skip output 'NAME' since it may contain secret.` - Variables are never masked. ### Masking other values with ::add-mask:: Mask a value that isn't a secret, such as a token you fetch during the job, by printing the `add-mask` workflow command: ```yaml - name: Get a short-lived token run: | token=$(curl -fsS -X POST https://auth.example.com/token -d "key=$API_KEY" | jq -r .token) echo "::add-mask::$token" echo "SESSION_TOKEN=$token" >> "$GITHUB_ENV" env: API_KEY: ${{ secrets.API_KEY }} ``` From that line on, the value is replaced with `***` in the rest of the job's log. The `add-mask` line itself is never echoed. ### Debug logging Set a secret or a variable named `ACTIONS_STEP_DEBUG` to `true` to show `::debug::` messages in the log, set `RUNNER_DEBUG=1` for steps and make `runner.debug` `1`. A secret or variable named `ACTIONS_RUNNER_DEBUG` set to `true` also sets `RUNNER_DEBUG=1`, but doesn't change `runner.debug`. Turn them off again when you're done. ## GITHUB_TOKEN and the job token Every job gets its own token, created when the job starts and revoked when it finishes. It's available as `${{ secrets.GITHUB_TOKEN }}`, `${{ secrets.DIFFINERY_TOKEN }}` and `${{ github.token }}`, and `actions/checkout` uses it by default. It always replaces a repository or organization secret with the same name. What the token may do follows the job's `permissions:`, as on GitHub: | Access | Allowed | |---|---| | Clone and fetch the repository that runs the workflow (including Git LFS downloads) | With `contents: read` or `write`, or always when the repository is public | | Push to the repository | With `contents: write`. Branch protection applies, and changes to files under `.github/workflows/` are always refused. | | Read the repository through the REST API | With the scope each endpoint needs, for example `issues: read` for issues. A public repository can always be read. | | Write through the REST API (comments, labels, pull requests, releases, statuses, deployments) | With the matching scope at `write`, for example `pull-requests: write` to comment on a pull request | | Read another repository | Only if that repository is public | | Write to another repository | No | | Use the container registry | Decided by the `packages` scope: `write` pushes and pulls, `read` pulls, `none` neither. Pushes reach only packages linked to the workflow's repository, or new packages, which get linked to it. | | Request an OIDC token | Only with `id-token: write`. See [OpenID Connect (OIDC)](https://diffinery.com/docs/actions/oidc). | | Manage secrets, variables, environments or repository settings | No | A job without any `permissions:` (in the job or the workflow) gets the repository's default: read access to contents and packages for repositories created after this setting was introduced, and write access to everything except `id-token` for older ones. An admin can change it. The full rules, the scope each endpoint needs and the error messages are on [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). ```yaml permissions: contents: write pull-requests: write jobs: bump: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: | git config user.name "diffinery-actions[bot]" git config user.email "diffinery-actions[bot]@users.noreply.diffinery.com" npm version patch --no-git-tag-version git switch -c bump-version git commit -am "Bump version" git push origin bump-version ``` Use a personal access token stored as a secret instead when a workflow needs something the job token can't do, such as changing workflow files, writing to another repository, or pushing to a branch whose protection doesn't let the job push: ```yaml - uses: actions/checkout@v4 with: token: ${{ secrets.BOT_TOKEN }} ``` Create the token as a person, with the `write` scope and restricted to the repositories it needs. Pushes made this way still follow branch protection. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). ## Encryption at rest Secret values are encrypted before they're stored, with envelope encryption: - Each repository, each organization and each environment has its own AES-256-GCM key, derived from the instance's master key with HKDF. - The scope (the repository, the organization, or the repository and environment) is bound to every ciphertext as additional authenticated data, so a stored value copied into another repository or environment can't be decrypted there. - Values are decrypted only when a job starts, and sent to that job's runner over HTTPS with the rest of the job's definition. - The API, CLI and web UI only ever return names and update times. ## Agents, secrets and variables Agents (AI accounts) can't add, change or delete secrets or variables, whatever their role: - The organization secret endpoints (listing included) return `403 agents cannot manage secrets` for any agent's token. - Setting and deleting repository secrets (`PUT` and `DELETE /api/v3/repos/{owner}/{repo}/actions/secrets/{name}`) and environment secrets refuse an agent's token: an agent with the admin role gets `403 agents cannot manage secrets`; with the write role, the request already fails with `403 admin access required`. - Creating, changing and deleting repository variables (`POST`, `PATCH`, `PUT` and `DELETE` on `/api/v3/repos/{owner}/{repo}/actions/variables`) and environment variables refuse an agent's token the same way, with `403 agents cannot manage variables` for an agent with the admin role. - The organization variable endpoints (`/api/v3/orgs/{org}/actions/variables`, listing included) return `403 organization admins only` for any agent's token, or `404 not found` when neither the agent nor its owner belongs to the organization. A GitHub import started by an agent skips variables, secret names and environments. - Agents can't create, change or delete environments or their branch policies (`403 only repository admins (people, not agents) can manage environments` for an agent with the admin role). - Listing repository secret names and environment secret names needs the admin role; listing and reading variables needs the write role, so an agent with write access can read the repository's variable values (organization variables are readable only by organization owners and admins). An agent has the write role unless a person gave it more (its **Maximum role**, or an explicit admin role on the repository). Agents can still push branches and open pull requests. The workflows that run for them get no repository or organization secrets unless a repository admin allows it with `agent_runs_use_secrets` (see [Which runs receive secrets](#which-runs-receive-secrets)); where it's allowed, treat write access for an agent as access to those secrets. Environment secrets are different: runs whose code came from an agent only get them from environments set to protected branches only, and never on pull request refs. In those runs the job token also acts as an agent, so it can't push to protected or default branches. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent) and [Agents](https://diffinery.com/docs/agents/overview). ## Audit log Changes are recorded in the audit log with the name but never the value: | Change | Audit action | |---|---| | Set or delete a repository secret | `repo.secret.set`, `repo.secret.delete` | | Create, update, rename or delete a repository variable | `repo.variable.set` (a rename adds `renamed_from` with the old name), `repo.variable.delete` | | Set or delete an organization secret | `org.secret.set`, `org.secret.delete` | | Create, update or delete an organization variable | `org.variable_set`, `org.variable_delete`, with the organization as the target and `name` in the details | | Set or delete an environment secret | `environment.secret.set`, `environment.secret.delete` | | Create, update, rename or delete an environment variable | `environment.variable.set` (a rename adds `renamed_from`), `environment.variable.delete` | | Change an environment or its branch policies | `environment.update`, `environment.delete`, `environment.branch_policy.create`, `environment.branch_policy.delete` | Repository and organization secret changes made with the API or the CLI include `"via": "api"` in the entry's details; changes made on the settings page don't. Organization variable changes made with the API include it too. When a GitHub organization import adds organization variables, each one is recorded as `org.variable_set` by the person who started the import, with the GitHub organization as the target and the import's id, the Diffinery organization (`org`) and `name` in the details. See [Audit log](https://diffinery.com/docs/security/audit-log). ## Limits | Limit | Value | |---|---| | Name format | `^[A-Za-z_][A-Za-z0-9_]*$`, stored uppercase; repository and environment secrets, and environment and organization variables, can't start with `GITHUB_` | | Shortest value that is masked | 3 characters | | Organization variable value | 48 KB (49,152 characters) | | Number of organization variables | No limit of its own | | Number and size of secrets | Diffinery doesn't enforce a limit of its own; every secret is decrypted when each job starts (only the ones the job names are sent to its runner), so keep them few and small | ## Troubleshooting | Problem | Cause and fix | |---|---| | `422 invalid secret name` | The name has characters other than letters, digits and `_`, starts with a digit, or (repository secrets) starts with `GITHUB_`. | | `invalid secret name "...": use letters, digits and _, not starting with a digit` | The CLI checks the name before sending it. | | `422 value is required (send it over HTTPS; it is encrypted at rest)` | The JSON body has no string `value`. | | ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest`` | The client encrypted the value with a public key, as GitHub requires. Send the plain value as `value`. | | `secret value is empty` | The CLI got an empty value from `--body` or stdin. | | A secret is empty in a workflow run, or a deploy fails on a missing value such as `CLOUDFLARE_ACCOUNT_ID is required`, though the secret is set | The run is agent code: an agent triggered it, pushed its branch last (an agent's auto-merge counts), or opened or pushed its pull request. It gets no repository or organization secrets. Move the secrets into an environment limited to protected branches, or have an admin turn on **Give runs of agent code the repository's and organization's secrets**. See [Which runs receive secrets](#which-runs-receive-secrets). | | `403 agents cannot manage secrets` | Use a person's token. | | `403 agents cannot manage variables` | Agents can't create, change or delete variables. Use a person's token. | | `403 admin access required` | The token's owner isn't a repository admin, or the token lacks the `admin` scope. | | `403 this token needs the write scope` | The token has only the `read` scope and can't change anything. Use an `admin` token. | | `403 this token needs the admin scope` | Organization secrets need an `admin`-scoped token, even for listing. Creating, changing and deleting organization variables needs one too. | | `403 this token is limited to repositories and can't change account settings` | Organization secrets, and changes to organization variables, need a token without a repository list. | | `403 organization admins only` | Only organization owners and admins (people) manage organization secrets and organization variables, and only they can read organization variables. | | `422 invalid variable name` | The organization variable's name has characters other than letters, digits and `_`, starts with a digit, or starts with `GITHUB_`. | | `422 value must be a string of at most 48 KB` | The organization variable request has no string `value`, or the value is longer than 49,152 characters. | | `404 variable not found` | The organization has no variable of that name. | | `403 only repository admins (people, not agents) can manage environments` | Environments and their branch policies need a person with the admin role. | | `404 environment not found` | Create the environment with `PUT .../environments/{name}` before adding secrets or variables. | | An environment secret is empty in the job | The run is on a pull request ref, or an agent started it and the environment isn't set to protected branches only, or the job has no `environment:`. | | `<ref> is not allowed to deploy to <env> due to environment protection rules.` | The environment's branch policy doesn't allow the ref. See [Deployments and environments](https://diffinery.com/docs/actions/environments#deployment-branch-policies). | | A secret is empty in the workflow | Check the name, that it's set on this repository, its organization or the job's environment, and, in a reusable workflow, that the caller passes it. | | `***` appears in output where you expected text | The text contains a secret's value (or a value shorter than you think matches part of it). Rename or change the secret, or don't print that text. | | `git push` with `GITHUB_TOKEN` fails with `403` | The job has no `contents: write`. Add it to the job's or the workflow's `permissions:`, or ask an admin to change the repository default. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | The job's `permissions:` don't include the scope that endpoint needs. Add the scope the message names. | | `409 Already exists - Variable already exists` | A variable with that name already exists in the repository, or (for `POST` on a repository) the organization has a variable with that name. Update it with `PATCH` or `PUT`, use `PUT` to give the repository its own value, or choose another name. | | A variable is empty in the workflow | Check the name and that it's set on this repository, its organization or the job's environment. Environment variables aren't available in `jobs.<id>.if` or `runs-on`. | --- # GITHUB_TOKEN permissions > What a workflow job's GITHUB_TOKEN may do on Diffinery, how permissions are resolved, the repository default, and the rules no permission can lift. Source: https://diffinery.com/docs/actions/workflow-permissions Every workflow job gets its own token, `GITHUB_TOKEN`, that works against Diffinery's REST API and git server. What the token may do follows GitHub's `permissions:` model: each API endpoint needs a permission scope, and the job's effective `permissions:` decide which scopes the token has. This page explains how those permissions are worked out, how to change the repository's default, and the rules that apply whatever a workflow asks for. ## The job token The token is available as `secrets.GITHUB_TOKEN`, `github.token` and, under a Diffinery name, `secrets.DIFFINERY_TOKEN`. It isn't an environment variable unless you set one: ```yaml - run: gh api repos/${{ github.repository }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` - It works only while the job runs. When the job completes, the token stops working, and it never works more than 30 minutes past the job's timeout (counted from when the job started), even if the job is never marked complete. - It acts in the repository as `diffinery-actions[bot]`, so issues, comments and labels it creates show that name. - Its role is `write` when any scope other than `id-token` is `write`, and `read` otherwise. It never has the `maintain` or `admin` role, so [branch protection](https://diffinery.com/docs/repositories/branch-protection) always applies to it. - Each request is then checked against the scope its endpoint needs (see [What each endpoint needs](#what-each-endpoint-needs)). ## How permissions are resolved A job's effective permissions come from the first of these that exists: 1. The job's own `permissions:` (`jobs.<id>.permissions`). It replaces the workflow's, it isn't merged with it. 2. The workflow's top-level `permissions:`. 3. The repository default, set by an admin (see [The repository default](#the-repository-default)). Inside a [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows), the calling job's permissions take the place of the repository default and also cap everything the called workflow declares. See [Reusable workflows](#reusable-workflows). ### Scopes `permissions:` takes these scopes, the same as GitHub's: | Scope | What it covers on Diffinery | | --- | --- | | `actions` | Workflows, runs, jobs, logs, artifacts and caches (`/actions/...`), including starting a `workflow_dispatch` run | | `attestations` | `/attestations` | | `checks` | Check runs and check suites | | `contents` | Git fetch and push, files, commits, branches, tags, releases and release assets, compare, archives, commit comments, `repository_dispatch`, merging pull requests | | `deployments` | Deployments, deployment statuses, and reading environments | | `discussions` | `/discussions` | | `id-token` | Requesting an [OIDC](https://diffinery.com/docs/actions/oidc) token. Only `write` (or `none`) has a meaning. | | `issues` | Issues, issue comments, labels, milestones and assignees | | `models` | Accepted for compatibility. Only `read` or `none`. | | `packages` | The [container registry](https://diffinery.com/docs/packages/container-registry) | | `pages` | `/pages` | | `pull-requests` | Pull requests, reviews and review comments, and issue endpoints used on pull requests | | `repository-projects` | `/projects` | | `security-events` | `/code-scanning`, `/secret-scanning`, `/dependabot` | | `statuses` | Commit statuses | Each scope takes `read`, `write` or `none`. `write` includes `read`. An unknown scope gives the warning `unknown permission scope '<scope>'` when the workflow is parsed, and an invalid level fails the file with `permission '<scope>' must be 'read', 'write' or 'none'`. ### Shorthand values | Value | Result | | --- | --- | | `read-all` | Every scope `read`, except `id-token`, which gets nothing. | | `write-all` | Every scope `write`, except `models`, which stays `read`. Includes `id-token: write`. | | `{}` | Every scope `none`. The token can still read what anyone may read in a public repository. | | A mapping | The scopes you list get the levels you give. **Every scope you don't list is `none`.** | Two scopes have special rules: - `id-token: read` grants nothing. OIDC needs `id-token: write`. - `models: write` is lowered to `read`. Any other value of `permissions:` fails the file with `permissions must be 'read-all', 'write-all' or a mapping of scope: read|write|none`. ### Examples A workflow that only reads code, with one job that comments on pull requests: ```yaml permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm test comment: needs: test runs-on: ubuntu-latest permissions: contents: read # the job's map replaces the workflow's, so repeat contents pull-requests: write steps: - uses: actions/github-script@v7 with: script: | await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: "Tests passed.", }); ``` A release job that pushes a tag and uploads release assets needs `contents: write`. A job that pushes an image needs `packages: write`, and because naming any scope sets the others to `none`, it also needs `contents: read` to check out a private repository: ```yaml jobs: image: runs-on: ubuntu-latest permissions: contents: read packages: write ``` ## The repository default When neither the job nor the workflow sets `permissions:`, the job gets the repository default. It's one of two settings, the same as GitHub's "Workflow permissions" setting: | `default_workflow_permissions` | Token permissions | | --- | --- | | `read` | `contents: read` and `packages: read`. Every other scope `none`. | | `write` | Every scope `write` (`models` `read`), except `id-token`, which gets nothing. | | Repository | Default | | --- | --- | | Created after this setting was added | `read`, and pull request creation and approval off | | Created before | `write`, and pull request creation and approval off | A second setting, `can_approve_pull_request_reviews`, decides whether job tokens may open pull requests and approve or request changes on them (see [Creating and approving pull requests](#creating-and-approving-pull-requests)). It's `false` by default. A third, `agent_runs_use_secrets` (a Diffinery addition), decides whether runs of code an agent wrote get the repository's and organization's secrets. It's `false` by default; see [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets). ### Read and change the setting In the web UI, open the repository's **Settings → Actions** tab (`/{owner}/{repo}/settings/actions`). Under **Workflow permissions**, pick **Read repository contents and packages permissions** or **Read and write permissions**, check or clear **Allow GitHub Actions to create and approve pull requests** and **Give runs of agent code the repository's and organization's secrets**, and click **Save**. You see "Actions settings saved." The tab needs the **admin** role on the repository. You can also read and change it with the REST API or the CLI. Both need the **admin** role on the repository, and only people can change it: an agent's token gets `403 only people can change what workflows are allowed to do`. Read the current setting: ```http GET /api/v3/repos/acme/website/actions/permissions/workflow Authorization: Bearer dfy_... ``` ```json { "default_workflow_permissions": "read", "can_approve_pull_request_reviews": false, "agent_runs_use_secrets": false } ``` Change it with `PUT`. Send any of the fields; a field you leave out keeps its value. The response is `204 No Content`: ```sh curl -fsS -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/permissions/workflow \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"default_workflow_permissions": "write", "can_approve_pull_request_reviews": true}' ``` With the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): ```sh diffinery api GET /repos/acme/website/actions/permissions/workflow diffinery api PUT /repos/acme/website/actions/permissions/workflow -f default_workflow_permissions=write ``` `-f` sends string values only. To change `can_approve_pull_request_reviews`, which is a boolean, send a JSON body with `--input` (a file, or `-` for stdin): ```sh echo '{"can_approve_pull_request_reviews": true}' \ | diffinery api PUT /repos/acme/website/actions/permissions/workflow --input - ``` Every change is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `repo.actions_workflow_settings`, with the new values. | Status and message | Cause | | --- | --- | | `403 admin access required` | Your role on the repository is below admin, or your token's scope doesn't reach admin. | | `403 only people can change what workflows are allowed to do` | The request used an agent's token. | | `422 a JSON body is required` | The body is missing or isn't JSON. | | `422 default_workflow_permissions must be read or write` | Another value was sent. | | `422 can_approve_pull_request_reviews must be a boolean` | A string such as `"true"` was sent instead of `true`. | > **Note:** The setting is read when a job starts. Jobs that are already running keep the permissions they started with. ## What each endpoint needs Each request the token makes to `/api/v3/repos/{owner}/{repo}/...`, to the git server, or to a repository's web pages is matched to a scope by the first path segment after the repository. `GET`, `HEAD` and `OPTIONS` need `read`; other methods need `write`. Git fetches (`git-upload-pack`) and Git LFS downloads are reads even though they use `POST`. | Path after `/repos/{owner}/{repo}` | Scope | | --- | --- | | `/issues` (list or create issues) | `issues` | | `/issues/...`, `/labels`, `/milestones`, `/assignees` | `issues` or `pull-requests` (either one is enough) | | `/pulls`, `/pulls/...` | `pull-requests` | | `/pulls/{number}/merge`, `/pulls/{number}/update-branch`, `/pulls/{number}/auto-merge` | `contents` | | `/statuses/...`, `/commits/{sha}/status`, `/commits/{sha}/statuses` | `statuses` | | `/check-runs`, `/check-suites`, `/commits/{sha}/check-runs`, `/commits/{sha}/check-suites` | `checks` | | `/deployments`, reading `/environments` | `deployments` | | `/actions/...` | `actions` | | `/pages` | `pages` | | `/discussions` | `discussions` | | `/code-scanning`, `/secret-scanning`, `/dependabot` | `security-events` | | `/packages` | `packages` | | `/projects` | `repository-projects` | | `/attestations` | `attestations` | | `/contents`, `/commits`, `/compare`, `/git`, `/branches`, `/tags`, `/releases`, `/merges`, `/tarball`, `/zipball`, `/readme`, `/dispatches`, `/comments`, `/merge-upstream`, `/languages`, `/contributors`, `/stats` | `contents` | | Git over HTTPS (`/{owner}/{repo}.git/...`), including push | `contents` | | Release asset uploads (`/api/uploads/repos/{owner}/{repo}/releases/{id}/assets`) | `contents` | | `GET /repos/{owner}/{repo}` and other reads not listed here | None: repository metadata is always readable | ### Endpoints a job token can never use Some endpoints are refused whatever the permissions, even with `write-all`: - `/hooks`, `/collaborators`, `/keys`, `/invitations`, `/settings`, `/rulesets`, `/teams`, `/transfer`, `/autolinks`, `/vulnerability-alerts` - `/actions/secrets`, `/actions/variables`, `/actions/runners`, `/actions/permissions`, `/actions/oidc` - Creating, changing or deleting environments, and environment secrets and variables - Branch protection (`/branches/{branch}/protection`) - Changing or deleting the repository itself (`PATCH` or `DELETE /repos/{owner}/{repo}`) - Any write to a path not in the table above Most of these endpoints need the admin role, which a job token never has, so the response is `403 admin access required`. The others answer `403 this endpoint is not available to a workflow's GITHUB_TOKEN`. A job token can't re-run workflows either: `POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun` (and `/rerun-failed-jobs`, and `POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun`) fails with `403 a workflow's GITHUB_TOKEN cannot re-run workflows`. ### The 403 response A request whose scope the token lacks gets GitHub's message, with the scope it needed: ```json { "message": "Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: issues: write)", "documentation_url": "https://docs.github.com/rest" } ``` When either of two scopes is enough, the message lists both, for example `needs permissions: issues or pull-requests: write`. Add the scope to the job's `permissions:` and run the job again. ## Reading public repositories What anyone may read in a public repository, a job token may read too, whatever its permissions: - In its own public repository, a read is allowed even with `permissions: {}` or `contents: none`. - It can read **other** public repositories on Diffinery, for example to check out a public dependency or clone a public submodule, but only read: any write is refused. - It can't see other private repositories at all, even ones with the same owner. A request gets `404`. To check out another private repository, pass a personal or agent token from a secret as `token:`. ## Workflow files A job token never creates, changes or deletes files under `.github/workflows/` or `.diffinery/workflows/`, whatever its permissions. This is GitHub's `workflows` permission, which a `GITHUB_TOKEN` can't have. The refusal reads: ```text refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission ``` It applies to: - `git push`: the ref update is rejected with that reason. - Commits through the API, such as the contents API. - Merging a pull request: the message appears as a merge blocker when the pull request changes a workflow file. A push that creates a new branch is compared with where the branch left the default branch, so a new branch that only carries workflow changes already on the default branch is allowed. To change workflow files from automation, use a personal or agent token from a secret. ## Creating and approving pull requests By default a job token can't open pull requests, approve them or request changes on them. The request fails with: ```text GitHub Actions is not permitted to create pull requests. An admin can allow it in the repository's Actions settings ("Allow GitHub Actions to create and approve pull requests"). ``` For reviews the message says `approve` instead of `create`. Commenting reviews are always allowed. To allow the rest, set `can_approve_pull_request_reviews` to `true` (see [Read and change the setting](#read-and-change-the-setting)); the job also needs `pull-requests: write`. Even when allowed, an approval from a job token **never counts** toward a branch's required approvals. Only approvals by people count. ## Pull requests from forks A `pull_request` run for a pull request whose head is in another repository gets read access only: `read-all` at most, whatever the workflow asks for, and never `id-token: write`. It also gets no secrets: no repository, organization or environment secrets, only `GITHUB_TOKEN`, as on GitHub. Diffinery has no forks of its own, so today this applies to pull requests [imported from GitHub](https://diffinery.com/docs/migrate-from-github) whose head was in a fork. Their head shows as `owner:branch`. ## Reusable workflows A [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows) never gets more than its caller: - A job in the called workflow without `permissions:` (and a called workflow without top-level `permissions:`) inherits the calling job's permissions. - Whatever the called workflow or its jobs declare is lowered, scope by scope, to what the calling job has. - The calling job's own permissions follow the usual order: its `permissions:`, then its workflow's, then the repository default. ```yaml # caller jobs: deploy: uses: ./.github/workflows/deploy.yml permissions: contents: read id-token: write ``` Here the jobs in `deploy.yml` can have at most `contents: read` and `id-token: write`, even if `deploy.yml` asks for `write-all`. ## Runs whose code came from an agent A run is treated as agent code when: - an agent triggered it, - an agent last pushed the branch or tag it runs on (a merge that Diffinery lands for an agent through auto-merge counts as the agent's push), - for pull request and merge queue events, an agent opened the pull request or last pushed its head branch, or - off the default branch, the code it runs includes a commit an agent pushed that hasn't landed on the default branch yet. A person pushing on top of an agent's commits, tagging them, making a release from them or dispatching a workflow on them doesn't make that code a person's. Once a person merges the commits into the default branch (or a person lands them there), they count as that person's, since the default branch's runs go by who moved it. A re-run of such a run keeps this, whoever starts the re-run, and so does a `workflow_run` run that follows such a run, since it acts on what that run produced (its artifacts and outputs). The jobs of these runs act as an agent, so the [agent guardrails](https://diffinery.com/docs/agents/overview) hold for code an agent wrote, even when a person triggered the run: - Pushes to the default branch or a protected branch are refused with `agents may not push to protected or default branches; open a pull request`, unless the branch's protection rule has **Allow agents to push directly** on. - Merges into those branches are blocked with `agents may not merge into this branch; a human must merge`, unless the rule has **Allow agents to merge pull requests** on. The token can still turn on [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge) where the rule has **Allow agents to land pull requests through auto-merge** and a required check; that rule holds for every job token on those branches, agent code or not. - A pull request the job opens counts as agent-authored, so it needs at least one approval from a person, unless the base branch's rule [waives approvals for agents' auto-merges](https://diffinery.com/docs/repositories/branch-protection#waiving-approvals-for-agents). - A deployment the job records counts as the agent's, so [environment secret rules for agents](https://diffinery.com/docs/actions/environments#who-receives-environment-secrets) apply. - The jobs get no repository or organization secrets, only the job token, unless a repository admin who is a person turns on `agent_runs_use_secrets` (see [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets)). ## Runs started by the token Like on GitHub, what a job does with its token starts no new workflow runs, so a workflow can't trigger itself in a loop. Pushes, pull requests, releases and merges made with a job token (including a later merge by an auto-merge the token turned on) don't start runs. The two exceptions, as on GitHub, are `workflow_dispatch` (a job with `actions: write` can start another workflow with `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`) and `repository_dispatch` (`POST /repos/{owner}/{repo}/dispatches`, which needs `contents: write`). ## Differences from GitHub | Area | GitHub | Diffinery | | --- | --- | --- | | Changing the default | **Settings** > **Actions** > **General** > **Workflow permissions** | **Settings** > **Actions** > **Workflow permissions**, or `PUT /repos/{owner}/{repo}/actions/permissions/workflow` | | Organization and enterprise defaults | Can limit repositories | Not available; each repository has its own setting | | Agent code | Not a concept | Runs whose code came from an agent are held to the agent rules | | Name in the repository | `github-actions[bot]` | `diffinery-actions[bot]` | | Extra name | None | `secrets.DIFFINERY_TOKEN` | ## Troubleshooting | Problem | Fix | | --- | --- | | `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: write)` | Add that scope to the job's `permissions:`, or to the workflow's when the job has none. In a reusable workflow, the calling job needs it too. | | A job that used to write now gets 403 | The repository's default is `read` (the default for new repositories), or the workflow added a `permissions:` map that leaves the scope out. Add the scope, or have an admin set `default_workflow_permissions` to `write`. | | `actions/checkout` fails on a private repository after adding `permissions:` | Naming any scope sets the others to `none`. Add `contents: read`. | | `this endpoint is not available to a workflow's GITHUB_TOKEN`, or `admin access required` from a job | The endpoint manages settings, secrets, variables, hooks, runners, environments or branch protection. Use a personal or agent token from a secret. | | `refusing to allow a GitHub App to create or update workflow ...` | Job tokens never change workflow files. Use a personal or agent token. | | `GitHub Actions is not permitted to create pull requests. ...` | An admin sets `can_approve_pull_request_reviews` to `true`, and the job needs `pull-requests: write`. | | A job token's approval doesn't satisfy required reviews | Only people's approvals count. | | `agents may not push to protected or default branches; open a pull request` from a job | The run's code came from an agent. Open a pull request for a person to review and merge. | | A push from a job doesn't start the expected workflow | Events caused by a job token start no runs. Use `workflow_dispatch` with `actions: write` or `repository_dispatch` with `contents: write`, or a personal or agent token. | | `Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable` | Add `id-token: write`. `id-token: read` grants nothing. | --- # Deployments and environments > Create deployment environments, limit which branches and tags deploy to them, give them their own secrets and variables, and track deployments. Source: https://diffinery.com/docs/actions/environments An environment is a named deployment target, such as `staging` or `production`, with its own secrets, variables and a policy that decides which branches and tags may deploy to it. A workflow job that names an environment gets that environment's secrets and variables, is refused if its ref isn't allowed, and records a deployment with statuses that other tools can follow. Diffinery implements GitHub's environments, deployments and deployment statuses, including their REST API and webhooks; the differences are listed in [Differences from GitHub](#differences-from-github). ## How it fits together | Concept | What it is | |---|---| | Environment | A name in one repository (`production`), with an optional deployment branch policy, protection rules, secrets and variables. | | Deployment branch policy | Which refs may use the environment: any ref (no policy), protected branches only, or a list of branch and tag patterns. | | Required reviewers | Up to 6 people, one of whom must approve before a run's jobs deploy to the environment. | | Wait timer | Minutes a job waits before it deploys, up to 43,200 (30 days). | | Environment secret | An encrypted value that only jobs using the environment receive, and only when the rules in [Who receives environment secrets](#who-receives-environment-secrets) allow it. | | Environment variable | A plain value that jobs using the environment receive in the `vars` context. | | Deployment | A record that a commit was (or is being) deployed to an environment. Workflow jobs create one automatically; you can also create them with the REST API. | | Deployment status | The state of a deployment over time: `queued`, `pending`, `in_progress`, `success`, `failure`, `error` or `inactive`, with optional log and environment URLs. | A typical setup: 1. A repository admin creates `production`, limits it to protected branches, and stores the deploy credentials as environment secrets. 2. The deploy job in a workflow says `environment: production`. 3. When the job starts on `main` (a protected branch), Diffinery records a deployment with an `in_progress` status, and the job gets the `production` secrets and variables. 4. When the job finishes, the deployment gets a final status (`success`, `failure` or `inactive`). Webhooks receive a `deployment` event when the deployment is created and `deployment_status` events for each status. Repository admins manage environments under **Settings → Environments** (protection rules, deployment branches and their patterns, and environment secrets) and with the REST API, which follows GitHub's paths and JSON shapes, so `gh api`, Octokit and existing scripts work after you point them at `https://diffinery.com/api/v3`. Environment variables are managed with the API. ## Create an environment An environment is created in one of three ways: - **Explicitly**, with `PUT /api/v3/repos/{owner}/{repo}/environments/{name}`. This needs a person with the admin role on the repository and a token with the `admin` scope. - **By a workflow job** that names an environment that doesn't exist yet. The job creates it with no branch policy, secrets or variables, and the job runs. - **By creating a deployment** with the REST API for an environment that doesn't exist yet. Environments created automatically have no branch policy, so every ref may use them. Create sensitive environments yourself, with a policy, before a workflow uses them. ### Environment names - 1 to 255 characters, with no control characters, `/` or `\`. Anything else fails with `422 invalid environment name`. - Names are case-insensitive: `Production` and `production` are the same environment. Once an environment exists, deployments, statuses and jobs that name it in another case use its existing spelling, and filtering deployments with `?environment=` ignores case. - URL-encode the name in API paths, for example `environments/QA%20cluster`. ### Create one with the API An empty body creates the environment with no branch policy: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/staging \ -H "Authorization: Bearer dfy_..." ``` Create one that only protected branches may use: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}' ``` The response (`200 OK`) is the environment: ```json { "id": 1, "node_id": "RW52aXJvbm1lbnQ6YWNtZS93ZWJzaXRlLzE", "name": "production", "url": "https://diffinery.com/api/v3/repos/acme/website/environments/production", "html_url": "https://diffinery.com/acme/website/settings/environments/production", "created_at": "2026-10-13T09:00:00Z", "updated_at": "2026-10-13T09:00:00Z", "can_admins_bypass": false, "protection_rules": [{ "id": 13, "node_id": "QnJhbmNoUG9saWN5OjE", "type": "branch_policy" }], "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false } } ``` Notes on the fields: - `protection_rules` lists a `wait_timer` entry (with `wait_timer`), a `required_reviewers` entry (with `prevent_self_review` and `reviewers`, each `{"type": "User", "reviewer": {...}}`) and a `branch_policy` entry, for the rules the environment has. See [Required reviewers and wait timers](#required-reviewers-and-wait-timers). - `can_admins_bypass` is always `false`: nobody bypasses an environment's rules, admins included. - `html_url` is the environment's page under **Settings → Environments**. `PUT` on an existing environment updates it. Fields you leave out keep their values. The body accepts: | Field | Effect | |---|---| | `deployment_branch_policy` | `null` removes the policy (any ref may deploy). An object sets exactly one of `protected_branches` and `custom_branch_policies` to `true`. Leave the field out to keep the current policy. | | `wait_timer` | Minutes a job waits before it deploys, from `0` to `43200`. Anything else fails with `422 wait_timer must be a whole number of minutes from 0 to 43200`. | | `reviewers` | Up to 6 required reviewers, each `{"type": "User", "id": <id>}` with the numeric `id` Diffinery's API shows for the person, or `{"type": "User", "login": "alice"}`. `[]` or `null` removes them. Each must be a person with at least read access: an agent fails with `422 reviewers[0]: <login> is an agent; only people can be required reviewers`, and `{"type": "Team"}` fails because Diffinery has no teams. | | `prevent_self_review` | `true` stops whoever started a run from approving it. See [Prevent self-review](#prevent-self-review). | ### List, get and delete environments ```sh # Every environment, sorted by name (read access) curl https://diffinery.com/api/v3/repos/acme/website/environments \ -H "Authorization: Bearer dfy_..." # One environment (read access) curl https://diffinery.com/api/v3/repos/acme/website/environments/production \ -H "Authorization: Bearer dfy_..." # Delete it, with its secrets and variables (admin, people only) curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/staging \ -H "Authorization: Bearer dfy_..." ``` The list returns `{"total_count": 2, "environments": [...]}` and takes `per_page` (default 30, at most 100) and `page`. Deleting returns `204 No Content` and removes the environment's secrets and variables. The deployments recorded for it stay. A missing environment returns `404 environment not found`. ## Deployment branch policies A branch policy decides which refs a workflow job may use the environment from. It's checked when a job with `environment:` starts. A job whose ref isn't allowed fails before any step runs, with: ```text feature/login is not allowed to deploy to production due to environment protection rules. ``` The policy has three settings: | Setting | API value | Which refs may deploy | |---|---|---| | No policy | `"deployment_branch_policy": null` | Every branch, tag and pull request ref. | | Protected branches only | `{"protected_branches": true, "custom_branch_policies": false}` | Branches that a branch protection rule matches. Tags and pull request refs never. | | Selected branches and tags | `{"protected_branches": false, "custom_branch_policies": true}` | Branches and tags that match one of the environment's patterns. Pull request refs never. | Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`. In the web UI, open **Settings → Environments → (environment)**, choose **No restriction**, **Protected branches only** or **Selected branches and tags** under **Deployment branches**, and select **Save protection rules**. With **Selected branches and tags** saved, a **Branch and tag patterns** box appears below, where you add a pattern (choose whether it matches branches or tags) and remove one. The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it. ### Protected branches only A branch counts as protected when one of the repository's branch protection rules matches its name (see [Branch protection](https://diffinery.com/docs/repositories/branch-protection)). The default branch is only protected for this purpose when a rule matches it, so add a rule for `main` if production deploys from `main`. This is the setting to use for environments whose secrets matter: changes reach protected branches through reviewed pull requests, and it's the only setting under which runs whose code came from an agent receive environment secrets. ### Selected branches and tags With `custom_branch_policies`, the environment allows the refs that match its patterns, and nothing else. A new custom policy has no patterns, so no ref may deploy until you add one. Add a pattern (admin, people only): ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "release/*", "type": "branch"}' ``` ```json { "id": 1, "name": "release/*", "type": "branch", "node_id": "QnJhbmNoUG9saWN5OjEvMQ" } ``` `type` is `branch` (the default) or `tag`. A branch pattern is matched against branch names (`refs/heads/<name>`), and a tag pattern against tag names (`refs/tags/<name>`). Adding a pattern to an environment that doesn't use `custom_branch_policies` fails with `422 enable custom_branch_policies on the environment first`; an empty `name` fails with `422 name is required`. List and delete patterns: ```sh curl https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \ -H "Authorization: Bearer dfy_..." curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies/1 \ -H "Authorization: Bearer dfy_..." ``` The list returns `{"total_count": 1, "branch_policies": [{"id": 1, "name": "release/*", "type": "branch", "node_id": "..."}]}`. Switching an environment from custom patterns to protected branches only clears its patterns; switching back to custom patterns starts empty. Pattern syntax: | Pattern | Matches | |---|---| | `*` | Any characters except `/` | | `**` | Any characters, including `/` | | `?` | One character other than `/` | | Anything else | Itself, case-sensitively | Examples: | Pattern | Type | Matches | Doesn't match | |---|---|---|---| | `main` | branch | `main` | `main-2`, `Main` | | `release/*` | branch | `release/1.4` | `release/1.4/hotfix`, `release` | | `release/**` | branch | `release/1.4`, `release/1.4/hotfix` | `releases/1.4` | | `v*` | tag | `v1.4.0`, `v2` | `1.4.0` | | `v?.*` | tag | `v1.4`, `v2.0` | `v10.0` | > **Important:** A tag pattern never matches a tag that an agent created or last moved, whether it pushed the tag with git, created it with a release or wrote it through the API. Diffinery remembers who last moved each tag. Tags that people push can deploy through a tag pattern; tags that agents push can't. ### The default policy when the first secret is added When you add a secret to an environment that has no branch policy, Diffinery sets the policy to **protected branches only** at the same time. This keeps a new environment's secrets away from unreviewed branches. If you want selected branches and tags instead, set `custom_branch_policies` (before or after adding the secret) and add patterns. If you remove the policy afterwards with `"deployment_branch_policy": null`, it stays removed. ## Required reviewers and wait timers Protection rules hold a job before it starts. When a job that names the environment is ready to run, and the environment has required reviewers or a wait timer, the job waits. No runner starts, no token is minted and no secret leaves Diffinery until the rules pass. The job's status is `waiting` (shown as **Waiting for approval** in the web UI), the run's status is `waiting`, and the job's deployment gets a `waiting` status. A waiting job holds no concurrency group and none of the repository's hosted-runner slots, so other jobs keep running. The branch policy is checked first. A ref that may never deploy to the environment fails at once, without asking anyone. Set the rules under **Settings → Environments → (environment)**, or with the API: ```sh curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"reviewers": [{"type": "User", "login": "alice"}, {"type": "User", "login": "bob"}], "wait_timer": 10, "prevent_self_review": true}' ``` ### Required reviewers - One approval from any listed reviewer lets the run's jobs for that environment go on. A rejection fails them, with `The deployment to production was rejected by alice: <comment>`, and jobs that need them are skipped as usual. - The first decision on an environment in a run is the one that counts. Approving covers every job and matrix leg of that run that names the environment. - Reviewers get a notification and an email (Actions email settings apply), the environment shows under **Deployments waiting for you** on [Today](https://diffinery.com/docs/agents/today), and the run page shows a **Review deployments** box with each environment they may approve, a comment and **Approve and deploy** or **Reject**. - Only a person can approve, signed in to the web UI or with a personal token that has the `write` scope. Agents, a workflow's `GITHUB_TOKEN` and sandbox tokens can't, even when an agent's account is listed by mistake. Being listed is enough; a reviewer with read access may approve. That access is checked again when they decide, and it has to be access they were given (as the owner, a collaborator or an organization member): on a public repository, a reviewer removed from it can't approve, even though they can still read it. - A job waits at most 30 days. After that it fails with `The deployment to production wasn't approved within 30 days.` ### Prevent self-review With `prevent_self_review`, these people can't approve a run, even when they're listed: - whoever started the run, - whoever started an earlier attempt of a re-run, - the person who owns the agent that started it. This compares accounts, not logins, so renaming an account doesn't get around it. They see why on the run page, and the API answers `403 production doesn't let whoever started a run approve it`. ### Wait timers A wait timer delays the job by that many minutes, counted from when it became ready to start. With reviewers too, the job starts once someone approved and the timer has passed, whichever comes last. Nobody can skip a timer. ### Re-runs A re-run is a new run: its jobs wait again and need a new approval. The timer starts again too. A job that a failed-job or single-job re-run keeps from the earlier attempt doesn't run again, so it neither waits nor deploys. ### Review with the API ```sh # Environments the run waits on, and whether you may approve them curl https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/pending_deployments \ -H "Authorization: Bearer dfy_..." # Approve (or "rejected"); returns the deployments of the jobs that were waiting curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/pending_deployments \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"environment_ids": [1], "state": "approved", "comment": "Ship it"}' # Past decisions on the run curl https://diffinery.com/api/v3/repos/acme/website/actions/runs/{run_id}/approvals \ -H "Authorization: Bearer dfy_..." ``` A pending deployment looks like GitHub's: ```json [ { "environment": { "id": 1, "node_id": "...", "name": "production", "url": "...", "html_url": "..." }, "wait_timer": 10, "wait_timer_started_at": "2026-10-13T09:30:00Z", "current_user_can_approve": true, "reviewers": [{ "type": "User", "reviewer": { "login": "alice", "id": 12345, "type": "User" } }] } ] ``` | Error | Meaning | |---|---| | `422 environment 1 isn't waiting for a review in this run` | Nothing in the run waits on that environment, or someone already decided. | | `403 you aren't a required reviewer for production` | Only listed reviewers may decide. | | `403 only people can review deployments, not agents` | Agents never approve deployments. | | `403 reviewing deployments needs a token that may write` | Use a token with the `write` scope. | | `403 only people with access to this repository review its deployments` | The reviewer was removed from the repository after being listed. Add them back, or change the environment's reviewers. | Each approval and rejection is in the audit log as `deployment.approve` or `deployment.reject`, with the comment. ## Environment secrets and variables Environment secrets and variables work like repository ones, but only jobs that use the environment receive them. Secrets are managed under **Settings → Environments → (environment) → Environment secrets** or with the REST API; variables with the REST API. Secret values are encrypted at rest with a key specific to the repository and the environment. ### Environment secrets Setting and deleting environment secrets needs a person (not an agent) with the admin role on the repository, and with the API a token with the `admin` scope. The environment must exist. In the web UI, the **Environment secrets** box on the environment's page lists the secret names with when each was last updated. Enter a name and a value and select **Save secret** to add one or replace its value, or select **Delete** next to a name. Values are never shown again after you save them. ```sh # Create or replace a secret: 201 Created when new, 204 No Content when replaced curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"value": "cf_..."}' # List names (values are never returned) curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets \ -H "Authorization: Bearer dfy_..." # One secret's metadata curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ -H "Authorization: Bearer dfy_..." # Delete (204 No Content) curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ -H "Authorization: Bearer dfy_..." ``` The list returns `{"total_count": 1, "secrets": [{"name": "CLOUDFLARE_API_TOKEN", "created_at": "...", "updated_at": "..."}]}`; listing and reading need the admin role. Rules: - Names use letters, digits and underscores, don't start with a digit, and don't start with `GITHUB_`. Anything else fails with `422 invalid secret name`. Names are stored in uppercase. - Send the value as `value`, in plain JSON over HTTPS. A body with only GitHub's sealed-box `encrypted_value` fails with ``422 encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest``. There's no `public-key` endpoint. - Adding the first secret to an environment without a branch policy sets it to protected branches only (see [The default policy when the first secret is added](#the-default-policy-when-the-first-secret-is-added)). - Agents can't set or delete them, whatever their role (`403 agents cannot manage secrets` for an agent with the admin role). ### Environment variables | Request | Needs | Does | |---|---|---| | `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables` | write role | Returns `{"total_count": 1, "variables": [{"name", "value", "created_at", "updated_at"}]}`. `per_page` defaults to 10 (at most 100). | | `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | write role | Returns one variable, or `404 Not Found`. | | `POST /api/v3/repos/{owner}/{repo}/environments/{env}/variables` with `{"name": "...", "value": "..."}` | admin role, people only | Creates a variable (`201`). An existing name fails with `409 Already exists - Variable already exists`. | | `PATCH /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` with `{"value": "..."}` and optionally a new `"name"` | admin role, people only | Updates or renames a variable (`204`), or `404 Not Found`. Renaming onto a name that already exists fails with `409 Already exists - Variable already exists`. | | `DELETE /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin role, people only | Deletes a variable (`204`). | ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}' ``` Variable names follow the same rules as secret names (`422 invalid variable name` otherwise) and are stored in uppercase. Variables are not secret: anyone with write access can read them. Agents can't create, change or delete them, whatever their role (`403 agents cannot manage variables` for an agent with the admin role). ### Who receives environment secrets A job that uses an environment receives its secrets only when all of these hold: 1. The environment's branch policy allows the job's ref (otherwise the job fails, see [Deployment branch policies](#deployment-branch-policies)). 2. The run isn't on a pull request ref (`refs/pull/<number>/merge`). Environment secrets never go to `pull_request` runs, whatever the policy says. 3. If the run's code came from an agent, the environment is set to **protected branches only**. With no policy or with custom patterns, those runs get no environment secrets. A run's code came from an agent when any of these is true: - An agent started the run, for example with a push, a `workflow_dispatch` or a re-run. - The run is for a branch whose last push was made by an agent. - The run is for a pull request that an agent opened, or whose head branch an agent pushed to last. - The run is a re-run of a run whose code came from an agent, whoever starts the re-run. - The run was started by `workflow_run` after a run whose code came from an agent (it acts on that run's artifacts and outputs). In such a run the job also acts as an agent: its deployment's creator is recorded as an agent, and its `GITHUB_TOKEN` follows the rules for agents (see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent)). When a rule withholds the secrets, the job still runs with the environment's variables, and `${{ secrets.NAME }}` for an environment secret evaluates to an empty string. | Run | No policy | Protected branches only | Selected branches and tags | |---|---|---|---| | Started by a person, on an allowed branch or tag | Secrets | Secrets | Secrets | | Code from an agent, on an allowed branch | No secrets | Secrets | No secrets | | `pull_request` run (`refs/pull/<n>/merge`) | Job runs, no secrets | Job fails (ref not allowed) | Job fails (ref not allowed) | | Ref not allowed by the policy | Not applicable | Job fails | Job fails | `pull_request_target` runs use the base branch as their ref, so they're treated like a run on that branch. ### Precedence In a job that uses an environment: - An environment secret replaces a repository or organization secret with the same name (and, in a called reusable workflow, a secret passed by the caller). - An environment variable replaces a repository variable with the same name. - `GITHUB_TOKEN` and `DIFFINERY_TOKEN` are always the job's own token. Values are read when the job starts, so changes affect jobs that start afterwards. Environment secrets are masked in logs like every other secret. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables) for masking, names and repository secrets. ## Use an environment in a job ### Syntax Name the environment with a string: ```yaml jobs: deploy: runs-on: ubuntu-latest environment: production steps: - run: ./deploy.sh ``` Or with a mapping that also gives the URL of the deployed site: ```yaml jobs: deploy: runs-on: ubuntu-latest environment: name: production url: https://www.example.com steps: - run: ./deploy.sh ``` | Key | Description | |---|---| | `name` | The environment's name (required in the mapping form). Expressions are allowed. | | `url` | Optional. Stored as the deployment status's `environment_url` when it's an `http://` or `https://` URL; other values are ignored. Expressions are allowed. | Both values can use the `github`, `inputs`, `vars`, `matrix`, `strategy` and `needs` contexts. Other contexts evaluate to empty: - A `name` that evaluates to an empty string fails the job with `invalid environment name ''`. - A `url` that refers to `steps` (for example `${{ steps.deploy.outputs.url }}`, which GitHub allows) is ignored, so the deployment has no environment URL. Compute the URL from `inputs`, `vars`, `matrix` or a job output in `needs` instead, or post a deployment status yourself (see [Deployment statuses](#deployment-statuses)). ```yaml jobs: deploy: runs-on: ubuntu-latest strategy: matrix: target: [staging, production] environment: name: ${{ matrix.target }} url: https://${{ matrix.target }}.example.com steps: - run: ./deploy.sh "${{ matrix.target }}" ``` `environment` belongs on jobs that have `steps`. On a job that calls a reusable workflow (`uses:`), it produces the warning `unknown reusable-workflow job key 'environment'` and is ignored; put `environment:` on the jobs inside the called workflow instead (see [Reusable workflows](#reusable-workflows)). ### What happens when the job starts When a job with `environment:` is ready to run, Diffinery evaluates `name` once, and that name is the one the job deploys to. If the environment has required reviewers or a wait timer, the job [waits for them](#required-reviewers-and-wait-timers) first. When a hosted or self-hosted runner then starts the job, Diffinery: 1. Evaluates `url`. 2. Creates the environment if it doesn't exist (with no branch policy). 3. Checks the branch policy against the run's ref, and fails the job if the ref isn't allowed. A job whose environment needs an approval it didn't get fails with `The environment production requires approval this job didn't get. Re-run the job to ask for it.` (This happens when someone adds reviewers while the job is already on its way to a runner.) 4. Creates a deployment for the job: `ref` is the branch or tag name (or `refs/pull/<n>/merge`), `sha` is the commit the run checked out, `task` is `deploy`, `payload` is `{}`, the creator is the person or agent who started the run (an agent when the run's code came from an agent), and `production_environment` is `true` when the environment is named `production` in any case (`Production` counts). 5. Sends a `deployment` webhook event with the action `created`, then adds an `in_progress` status, with `log_url` set to the job's page (`https://diffinery.com/acme/website/actions/runs/<run id>/job/<job id>`) and `environment_url` from `url`. 6. Passes the environment's variables to the job, and its secrets when [the rules](#who-receives-environment-secrets) allow it. A job creates one deployment, even if its runner fetches the job more than once. A re-run creates a new deployment, because it's a new job. The deployment's commit statuses and checks aren't required to have passed, unlike [deployments created with the API](#required-contexts). ### Automatic deployment statuses When the job finishes, its deployment gets a final status with the same `log_url` and `environment_url`: | Job conclusion | Deployment status | |---|---| | `success` | `success` | | `cancelled` or `skipped` | `inactive` | | Anything else (`failure`, timed out) | `failure` | A `success` status also marks older deployments to the same environment as `inactive` when their latest status is `success`, unless they are transient or production deployments. Deployments created after this one are left alone. See [auto_inactive](#deployment-statuses). ### Reusable workflows Jobs inside a called workflow can have `environment:`. The environment, its branch policy, secrets and variables come from the **calling** repository (the repository whose run it is), and the policy is checked against the caller's ref. Environment secrets are added to the job even though the caller didn't pass them in `secrets:`, as on GitHub. The environment name can use the called workflow's `inputs`: ```yaml # .github/workflows/deploy.yml on: workflow_call: inputs: target: type: string required: true jobs: deploy: runs-on: ubuntu-latest environment: name: ${{ inputs.target }} url: https://${{ inputs.target }}.example.com steps: - uses: actions/checkout@v4 - run: npx wrangler deploy --env "${{ inputs.target }}" env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} ``` ```yaml # .github/workflows/release.yml on: push: branches: [main] jobs: staging: uses: ./.github/workflows/deploy.yml with: target: staging production: needs: staging uses: ./.github/workflows/deploy.yml with: target: production ``` Here `CLOUDFLARE_API_TOKEN` can be a different environment secret in `staging` and in `production`. See [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). ### Choosing an environment when you run a workflow manually A `workflow_dispatch` input with `type: environment` is accepted. It's shown as a text field when you run the workflow from the web UI, and its value isn't checked against the repository's environments. Use it like a string input: ```yaml on: workflow_dispatch: inputs: target: type: environment required: true jobs: deploy: runs-on: ubuntu-latest environment: ${{ inputs.target }} steps: - run: ./deploy.sh ``` Because a dispatched run can name any environment, the environment's branch policy is what keeps a branch from deploying where it shouldn't. ### Example: staging on main, production on version tags 1. Set up the environments (as a repository admin): ```sh api=https://diffinery.com/api/v3/repos/acme/website auth="Authorization: Bearer dfy_..." # staging: protected branches only (add a branch protection rule for main first) curl -X PUT "$api/environments/staging" -H "$auth" -H "Content-Type: application/json" \ -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}' # production: only tags that match v* curl -X PUT "$api/environments/production" -H "$auth" -H "Content-Type: application/json" \ -d '{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}' curl -X POST "$api/environments/production/deployment-branch-policies" -H "$auth" \ -H "Content-Type: application/json" -d '{"name": "v*", "type": "tag"}' # A different token for each environment curl -X PUT "$api/environments/staging/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \ -H "Content-Type: application/json" -d '{"value": "cf_staging_..."}' curl -X PUT "$api/environments/production/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \ -H "Content-Type: application/json" -d '{"value": "cf_production_..."}' ``` 2. Write the workflow: ```yaml name: Deploy on: push: branches: [main] tags: ["v*"] jobs: staging: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: name: staging url: https://staging.example.com steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - run: npx wrangler deploy --env staging env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} production: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: name: production url: https://www.example.com steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - run: npx wrangler deploy --env production env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} ``` Pushing to `main` deploys to staging; a person pushing the tag `v1.4.0` deploys to production. A tag pushed by an agent fails the production job with the "not allowed to deploy" message. ## Deployments API You can create deployments yourself, for example from a deploy tool outside Diffinery Actions, and report their progress with statuses. The endpoints follow GitHub's REST API. Creating deployments and statuses needs the write role and a token with the `write` scope; reading needs read access. Agents with write access can create deployments and statuses. A workflow step can create deployments and statuses with `GITHUB_TOKEN` when the job has `deployments: write` in its `permissions:`. Without it, the request fails with `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: deployments: write)`, or `403 write access required` when the token has no write scope at all. Often `environment:` on the job is simpler, because it records the deployment for you. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). ```yaml jobs: deploy: runs-on: ubuntu-latest permissions: contents: read deployments: write steps: - run: | curl -fsS -X POST "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/deployments" \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ref": "${{ github.sha }}", "environment": "preview", "required_contexts": []}' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ### Create a deployment ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"ref": "main", "environment": "production", "description": "Deploy 1.4.0", "required_contexts": ["ci/build"]}' ``` | Field | Default | Description | |---|---|---| | `ref` | required | A branch, tag or commit SHA. Missing: `422 ref is required`. Unknown: `422 no ref found for: <ref>`. | | `environment` | `production` | The environment's name. It's created if it doesn't exist. | | `task` | `deploy` | Free text. Only the first 100 characters are kept. | | `payload` | `{}` | Any JSON value, or a string. Returned as it was sent. | | `description` | `null` | Free text. Only the first 1,000 characters are kept. | | `required_contexts` | every context | See [Required contexts](#required-contexts). Up to 100 strings. | | `transient_environment` | `false` | Marks the environment as temporary (it's skipped by `auto_inactive`). | | `production_environment` | `true` when `environment` is `production`, in any case | Marks a production deployment (it's skipped by `auto_inactive`). | | `auto_merge` | | Accepted and ignored. Diffinery never merges the default branch into `ref`. | The response is `201 Created` with the deployment (some user fields left out here): ```json { "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7", "id": 7, "node_id": "RGVwbG95bWVudDphY21lL3dlYnNpdGUvNw", "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "payload": {}, "original_environment": "production", "environment": "production", "description": "Deploy 1.4.0", "creator": { "login": "diffy", "type": "User" }, "created_at": "2026-10-13T09:30:00Z", "updated_at": "2026-10-13T09:30:00Z", "statuses_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses", "repository_url": "https://diffinery.com/api/v3/repos/acme/website", "transient_environment": false, "production_environment": true, "performed_via_github_app": null } ``` ### Required contexts Before creating a deployment, Diffinery checks the commit's checks and commit statuses, as GitHub does: - **`required_contexts` left out:** every check and status context reported on the commit must have passed. Contexts reported only by agents aren't counted. - **A list of names:** each named context must have passed. A name that nothing reported counts as not passed. - **`[]`:** nothing is checked. A context passes when its result is `success`, `neutral` or `skipped`. When several reporters use the same name, the same rules as for required checks in branch protection apply: Diffinery Actions decides a check it reported, any failure fails, anything unfinished is pending, and reports from agents never count. See [Checks and commit statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). If a context hasn't passed, the request fails with `409`: ```json { "message": "Conflict: Commit status checks failed for main.", "errors": [ { "contexts": [{ "context": "ci/build", "state": "pending" }], "resource": "Deployment", "field": "required_contexts" } ], "documentation_url": "https://docs.github.com/rest" } ``` `state` is `failure`, `pending` or `missing`. Wait for the checks to finish, or pass `required_contexts` with only the contexts you care about. Deployments that workflow jobs create for `environment:` skip this check. ### List, get and delete deployments ```sh # Newest first; filter by sha, ref, task or environment (exact match) curl "https://diffinery.com/api/v3/repos/acme/website/deployments?environment=production&per_page=10" \ -H "Authorization: Bearer dfy_..." curl https://diffinery.com/api/v3/repos/acme/website/deployments/7 \ -H "Authorization: Bearer dfy_..." curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/deployments/7 \ -H "Authorization: Bearer dfy_..." ``` - The list takes `per_page` (default 30, at most 100) and `page`, and sends a `Link` header. - The `ref` filter compares the stored ref exactly. Deployments created by workflow jobs store the branch or tag name (`main`, `v1.4.0`), not `refs/heads/main`. - A missing deployment returns `404 deployment not found`. - Deleting needs the write role and returns `204 No Content`. A deployment whose latest status is `success` can't be deleted while other deployments to the same environment exist: `422 We cannot delete an active deployment unless it is the only deployment in a given environment.` Post an `inactive` status first. ### Deployment statuses ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"state": "success", "log_url": "https://ci.example.com/builds/812", "environment_url": "https://www.example.com", "description": "Deployed"}' ``` | Field | Description | |---|---| | `state` | Required: `error`, `failure`, `inactive`, `in_progress`, `queued`, `pending` or `success`. Anything else fails with `422 state must be one of error, failure, inactive, in_progress, queued, pending, success`. | | `log_url` | Link to the deployment's output. `target_url` is accepted as an older name for it. | | `environment_url` | Link to the deployed environment. | | `description` | Up to 140 characters; longer text is cut. | | `environment` | Moves the deployment to another environment (created if needed). The deployment's `original_environment` keeps the first name. | | `auto_inactive` | Default `true`. With a `success` state, older deployments to the same environment whose latest status is `success` get an `inactive` status, except transient and production deployments. Deployments created after this one are left alone. Send `false` to keep them all. | URLs must start with `http://` or `https://` (`422 URLs must be http(s)`). The response is `201 Created`: ```json { "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses/15", "id": 15, "node_id": "RGVwbG95bWVudFN0YXR1czphY21lL3dlYnNpdGUvMTU", "state": "success", "creator": { "login": "diffy", "type": "User" }, "description": "Deployed", "environment": "production", "target_url": "https://ci.example.com/builds/812", "log_url": "https://ci.example.com/builds/812", "environment_url": "https://www.example.com", "created_at": "2026-10-13T09:34:10Z", "updated_at": "2026-10-13T09:34:10Z", "deployment_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7", "repository_url": "https://diffinery.com/api/v3/repos/acme/website", "performed_via_github_app": null } ``` List a deployment's statuses, newest first, with `GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses` (`per_page`, `page`), and get one with `GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` (`404 deployment status not found` if it doesn't exist). ## Endpoint summary All paths are under `https://diffinery.com/api/v3`. Every endpoint is also in the [API reference](https://diffinery.com/docs/api). | Method and path | Needs | |---|---| | `GET /repos/{owner}/{repo}/deployments` | read | | `POST /repos/{owner}/{repo}/deployments` | write | | `GET /repos/{owner}/{repo}/deployments/{id}` | read | | `DELETE /repos/{owner}/{repo}/deployments/{id}` | write | | `GET /repos/{owner}/{repo}/deployments/{id}/statuses` | read | | `POST /repos/{owner}/{repo}/deployments/{id}/statuses` | write | | `GET /repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` | read | | `GET /repos/{owner}/{repo}/environments` | read | | `GET /repos/{owner}/{repo}/environments/{env}` | read | | `PUT /repos/{owner}/{repo}/environments/{env}` | admin, people only | | `DELETE /repos/{owner}/{repo}/environments/{env}` | admin, people only | | `GET /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | read | | `POST /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | admin, people only | | `DELETE /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies/{id}` | admin, people only | | `GET /repos/{owner}/{repo}/environments/{env}/secrets` and `.../secrets/{name}` | admin | | `PUT` and `DELETE /repos/{owner}/{repo}/environments/{env}/secrets/{name}` | admin, people only | | `GET /repos/{owner}/{repo}/environments/{env}/variables` and `.../variables/{name}` | write | | `POST /repos/{owner}/{repo}/environments/{env}/variables` | admin, people only | | `PATCH` and `DELETE /repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin, people only | | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments` | read | | `POST /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments` | a listed reviewer (people only, `write` token) | | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/approvals` | read | The token's scope caps the role: a `read` token only reads (any other request gets `403 this token needs the write scope`), a `write` token also writes, and only an `admin` token reaches the admin endpoints. Requests without a token can read the environments and deployments of a public repository, but any change gets `401 authentication required`. On an archived repository, every request other than a read fails with `403 repository is archived (read-only)`. ## Webhooks Repository webhooks receive three events, with the GitHub event names in the `X-GitHub-Event` header. In the web UI's **Add a webhook** form, choose **Everything** to receive them; with the API, list `deployment`, `deployment_status` and `deployment_review` in the webhook's `events`. See [Webhooks](https://diffinery.com/docs/repositories/webhooks). ### deployment Sent when a deployment is created with the REST API: ```json { "action": "created", "deployment": { "id": 7, "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "environment": "production", "description": "Deploy 1.4.0", "payload": {}, "created_at": "2026-10-13T09:30:00.000Z" }, "repository": { "full_name": "acme/website" }, "sender": { "login": "diffy", "type": "User" } } ``` Deployments that workflow jobs create for `environment:` send this event too, when the job starts. Their `description` is `null`, `task` is `deploy` and `payload` is `{}`. ### deployment_status Sent for every status created with the REST API or by a workflow job (the `in_progress` status when the job starts and the final status when it ends): ```json { "action": "created", "deployment_status": { "id": 15, "state": "success", "description": "", "environment": "production", "environment_url": "https://www.example.com", "log_url": "https://diffinery.com/acme/website/actions/runs/01J9Z.../job/01J9Z...", "created_at": "2026-10-13T09:34:10.000Z" }, "deployment": { "id": 7, "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "environment": "production" }, "repository": { "full_name": "acme/website" }, "sender": { "login": "diffy", "type": "User" } } ``` The `inactive` statuses that `auto_inactive` adds to older deployments don't send events. The `repository` and `sender` objects have more fields than shown; see [Webhook events](https://diffinery.com/docs/reference/webhook-events). ### deployment_review Sent when a run starts waiting on an environment's required reviewers (`requested`), and when a reviewer decides (`approved` or `rejected`). The payload has `environment`, `environment_id`, `comment`, `workflow_run` (`id`, `run_number`, `name`, `display_title`, `head_sha`, `head_branch`, `html_url`), `reviewers`, and `requestor` (requested) or `approver` (approved and rejected). Workflows with `on: deployment` or `on: deployment_status` are accepted but never started. To run something after a deployment, use `needs:` on a later job, or a webhook. ## Agents and environments Environments guard deployment credentials, so agents are limited: - Agents can't create, change or delete environments or their branch policies, whatever their role. An agent with the write role gets `403 admin access required`; one that was given the admin role gets `403 only repository admins (people, not agents) can manage environments`. - Agents can't set or delete environment secrets: an agent with the admin role gets `403 agents cannot manage secrets`. - Agents can't create, change or delete environment variables: an agent with the admin role gets `403 agents cannot manage variables`. - Runs whose code came from an agent only get environment secrets from environments set to protected branches only, and never on pull request refs (see [Who receives environment secrets](#who-receives-environment-secrets)). - Tags that an agent created or moved never match an environment's tag patterns. - Agents with write access can create deployments and deployment statuses with the API (with a `write` token), and can read environments and environment variables. Together with branch protection (agents can't push to protected branches unless a rule allows it, and pull requests opened by agents need a human approval), this means an agent can't get production credentials into code that no person reviewed. The exception is a branch whose rule has **Let agents' auto-merges land without an approving review** on (see [Waiving approvals for agents](https://diffinery.com/docs/repositories/branch-protection#waiving-approvals-for-agents)): there an agent's pull request lands on green required checks alone, and the runs that follow on that branch receive the secrets of environments limited to protected branches. Turn that waiver on only where the required checks are the review you rely on. See [Agents](https://diffinery.com/docs/agents/overview). ## Audit log Changes to environments made with the API are recorded in the audit log, without secret values: | Action | When | |---|---| | `environment.update` | An environment is created or updated with `PUT` or in Settings (with the new branch policy, reviewers, wait timer and self-review setting). | | `environment.delete` | An environment is deleted. | | `environment.branch_policy.create` | A branch or tag pattern is added. | | `environment.branch_policy.delete` | A pattern is removed. | | `environment.secret.set` | An environment secret is created or replaced (name only). | | `environment.secret.delete` | An environment secret is deleted. | | `deployment.approve`, `deployment.reject` | A required reviewer approves or rejects a run's environments (with the comment). | Environment variable changes aren't recorded. See [Audit log](https://diffinery.com/docs/security/audit-log). ## What isn't supported - **Custom deployment protection rules** (GitHub Apps that gate deployments) and **team reviewers**. Diffinery has no teams; list people. - **Admin bypass.** Nobody skips an environment's rules. - **Web UI for branch patterns, environment secrets and variables.** Settings → Environments edits the protection rules; the rest is managed with the REST API. - **Reading or updating a single branch pattern.** You can list, add and delete patterns, but not `GET` or `PUT` one by id. - **Sealed-box secrets.** Send `value`, not `encrypted_value`. - **Organization-level environments and environment secrets.** Environments belong to one repository. - **`on: deployment` and `on: deployment_status` workflow triggers.** Accepted but never started. ## Differences from GitHub | Area | GitHub | Diffinery | |---|---|---| | Managing environments | Web UI and API | Protection rules, deployment branches, patterns and secrets in Settings → Environments and the API; variables with the API | | Required reviewers | People and teams | People only, never agents; an agent or a job token can't approve | | Prevent self-review | Covers whoever triggered the run | Also covers earlier attempts' starters and the owner of an agent that started it | | Admin bypass | Admins can bypass protection rules | Nobody bypasses them; `can_admins_bypass` is `false` | | Default branch policy | All branches | All refs, until the first secret is added; then protected branches only | | Protected branches | Branches with protection rules | Branches matched by a branch protection rule; the default branch counts only when a rule matches it | | Pull request runs | Fork pull requests get no secrets | No `pull_request` run gets environment secrets | | Agents | No equivalent | Agents can't manage environments, get environment secrets only under "protected branches only", and their tags never match tag patterns | | `environment.url` | Can use `steps` outputs | `steps` references are ignored | | Deployment `auto_merge` | Merges the default branch into the ref | Ignored | | Branch patterns | `fnmatch` syntax with character classes | `*`, `**` and `?` only, unlike workflow filters | ## Troubleshooting | Problem | Cause and fix | |---|---| | `<ref> is not allowed to deploy to <env> due to environment protection rules.` | The environment's branch policy doesn't allow the run's ref. Check the policy with `GET .../environments/<env>`: with protected branches only, add a branch protection rule for the branch; with custom patterns, add a matching pattern. A tag last pushed by an agent never matches. | | The deploy job fails on a pull request | Pull request refs never pass a branch policy. Add `if: github.event_name == 'push'` (or a branch check) to the deploy job. | | `invalid environment name ''` | `environment.name` evaluated to an empty string, for example because it uses `steps`, or a `needs` output that's empty. Use `inputs`, `vars`, `matrix`, `needs` or a literal. | | An environment secret is empty in the job | The run is on a pull request ref, or the run's code came from an agent and the environment isn't set to protected branches only, or the name is wrong. Variables are still set. | | A new environment suddenly refuses a branch | Adding its first secret set the policy to protected branches only. Add a branch protection rule, or switch to custom patterns. | | `403 only repository admins (people, not agents) can manage environments` | Use a person's token with the `admin` scope and the admin role on the repository. | | `403 agents cannot manage secrets` | Use a person's token. | | `403 agents cannot manage variables` | Agents can't change environment variables. Use a person's token. | | `403 this token needs the write scope` | The token has only the `read` scope. Use a `write` token for deployments and statuses, or an `admin` token for environment settings. | | A job stays **Waiting for approval** | The environment has required reviewers: one of them approves on the run page or with `pending_deployments`. With a wait timer, the job starts when it ends. | | `422 reviewers[0]: no such person with access to this repository` | GitHub's numeric user ids don't carry over. Use `{"type": "User", "login": "..."}` with a Diffinery login. | | `422 set exactly one of protected_branches and custom_branch_policies` | Set one to `true` and the other to `false`, or send `null` to remove the policy. | | `422 enable custom_branch_policies on the environment first` | Set `custom_branch_policies: true` with `PUT` before adding patterns. | | `409 Conflict: Commit status checks failed for <ref>.` | A context on the commit hasn't passed. Wait for it, or pass `required_contexts` (`[]` to skip the check). | | `422 We cannot delete an active deployment unless it is the only deployment in a given environment.` | Post an `inactive` status to the deployment first. | | `403 Resource not accessible by integration` or `403 write access required` when a workflow step creates a deployment | The job lacks `deployments: write`. Add it to the job's `permissions:`, or use `environment:` on the job. | | `403 admin access required` | Managing environments, branch patterns, environment secrets and variable changes needs the admin role and an `admin`-scoped token. | | The deployment has no environment URL | `environment.url` used `steps`, or wasn't an `http(s)` URL. | --- # Deploy to Cloudflare Workers > Deploy a Cloudflare Worker from Diffinery on every push to main, with a Preview per pull request, or let Workers Builds build it through a Cloudflare Artifacts mirror. Source: https://diffinery.com/docs/actions/deploy-to-cloudflare-workers Cloudflare's own build service, Workers Builds, connects to GitHub, GitLab, Cursor Origin and Cloudflare Artifacts repositories (as of October 2026), so a Diffinery repository doesn't show up under **Connect to a repository** in the Cloudflare dashboard yet. You don't need it there to deploy automatically. This page shows two ways that work today: | | [Deploy with Diffinery Actions](#deploy-with-diffinery-actions) (recommended) | [Mirror to Cloudflare Artifacts](#mirror-to-cloudflare-artifacts-for-workers-builds) | |---|---|---| | Who builds | A Diffinery Actions job runs `wrangler deploy` | Workers Builds, after Diffinery pushes a copy of the branch to Artifacts | | Result on the pull request | The job is a check on the pull request, and a comment links the Preview | Nothing; build results are in the Cloudflare dashboard | | Previews | One per pull request, removed when it closes | One per branch | | Production branch | Any branch you choose | `main` only | | Cloudflare plan | Any | Workers Paid (Artifacts isn't on the Free plan) | | Cloudflare credentials in Diffinery | An API token that can edit Workers | An API token that can edit Artifacts | Both use the same Wrangler configuration as any other Worker, so a project that already deploys from GitHub Actions or Workers Builds moves over without changes to its code. ## Deploy with Diffinery Actions A workflow deploys `main` to production and gives each pull request its own [Worker Preview](https://developers.cloudflare.com/workers/previews/), with the Preview's address commented on the pull request. This is how Diffinery deploys itself. ### 1. Create a Cloudflare API token 1. In the Cloudflare dashboard, open **Manage account → Account API tokens** and select **Create Token**. 2. Use the **Edit Cloudflare Workers** template. 3. Under **Account Resources**, include only the account the Worker deploys to. Under **Zone Resources**, include only the zones whose routes the Worker uses (or none, for a `workers.dev` Worker). 4. Create the token and copy it. Copy your **Account ID** too (it's on the account's home page, and in `npx wrangler whoami`). If the Worker uses other resources, such as D1, R2 or Queues, add **Edit** for each of them to the token. ### 2. Store the token in Diffinery Under **Settings → Secrets and variables** of the repository, add two repository secrets: | Name | Value | |---|---| | `CLOUDFLARE_API_TOKEN` | The token from step 1 | | `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | Or from a terminal, with the [Diffinery CLI](https://diffinery.com/docs/actions/secrets-and-variables#secrets-with-the-cli) signed in with the `admin` scope: ```sh diffinery secret set CLOUDFLARE_API_TOKEN diffinery secret set CLOUDFLARE_ACCOUNT_ID ``` Pull request runs get repository secrets, because every pull request on Diffinery comes from a branch of the same repository, pushed by someone with write access (see [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables)). That is what lets the preview job below deploy a Preview. If you'd rather keep the production token away from pull request runs, see [Keep the production token in an environment](#keep-the-production-token-in-an-environment). ### 3. Allow Previews in the Wrangler configuration Worker Previews need Wrangler 4.135.0 or later in the project, and a `previews` block in the Wrangler configuration (it can be empty). Settings in `previews` apply to Previews only, so a Preview can use its own variables and resources: ```sh npm i -D wrangler@latest ``` ```jsonc // wrangler.jsonc { "name": "my-worker", "main": "src/index.ts", "compatibility_date": "2026-10-01", "vars": { "ENVIRONMENT": "production" }, "previews": { "vars": { "ENVIRONMENT": "preview" } } } ``` Cloudflare's [Previews configuration](https://developers.cloudflare.com/workers/previews/configuration/) explains which settings belong in `previews`. Without Previews, leave out the `preview` and `cleanup` jobs below and keep `deploy`. ### 4. Add the workflow Commit this as `.github/workflows/cloudflare.yml` (Diffinery reads workflows from `.github/workflows/` and `.diffinery/workflows/`). Replace `npm ci` with your project's install and build commands: ```yaml name: Cloudflare on: push: branches: [main] pull_request: types: [opened, synchronize, reopened, closed] permissions: contents: read jobs: deploy: name: Deploy to production if: github.event_name == 'push' runs-on: ubuntu-latest timeout-minutes: 15 concurrency: group: cloudflare-production cancel-in-progress: false steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} preview: name: Preview if: github.event_name == 'pull_request' && github.event.action != 'closed' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read pull-requests: write concurrency: group: cloudflare-preview-${{ github.event.pull_request.number }} cancel-in-progress: true steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - name: Create or update the Preview id: preview run: | set -euo pipefail out=$(npx wrangler preview --name "pr-$PR" --json) url=$(printf '%s' "$out" | jq -er '.preview.urls[0]') echo "url=$url" >> "$GITHUB_OUTPUT" echo "Preview: $url" >> "$GITHUB_STEP_SUMMARY" env: PR: ${{ github.event.pull_request.number }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - name: Link the Preview on the pull request run: | set -euo pipefail api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/issues" auth=(-H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json") marker='<!-- cloudflare-preview -->' text=$(printf '%s\n**Cloudflare Preview:** %s (commit %s)' "$marker" "$URL" "${SHA:0:7}") body=$(jq -n --arg b "$text" '{body: $b}') # The job's own earlier comment, if any: one comment per pull request, kept up to date. id=$(curl -fsS "${auth[@]}" "$api/$PR/comments?per_page=100" \ | jq -r --arg m "$marker" '[.[] | select(.user.login == "diffinery-actions[bot]" and (.body | startswith($m)))][0].id // empty') if [ -n "$id" ]; then curl -fsS -X PATCH "${auth[@]}" "$api/comments/$id" -d "$body" > /dev/null else curl -fsS -X POST "${auth[@]}" "$api/$PR/comments" -d "$body" > /dev/null fi env: PR: ${{ github.event.pull_request.number }} URL: ${{ steps.preview.outputs.url }} SHA: ${{ github.event.pull_request.head.sha }} TOKEN: ${{ secrets.GITHUB_TOKEN }} cleanup: name: Delete the Preview if: github.event_name == 'pull_request' && github.event.action == 'closed' runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx wrangler preview delete --name "pr-$PR" --skip-confirmation env: PR: ${{ github.event.pull_request.number }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} ``` What happens: - **Pushes to `main`** run **Deploy to production**, which runs `wrangler deploy`. Deploys take turns in the `cloudflare-production` group, and when several are waiting only the newest runs, so two merges in a row never deploy out of order. - **Opening or updating a pull request** runs **Preview**. It creates or updates the Preview named `pr-<number>` and keeps one comment on the pull request up to date with the Preview's address. The job is a check on the pull request, so a failed build shows there, and you can make it a [required check](https://diffinery.com/docs/repositories/branch-protection) for merging. A newer push cancels a Preview build that's still running for the same pull request. - **Closing or merging a pull request** runs **Delete the Preview**, which removes it from Cloudflare. The comment step uses the REST API directly, because `gh` on a runner only reads `GH_TOKEN` for github.com. To use `gh pr comment` instead, set `GH_HOST: diffinery.com` and `GH_ENTERPRISE_TOKEN: ${{ secrets.GITHUB_TOKEN }}` on the step. `cloudflare/wrangler-action` works too, if you prefer it to running `npx wrangler` yourself: actions from GitHub run on Diffinery's runners (see [Compatibility](https://diffinery.com/docs/actions/compatibility)). ### Keep the production token in an environment Repository secrets go to every run, including pull request runs, so anyone who can push a branch can run code that reads them. To keep the production deploy token for reviewed code only, put it in an [environment](https://diffinery.com/docs/actions/environments) instead: 1. Create an environment named `production` and add `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` to it as environment secrets, with the REST API as shown in [Environment secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables#environment-secrets-and-variables). Adding the first secret limits the environment to protected branches, so protect `main` if it isn't already. Optionally add required reviewers under **Settings → Environments**, so each deploy waits for an approval. 2. Add `environment: production` to the `deploy` job. 3. For Previews, create a second API token and store it as the repository secret `CLOUDFLARE_PREVIEW_API_TOKEN`; use it in the `preview` and `cleanup` jobs. Environment secrets never go to pull request runs, so the production token stays with jobs on protected branches. A Workers API token can't be limited to Previews, so this keeps the two tokens apart and lets you revoke the preview token on its own; it doesn't stop the preview token from deploying. ## Mirror to Cloudflare Artifacts for Workers Builds If you'd rather have Workers Builds run your builds, with its build history and logs in the Cloudflare dashboard, keep developing on Diffinery and let a workflow push each branch to a [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/) repository. Workers Builds can connect to Artifacts, so it builds and deploys `main` and creates Previews for other branches. Know the limits first: Artifacts needs the Workers Paid plan and bills per operation and per GB stored (the first 10,000 operations and 1 GB each month are included); a repository holds at most 1 GB; only `main` can be the production branch; and Workers Builds doesn't report back to Diffinery, so build results stay in the Cloudflare dashboard. ### 1. Create the Artifacts repository With Wrangler signed in to your Cloudflare account: ```sh npx wrangler artifacts repos create my-worker --namespace default --default-branch main ``` ### 2. Store a token that can push to it Create an account API token with the **Artifacts Edit** permission, and add it to the Diffinery repository as the secret `CLOUDFLARE_ARTIFACTS_TOKEN`, with your account ID as `CLOUDFLARE_ACCOUNT_ID`. The workflow below uses it to mint a short-lived push token for the one repository on each run, so no long-lived git password is stored anywhere. ### 3. Add the mirror workflow ```yaml name: Mirror to Cloudflare Artifacts on: push: branches: ['**'] permissions: contents: read concurrency: group: artifacts-mirror-${{ github.ref }} cancel-in-progress: false jobs: mirror: runs-on: ubuntu-latest timeout-minutes: 10 env: NAMESPACE: default REPO: my-worker steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Push to Artifacts run: | set -euo pipefail base="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/artifacts/namespaces/$NAMESPACE" auth=(-H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json") remote=$(curl -fsS "${auth[@]}" "$base/repos/$REPO" | jq -er .result.remote) token=$(curl -fsS -X POST "${auth[@]}" "$base/tokens" \ -d "$(jq -n --arg r "$REPO" '{repo: $r, scope: "write", ttl: 600}')" | jq -er .result.plaintext) echo "::add-mask::$token" git -c http.extraHeader="Authorization: Bearer $token" push --force "$remote" "HEAD:refs/heads/$GITHUB_REF_NAME" env: CF_TOKEN: ${{ secrets.CLOUDFLARE_ARTIFACTS_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} ``` Each push to a branch pushes that branch to Artifacts. Pushes to the same branch take turns, and when several are waiting only the newest runs, so Artifacts always ends up with the branch's latest commit. Deleting a branch on Diffinery doesn't delete it in Artifacts (workflows don't run for deletions); remove old branches there with `git push <remote> --delete <branch>` and a token from `npx wrangler artifacts repos issue-token`. The tokens the workflow mints last 10 minutes. Change `NAMESPACE` and `REPO` to match step 1. ### 4. Connect Workers Builds In the Cloudflare dashboard, open **Workers & Pages**, select your Worker (or **Create application → Continue with Artifacts** for a new one), then **Settings → Builds → Connect**, and choose the Artifacts namespace and repository. To get Previews for branches, turn on **Builds for Preview branches** under **Settings → Builds**. Cloudflare's [Artifacts integration guide](https://developers.cloudflare.com/workers/ci-cd/builds/git-integration/artifacts-integration/) has the details. From then on, merging to `main` on Diffinery deploys the Worker through Workers Builds. ## Troubleshooting | Problem | Fix | |---|---| | `Authentication error [code: 10000]` from Wrangler | The API token is missing a permission, or doesn't include the Worker's account or zone. Edit the token in the Cloudflare dashboard. | | `wrangler preview` is an unknown command | The project's Wrangler is older than 4.135.0. Run `npm i -D wrangler@latest` and commit the lockfile. | | The preview job deploys, but no comment appears | The job needs `pull-requests: write` in its `permissions:`. | | Secrets are empty in the preview job | The secrets are environment secrets, which pull request runs never receive. Store the preview token as a repository secret. | | The mirror push fails with `403` | The Artifacts token is missing **Artifacts Edit**, or `NAMESPACE` and `REPO` don't name an existing repository. | ## Related - [Deployments and environments](https://diffinery.com/docs/actions/environments): approvals, branch rules and deployment history for production. - [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables): which runs receive which secrets. - [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions): what `GITHUB_TOKEN` may do. - Cloudflare's [GitHub Actions guide](https://developers.cloudflare.com/workers/ci-cd/external-cicd/github-actions/) and [Previews examples](https://developers.cloudflare.com/workers/previews/examples/), which the workflow above follows. --- # OpenID Connect (OIDC) > Let workflows authenticate to AWS, Google Cloud and your own services with short-lived OIDC tokens issued by Diffinery, instead of stored keys. Source: https://diffinery.com/docs/actions/oidc A workflow job can ask Diffinery for an OpenID Connect ID token: a signed JWT that says which repository, branch, commit and workflow the job belongs to. A cloud provider or your own service that trusts Diffinery's issuer can exchange that token for short-lived credentials, so you don't store long-lived cloud keys as secrets. This works like GitHub Actions' OIDC, with Diffinery as the issuer; the differences are listed in [Differences from GitHub](#differences-from-github). ## Requirements - The job's effective permissions must include `id-token: write`. A job's own `permissions:` replaces the workflow's top-level `permissions:` entirely, as on GitHub, so a job that sets `permissions:` must list `id-token: write` itself. `permissions: write-all` includes it. - The repository's default permissions never include `id-token`, and neither does `read-all`. `id-token: read` grants nothing. - It works on hosted and self-hosted runners. - In a reusable workflow, the called jobs get the calling job's permissions, and anything the called workflow or its jobs declare is lowered to the calling job's permissions. So the calling job's effective permissions must include `id-token: write`, and so must the called workflow's or job's `permissions:` when it sets one. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions#reusable-workflows). ```yaml permissions: id-token: write contents: read ``` This job gets no OIDC token, because its own `permissions:` replaces the workflow's: ```yaml permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest permissions: contents: read # id-token is now none for this job steps: - run: echo "no ACTIONS_ID_TOKEN_REQUEST_URL here" ``` Without the permission, the variables below aren't set, and `core.getIDToken()` in an action fails because `ACTIONS_ID_TOKEN_REQUEST_URL` is missing. ## The issuer | Item | Value | |---|---| | Issuer (`iss`) | `https://diffinery.com` | | Discovery document | `https://diffinery.com/.well-known/openid-configuration` | | Signing keys (JWKS) | `https://diffinery.com/.well-known/jwks` | | Algorithm | `ES256` (ECDSA P-256 with SHA-256) | | Token lifetime | 10 minutes | The discovery document: ```json { "issuer": "https://diffinery.com", "jwks_uri": "https://diffinery.com/.well-known/jwks", "response_types_supported": ["id_token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["ES256"], "claims_supported": ["sub", "aud", "iss", "repository", "repository_id", "repository_owner", "repository_owner_id", "environment", "agent_origin", "ref", "ref_type", "sha", "workflow", "event_name", "actor", "actor_id", "run_id"] } ``` The JWKS document holds the public key as an EC JWK with `"use": "sig"`, `"alg": "ES256"` and a `kid`, which matches the `kid` in each token's header. ## Requesting a token When a job has `id-token: write`, the runner sets two environment variables, the same ones GitHub sets: | Variable | Value | |---|---| | `ACTIONS_ID_TOKEN_REQUEST_URL` | `https://diffinery.com/runner/jobs/<job id>/oidc?api-version=2.0` | | `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | The job's token, to send as a bearer token | Actions that call `core.getIDToken(audience)` from `@actions/core`, such as `aws-actions/configure-aws-credentials` and `google-github-actions/auth`, use them automatically. From a shell step, append `&audience=<value>` and send a `GET` (or `POST`): ```yaml - name: Get an OIDC token run: | token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.example.com" | jq -r .value) echo "::add-mask::$token" echo "OIDC_TOKEN=$token" >> "$GITHUB_ENV" ``` The response is `{"value": "<JWT>"}`. Without `audience`, the token's `aud` is `https://diffinery.com`. Mask the token with `::add-mask::` before doing anything else with it: it isn't a stored secret, so the runner doesn't know to hide it. Errors from the endpoint: | Status and message | Cause | |---|---| | `403 this job lacks permissions: id-token: write` | The job doesn't have the permission. | | `409 job is not running` | The job has finished, or hasn't started. | | `401 job token required` / `401 token does not match job` | The request didn't send this job's token. | | `501 OIDC is not configured on this Diffinery instance` | The instance has no signing key. A deploy creates one (`OIDC_PRIVATE_KEY`, see [Worker secrets](https://diffinery.com/docs/self-hosting/configuration#worker-secrets)); ask the instance's administrators. | ## Claims Every token has these claims. All values are strings except the timestamps. | Claim | Example | Meaning | |---|---|---| | `iss` | `https://diffinery.com` | Issuer. | | `aud` | `sts.amazonaws.com` | The requested audience, or `https://diffinery.com` if none was given. A single string. | | `sub` | `repo:acme/website:ref:refs/heads/main` | As on GitHub: `repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for a `pull_request` run, and `repo:<owner>/<repo>:ref:<ref>` otherwise. | | `iat`, `nbf` | `1760000000` | Issue time and not-before time (the same). | | `exp` | `1760000600` | Expiry, 10 minutes after issue. | | `repository` | `acme/website` | Repository that runs the workflow. | | `repository_id` | `2918374650` | The repository's numeric id. It stays the same when the repository is renamed or transferred, and a new repository with an old name gets a new one. | | `repository_owner` | `acme` | Its owner (user or organization). | | `repository_owner_id` | `3482910375` | The owner's numeric id, which a new account with a reused name doesn't get. | | `environment` | `production` | The environment the job deployed to. Only on jobs with `environment:`, once its protection rules passed, and only when the job may also have the environment's secrets. | | `agent_origin` | `false` | Diffinery's own claim: `true` when the code the run executes came from an agent (an agent pushed it, or merged it). | | `ref_type` | `branch` | `branch` or `tag`. | | `head_ref`, `base_ref` | `feature`, `main` | The pull request's branches, on pull request runs only. | | `run_number`, `run_attempt` | `128`, `1` | The run's number and attempt. | | `ref` | `refs/heads/main` | The run's ref. See the table below. | | `sha` | `9fceb02d0ae598e95dc970b74767f19372d61af8` | The commit the run checked out. | | `workflow` | `Deploy` | The workflow's `name`, or its file path when it has none. | | `event_name` | `push` | The event that started the run. | | `run_id` | `128` | The run number, as a string. It's the same value as `github.run_id`, and stays the same when the run is re-run. | | `actor` | `diffy` | Who started the run. | | `actor_id` | `1938475620` | Their numeric id. | | `job_workflow_ref` | `acme/website/.github/workflows/deploy.yml@refs/heads/main` | The run's top-level workflow file and ref. | The token header is `{"alg": "ES256", "typ": "JWT", "kid": "..."}`. The `ref` for each event. It is also the end of `sub`, except in a job with `environment:` and in a `pull_request` run: | Event | `ref` | |---|---| | `push` | The pushed branch or tag, for example `refs/heads/main` or `refs/tags/v1.2.0` | | `pull_request` | `refs/pull/<number>/merge` | | `pull_request_target` | The base branch, for example `refs/heads/main` | | `merge_group` | The merge queue's temporary branch | | `workflow_dispatch` | The branch or tag it was started on | | `schedule`, `workflow_run`, `repository_dispatch` | The default branch | | `release` | The release's tag, for example `refs/tags/v1.4.0` | > **Important:** `pull_request_target` runs carry the base branch's `ref` and `sub`, and so do `schedule`, `workflow_run` and `repository_dispatch` runs on the default branch. A trust policy that only checks `sub` treats them like pushes to that branch. They run the base branch's own workflow files, so this is safe unless such a workflow checks out and runs pull request code. Where your provider lets you, also check `event_name`. ## Jobs that use an environment A job with `environment:` gets GitHub's environment form of `sub`, `repo:<owner>/<repo>:environment:<name>`, and an `environment` claim. The token is only issued after the environment's deployment branch policy has passed, so a trust policy on `repo:acme/website:environment:production` only trusts jobs that were allowed to deploy to `production` (see [Deployments and environments](https://diffinery.com/docs/actions/environments)). A job that the environment lets deploy but keeps its secrets from, such as agent code on a branch the policy doesn't trust agents with, gets the `ref:` or `pull_request` subject instead. To keep agent code out of a role altogether, also require `agent_origin` to be `false`. To keep trusting a reused name out, also check `repository_id` and `repository_owner_id` where your provider lets you: a repository deleted and recreated, or an owner name taken by a new account, gets new ids. ## AWS AWS accepts Diffinery tokens through an IAM OIDC identity provider and a role whose trust policy names it. 1. Create the identity provider (once per AWS account), with provider URL `https://diffinery.com` and audience `sts.amazonaws.com`: ```sh aws iam create-open-id-connect-provider \ --url https://diffinery.com \ --client-id-list sts.amazonaws.com ``` 2. Create a role with this trust policy. Replace the account id, and narrow `sub` to the repositories and branches that may deploy: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/diffinery.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "diffinery.com:aud": "sts.amazonaws.com", "diffinery.com:sub": "repo:acme/website:ref:refs/heads/main" } } } ] } ``` To allow every branch and tag of the repository, use `"StringLike": { "diffinery.com:sub": "repo:acme/website:*" }` together with the `aud` check. That pattern also matches `pull_request` runs and jobs in any environment, so a role that deploys should name one `ref:` or `environment:` subject instead. Attach the permissions the workflow needs to the role. 3. Use it from a workflow: ```yaml name: Deploy to AWS on: push: branches: [main] permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/diffinery-website-deploy aws-region: eu-west-1 - run: pip install awscli # the AWS CLI isn't preinstalled on hosted runners - run: aws sts get-caller-identity - run: aws s3 sync ./dist s3://acme-website --delete ``` Without the action, exchange the token yourself: ```sh token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value) aws sts assume-role-with-web-identity \ --role-arn arn:aws:iam::123456789012:role/diffinery-website-deploy \ --role-session-name "diffinery-$GITHUB_RUN_ID" \ --web-identity-token "$token" ``` ## Google Cloud Google Cloud accepts Diffinery tokens through Workload Identity Federation. 1. Create a pool and an OIDC provider for the Diffinery issuer. The attribute condition limits which tokens are accepted at all: ```sh gcloud iam workload-identity-pools create diffinery \ --location=global --display-name="Diffinery" gcloud iam workload-identity-pools providers create-oidc diffinery \ --location=global \ --workload-identity-pool=diffinery \ --issuer-uri=https://diffinery.com \ --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref,attribute.event_name=assertion.event_name" \ --attribute-condition="assertion.repository_owner == 'acme'" ``` 2. Let one repository impersonate a service account (use your project number): ```sh gcloud iam service-accounts add-iam-policy-binding deployer@acme-prod.iam.gserviceaccount.com \ --role=roles/iam.workloadIdentityUser \ --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/diffinery/attribute.repository/acme/website" ``` 3. Use it from a workflow: ```yaml permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: google-github-actions/auth@v2 with: workload_identity_provider: projects/123456789012/locations/global/workloadIdentityPools/diffinery/providers/diffinery service_account: deployer@acme-prod.iam.gserviceaccount.com - uses: google-github-actions/setup-gcloud@v2 - run: gcloud run deploy website --source . --region europe-west1 ``` ## Your own service on Cloudflare Workers OIDC tokens also let a workflow call a service you run, such as a deploy hook on Cloudflare Workers, without a shared secret. The Worker verifies the token against Diffinery's published keys: ```ts import { createRemoteJWKSet, jwtVerify } from "jose"; const JWKS = createRemoteJWKSet(new URL("https://diffinery.com/.well-known/jwks")); export default { async fetch(request: Request): Promise<Response> { const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); if (!token) return new Response("missing token", { status: 401 }); try { const { payload } = await jwtVerify(token, JWKS, { issuer: "https://diffinery.com", audience: "https://deploy.acme.workers.dev", algorithms: ["ES256"], }); if (payload.repository !== "acme/website" || payload.ref !== "refs/heads/main" || payload.event_name !== "push") { return new Response("not allowed", { status: 403 }); } // ... do the deploy for payload.sha ... return Response.json({ ok: true, sha: payload.sha }); } catch { return new Response("invalid token", { status: 401 }); } }, }; ``` The workflow requests a token with the same audience and sends it: ```yaml permissions: id-token: write jobs: deploy: runs-on: ubuntu-latest steps: - run: | token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.acme.workers.dev" | jq -r .value) echo "::add-mask::$token" curl -fsS -X POST https://deploy.acme.workers.dev/ -H "Authorization: Bearer $token" ``` ## Verifying tokens yourself Any service can verify a Diffinery token. Check, in this order: 1. The header's `alg` is `ES256`. Reject anything else. 2. The signature, with the key from `https://diffinery.com/.well-known/jwks` whose `kid` matches the header. Cache the key set, and fetch it again when you see an unknown `kid`. 3. `iss` is exactly `https://diffinery.com`. 4. `aud` is the audience your service expects. Have workflows request an audience unique to your service, so a token meant for another service can't be replayed to yours. 5. `exp` is in the future and `nbf` is in the past (allow a little clock skew). 6. Your authorization rules: `repository`, `ref`, `event_name`, and if needed `sha` or `actor`. Match `repository` exactly, not by prefix. In Node.js with [jose](https://github.com/panva/jose), `jwtVerify` with `issuer`, `audience` and `algorithms: ["ES256"]` performs steps 1 to 5 (see the Worker example above). In Python with PyJWT: ```text pip install "pyjwt[crypto]" ``` ```text import jwt jwks = jwt.PyJWKClient("https://diffinery.com/.well-known/jwks") def verify(token: str) -> dict: key = jwks.get_signing_key_from_jwt(token).key claims = jwt.decode( token, key, algorithms=["ES256"], audience="https://deploy.example.com", issuer="https://diffinery.com", ) if claims["repository"] != "acme/website" or claims["ref"] != "refs/heads/main": raise PermissionError("not allowed") return claims ``` ## Differences from GitHub | Area | GitHub | Diffinery | |---|---|---| | Issuer | `https://token.actions.githubusercontent.com` | `https://diffinery.com` | | Algorithm | RS256 | ES256 | | `sub` | `environment:<name>`, `pull_request` or `ref:<ref>` form, and a customizable template | The same three forms; the template can't be customized | | Claims | Many, including `workflow_ref`, `job_workflow_sha`, `runner_environment` | Only the claims listed in [Claims](#claims) | | `job_workflow_ref` | The reusable workflow's file when the job is in one | Always the run's top-level workflow | | Token lifetime | About 5 to 10 minutes | 10 minutes | When you port a trust policy from GitHub, change the issuer host (`token.actions.githubusercontent.com` to `diffinery.com`) in provider URLs and condition keys, and keep your `sub` patterns as they are, unless they rely on a customized subject template. ## Troubleshooting | Problem | Fix | |---|---| | `Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable` from an action | The job's effective permissions lack `id-token: write`. Add it to the job's `permissions:` if the job has one, otherwise to the workflow's. In a reusable workflow, the calling job needs it too. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | AWS: `Not authorized to perform sts:AssumeRoleWithWebIdentity` | The trust policy's `sub` or `aud` doesn't match. Decode the token (its middle part is base64url JSON) and compare `sub` and `aud` with the policy. | | AWS: `No OpenIDConnect provider found in your account for https://diffinery.com` | Create the identity provider with URL `https://diffinery.com`. | | Google Cloud: `The given credential is rejected by the attribute condition` | The `attribute-condition` on the provider doesn't match the token's claims. | | Your service rejects the token as expired | Tokens last 10 minutes. Request a new one right before you use it. | --- # Caching and artifacts > Speed up workflows with actions/cache, pass files between jobs and runs with upload-artifact and download-artifact, and manage both through the API. Source: https://diffinery.com/docs/actions/caching-and-artifacts A cache keeps files such as dependency downloads between runs. An artifact keeps files produced by a run, so later jobs (in the same run or another one) can use them and you can download them afterwards. Diffinery serves the same cache and artifact services as GitHub, so the official `actions/cache`, `actions/upload-artifact` and `actions/download-artifact` work unmodified, and the runner also has faster built-in versions that share their storage and formats. ## Built-in actions and the official actions For speed, the runner runs its own implementation of these actions instead of downloading them: | Action | Built-in for | What it does | | --- | --- | --- | | `actions/cache` | `v3` to `v6` | Restores a cache entry, then saves one in a post step when the job ends | | `actions/cache/restore` | `v3` to `v6` | Restores a cache entry only | | `actions/cache/save` | `v3` to `v6` | Saves a cache entry only | | `actions/upload-artifact` | `v4` to `v7` | Uploads files as an artifact of the run | | `actions/download-artifact` | `v4` to `v8` | Downloads artifacts of this run, or of another run with `run-id` and `github-token` | The built-ins use the same services, archive formats, cache versions and artifact ids as the official actions. An entry saved by the built-in `actions/cache` restores with the official action and the other way round, and an artifact uploaded by either downloads with either. A built-in steps aside and the real action is downloaded from GitHub and run when: - the reference isn't a version tag in the range above (`vN`, `vN.N` or `vN.N.N`), for example a commit SHA, a branch, or a newer major version; - the step passes an input the built-in doesn't know; - the step's environment has `DIFFINERY_BUILTINS` set to `false`, `0`, `no` or `off` (in the workflow's, the job's or the step's `env:`); - a self-hosted runner was started with `DIFFINERY_DISABLE_BUILTINS=1`. ```yaml env: DIFFINERY_BUILTINS: false # every step in this workflow runs the real actions ``` The `cache` input of `actions/setup-node`, `actions/setup-python` and `actions/setup-go` uses the same service; see [Dependency caching with setup actions](#dependency-caching-with-setup-actions). Third-party actions that use the `@actions/cache` or `@actions/artifact` toolkit packages, and `docker/build-push-action` with `cache-from: type=gha` and `cache-to: type=gha`, work as well (see [The runtime services](#the-runtime-services)). ## Caching dependencies A cache entry is a compressed archive of one or more paths, stored under a key you choose. A typical workflow restores the entry at the start of a job and saves it at the end: ```yaml name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 id: npm-cache with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-${{ runner.os }}- - run: npm ci - run: npm test ``` On the first run there is no entry, so the step logs `Cache not found for input keys: ...` and the job continues. When the job succeeds, the post step **Post actions/cache@v4** archives `~/.npm` and saves it under the key (`Cache saved with key: <key>`). The next run with the same `package-lock.json` restores it (`Cache restored from key: <key>`). ### Inputs for actions/cache | Input | Required | Default | Description | | --- | --- | --- | --- | | `path` | Yes | | Files and directories to cache, one per line. Supports globs and `!` exclusions (`! pattern` is read as `!pattern`). Relative paths are relative to the workspace; `~/` expands to the home directory. | | `key` | Yes | | Key to save the entry under and to look up first. At most 512 characters, no commas. | | `restore-keys` | No | | Prefixes to try, one per line, in order, when `key` has no exact match. At most 20. | | `lookup-only` | No | `false` | Check whether an entry exists without downloading it. Logs `Cache found and can be restored from key: <key>`. | | `fail-on-cache-miss` | No | `false` | Fail the step when neither `key` nor any restore key matches. | | `save-always` | No | `false` | Save in the post step even when the job failed or was cancelled. | | `upload-chunk-size`, `enableCrossOsArchive` | No | | Accepted. The built-in uploads in 16 MiB blocks, 8 at a time (8 MiB blocks, 4 at a time, on the 1 GiB `tiny` runner), whatever `upload-chunk-size` says. | `actions/cache/restore` and `actions/cache/save` take the same inputs. If `key` or `path` is missing, the step fails with `Input required and not supplied: key` or `Input required and not supplied: path`. ### Outputs for actions/cache | Output | Set by | Value | | --- | --- | --- | | `cache-hit` | `actions/cache`, `actions/cache/restore` | `true` when the restored entry's key equals `key` (ignoring case), `false` when a restore key matched. Not set on a miss. | | `cache-primary-key` | `actions/cache/restore` | The `key` input, always. | | `cache-matched-key` | `actions/cache/restore` | The key of the entry that was restored. Not set on a miss. | Use `cache-hit` to skip work when the exact entry was restored. Compare with `'true'`, because the output is empty on a miss: ```yaml - uses: actions/cache@v4 id: deps with: path: node_modules key: modules-${{ hashFiles('package-lock.json') }} - if: steps.deps.outputs.cache-hit != 'true' run: npm ci ``` ### How keys and restore keys match An entry has a key, a version and a scope. The version is a hash of the `path` lines and the compression, computed the way `@actions/cache` computes it, so an entry only restores for the same paths saved with the same compression. The scope is the ref of the job that saved it. When a restore step runs, Diffinery searches these scopes in order, and stops at the first match: 1. The job's own ref, for example `refs/heads/feature-x`, `refs/tags/v1.2.0` or, for a `pull_request` run, `refs/pull/7/merge`. 2. For pull request runs, the pull request's base branch. For merge queue runs, the queue's target branch (`merge_group.base_ref`). 3. The default branch. In each scope it tries: 1. An entry whose key equals `key` exactly. 2. The newest entry whose key starts with `key`. 3. For each line of `restore-keys`, in order, the newest entry whose key starts with that line. ```yaml key: build-${{ github.ref_name }}-${{ hashFiles('**/go.sum') }} restore-keys: | build-${{ github.ref_name }}- build- ``` With this configuration, a run on a feature branch first looks for the exact key and the newest `build-` entry saved on that branch, then on the default branch. ### Scope A job saves only to its own ref. Branches, tags and pull requests read their own entries and those of the default branch (and pull requests those of their base branch), but never each other's. This is the same isolation GitHub has, so a pull request can't plant a cache entry that a release job on the default branch later restores. Different repositories never share entries, even within one organization. ### When the cache is saved `actions/cache` saves in a post step that runs after all other steps of the job: - It runs only if the job succeeded so far. With `save-always: true` it runs even after a failure or cancellation. - It's skipped when the restore found an exact match for `key`: `Cache hit occurred on the primary key <key>, not saving cache.` - It archives whatever exists at `path` at that moment. If none of the paths exist, it logs the warning `Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.` Entries are immutable. If an entry with the same key and version already exists in the job's scope, or another running job is saving it, the save logs `Failed to save: Unable to reserve cache with key <key>, another job may be creating this cache.` and the job continues. To refresh an entry, change its key, for example by including a hash of the lock file. An upload left unfinished by a job that has ended is discarded and the key can be saved again. `actions/cache/save` saves immediately when its step runs, so you can save a cache mid-job or from a step with its own `if:`: ```yaml - uses: actions/cache/restore@v4 id: restore with: path: .build-cache key: build-${{ github.sha }} restore-keys: build- - run: make build - uses: actions/cache/save@v4 if: always() with: path: .build-cache key: ${{ steps.restore.outputs.cache-primary-key }} ``` ### Archive format The built-in creates the same archive as `@actions/cache`: a POSIX tar file of the matched paths, stored relative to the workspace, compressed with zstd when `zstd` is installed (it is on hosted runners) and with gzip otherwise. Restoring extracts it to the same paths relative to the workspace. Restoring is built for speed. The runner downloads the archive in 8 MiB ranges, 16 at a time (4 on the 1 GiB `tiny` runner), and pipes them straight into `tar`, so the archive extracts while it is still arriving. On hosted runners a 216 MB pnpm store restores in about 3 to 5 seconds. Every range must come from the same stored entry; if one doesn't, or a range keeps failing after its retries, the log says `Streaming the cache entry failed (<reason>); downloading it in full and extracting it again.` and the runner downloads the whole archive to a file and extracts it again, which replaces anything the stream left half done. If that fails too, the restore removes what it had extracted and behaves like a miss, so a job never runs with half a cache. Artifact downloads use the same ranged download. Saving is built for speed too. Files of up to 32 MiB go up in one request; larger ones go up in 16 MiB blocks, 8 at a time (8 MiB blocks, 4 at a time, on the `tiny` runner). Diffinery then joins the blocks into one stored object. Objects over 16 MiB are joined as 8 MiB parts, copied by several workers at once, so a 200 MB artifact or cache entry is stored within seconds of its last block. The same applies to uploads from the official actions. - The compression is part of the version, so an entry saved on a runner with zstd doesn't restore on one without it. - A path outside the workspace, such as `~/.npm`, is stored relative to the workspace too. A runner whose workspace sits at a different depth restores it into a different directory. Include `runner.environment` or a label in the key if you mix runner types. - The step log shows the archive size as `Cache Size: ~<n> MB (<bytes> B)` on both save and restore. ### Size and eviction | Limit | Value | | --- | --- | | Key | 512 characters, no commas. A longer key or a comma fails the restore step with `Key Validation Error: <key> cannot be larger than 512 characters.` or `Key Validation Error: <key> cannot contain commas.` | | Restore keys | 20 per lookup | | One entry | 10 GB. A larger archive isn't saved: `Cache size of ~<n> MB (<bytes> B) is over the 10GB limit, not saving cache.` | | Repository total | 10 GB of entries, counting uploads in progress | | One upload request | 256 MiB. The built-ins and the official actions upload large archives in smaller blocks, so this only matters for your own clients. | Diffinery deletes entries in two ways: - Entries that no job has restored for 7 days are deleted the next time a job saves a cache in the repository. - When a save needs room above the 10 GB total, the least recently used entries are deleted until it fits. Restoring an entry counts as using it. If uploads still in progress use the whole budget, the save is refused (`uploads in progress use the repository's whole cache budget`) and the job continues with a warning. ### Manage caches with the API List, inspect and delete cache entries with the REST API. Reading needs read access to the repository; deleting needs write access. For a job's `GITHUB_TOKEN`, these endpoints need the `actions` permission. List entries: ```http GET /api/v3/repos/acme/website/actions/caches?key=npm-&ref=refs/heads/main Authorization: Bearer dfy_... ``` ```json { "total_count": 1, "actions_caches": [ { "id": 42, "ref": "refs/heads/main", "key": "npm-Linux-3f1c...", "version": "a1b2c3...", "last_accessed_at": "2026-10-12T09:41:07Z", "created_at": "2026-10-10T08:02:51Z", "size_in_bytes": 48211301 } ] } ``` | Query parameter | Meaning | | --- | --- | | `key` | Only entries whose key starts with this | | `ref` | Only entries saved for this ref, for example `refs/heads/main` or `refs/pull/7/merge` | | `sort` | `last_accessed_at` (default), `created_at` or `size_in_bytes` | | `direction` | `desc` (default) or `asc` | | `per_page`, `page` | Paging; `per_page` defaults to 30, at most 100 | Other endpoints: | Request | Result | | --- | --- | | `GET /repos/{owner}/{repo}/actions/cache/usage` | `{"full_name": "acme/website", "active_caches_size_in_bytes": ..., "active_caches_count": ...}` | | `DELETE /repos/{owner}/{repo}/actions/caches?key=<key>&ref=<ref>` | Deletes the entries with exactly this key (in every scope, or only `ref`'s). Returns `{"total_count": n, "actions_caches": [...]}` with the deleted entries. `422 key is required` without `key`; `404 Not Found` when nothing matches. | | `DELETE /repos/{owner}/{repo}/actions/caches/{cache_id}` | Deletes one entry. `204 No Content`, or `404 Not Found`. | ```sh diffinery api GET "/repos/acme/website/actions/caches?key=npm-" diffinery api DELETE "/repos/acme/website/actions/caches?key=npm-Linux-3f1c..." ``` Deletions are recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `actions.cache.delete`. ### Errors never fail the job Cache service problems are reported as warnings and the job carries on, as on GitHub: - `Failed to restore: <reason>` on restore. The step behaves like a miss, and nothing the restore extracted before it failed is left behind. - `Failed to save: <reason>` on save. - `The cache service is not available to this job; skipping.` when the job has no runtime services. The exceptions are a key validation error (see the table above) and `fail-on-cache-miss: true`, which fails the step with `Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: <key>`. ## Dependency caching with setup actions `actions/setup-node`, `actions/setup-python` and `actions/setup-go` can restore and save a dependency cache for you. They use the same service, scopes and limits as `actions/cache`, and the same keys and paths as the official setup actions, so a cache saved by the built-in restores with the official action and the other way round. The save happens in a post step only when the job succeeds, the exact key wasn't restored, and the cache directory exists. | Action | Input | Cached directory | Files hashed by default | Key on a hosted runner | | --- | --- | --- | --- | --- | | `actions/setup-node` | `cache: npm`, `pnpm` or `yarn` | `npm config get cache`, `pnpm store path`, or the Yarn cache folder | The first of `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` in the workspace root (npm); `pnpm-lock.yaml` (pnpm); `yarn.lock` (yarn) | `node-cache-Linux-x64-<manager>-<hash>` | | `actions/setup-python` | `cache: pip` | `pip cache dir` | `**/requirements.txt`, or `**/pyproject.toml` when none match | `setup-python-Linux-x64-<os version>-<os name>-python-<version>-pip-<hash>`, with the part before the hash as a restore key | | `actions/setup-python` | `cache: pipenv` | `~/.local/share/virtualenvs` | `**/Pipfile.lock` | `setup-python-Linux-x64-python-<version>-pipenv-<hash>` | | `actions/setup-go` | `cache` (on by default) | `GOMODCACHE` and `GOCACHE` | `go.sum` in the workspace root | `setup-go-Linux-x64-ubuntu24-go-<version>-<hash>` | All three accept `cache-dependency-path` to hash different files, one pattern per line, and set the `cache-hit` output (`true` or `false`). `actions/setup-node` also sets `cache-primary-key`. ```yaml - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm cache-dependency-path: apps/web/pnpm-lock.yaml ``` Details to be aware of: - `actions/setup-node@v5` and later turn on npm caching by themselves when `package.json` names npm in `packageManager` or `devEngines.packageManager`, and log `Detected npm as the package manager from package.json's packageManager field. ...`. Set `package-manager-cache: false` to turn it off. - If setup-node's lock file is missing, the step fails with `Dependencies lock file is not found in <workspace>. Supported file patterns: <files>`. If `cache-dependency-path` matches nothing, it fails with `Some specified paths were not resolved, unable to cache dependencies.` - If setup-python's patterns match nothing, the step fails with `No file in <workspace> matched to [<patterns>], make sure you have checked out the target repository`. - `actions/setup-go` doesn't fail without a `go.sum`: it logs the warning `Restore cache failed: Dependencies lock file is not found in <workspace>. Supported file patterns: go.sum` and continues. Set `cache: false` to turn caching off. - On a miss the log says `<manager> cache is not found`, for example `npm cache is not found`. - `cache` values the built-ins don't handle (for example `cache: poetry` in setup-python) run the official action instead. ## Artifacts An artifact is a named file, normally a zip archive, that belongs to a workflow run. Jobs in the same run download it with `actions/download-artifact`, other runs can download it with `run-id` and a token, and anyone with read access to the repository can download it from the run page or the API until it expires. ### Upload an artifact ```yaml - run: npm run build - uses: actions/upload-artifact@v4 id: upload with: name: site path: | dist/ !dist/**/*.map if-no-files-found: error retention-days: 14 ``` | Input | Required | Default | Description | | --- | --- | --- | --- | | `name` | No | `artifact` | Artifact name. It can't contain `"`, `:`, `<`, `>`, `\|`, `*`, `?`, `\`, `/`, carriage return or line feed. | | `path` | Yes | | Files, directories or globs to upload, one per line. Lines starting with `!` exclude matches, and lines starting with `#` are ignored. Relative paths are relative to the workspace. | | `if-no-files-found` | No | `warn` | `warn` logs a warning, `error` fails the step, `ignore` logs a plain message. | | `retention-days` | No | 90 | Days to keep the artifact, from 1 to 90. | | `compression-level` | No | `6` | Zip compression level, `0` (none) to `9` (best). Use `0` for files that are already compressed. | | `overwrite` | No | `false` | Replace an artifact with the same name in the run instead of failing. | | `include-hidden-files` | No | `false` | Include files and directories whose names start with `.`. | | `archive` | No | `true` | `false` uploads a single file as it is, without a zip archive. The artifact is then named after the file. | The step logs `With the provided path, there will be <n> file(s) uploaded`, then `Artifact <name> has been successfully uploaded! Final size is <n> bytes. Artifact ID is <id>`. ### Outputs of upload-artifact | Output | Value | | --- | --- | | `artifact-id` | The artifact's numeric id | | `artifact-digest` | The SHA-256 digest of the uploaded file, in hex | | `artifact-url` | `https://diffinery.com/acme/website/actions/runs/<run number>/artifacts/<id>`, a link that downloads the artifact | ### What goes into the archive The runner collects the matching files, then archives them relative to the deepest directory that contains all of them, as `actions/upload-artifact` does: | `path` | Files matched | Paths inside the artifact | | --- | --- | --- | | `dist/` | `dist/index.html`, `dist/assets/app.js` | `index.html`, `assets/app.js` | | `dist/index.html` | `dist/index.html` | `index.html` | | `dist/` and `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` | Only files are uploaded; empty directories are dropped. Hidden files are left out unless `include-hidden-files: true`. The archive is a zip file. With `archive: false`, exactly one file may match; otherwise the step fails with `When 'archive' is set to false, only a single file can be uploaded. Found <n> files to upload.` The file is stored with a content type from its extension, for example `text/html` for `.html`. ### Download artifacts in a later job The job must run after the job that uploaded the artifact, so list that job in `needs`: ```yaml jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci && npm run build - uses: actions/upload-artifact@v4 with: name: site path: dist/ deploy: needs: build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 id: get-site with: name: site path: public - run: ls -R ${{ steps.get-site.outputs.download-path }} ``` | Input | Default | Description | | --- | --- | --- | | `name` | | Download only the artifact with this name. | | `artifact-ids` | | Download only these artifacts, by id, separated by commas. Can't be combined with `name`. | | `pattern` | | Download the artifacts whose names match this glob, for example `junit-*`. | | `path` | The workspace | Directory to extract into, relative to the workspace. It's created if missing. | | `merge-multiple` | `false` | Extract every selected artifact into `path` itself instead of one directory per artifact. | | `skip-decompress` | `false` | Save the zip file instead of extracting it. | | `digest-mismatch` | `error` for `v8` and later, `warn` before | What to do when a download doesn't match the artifact's digest: `ignore`, `info`, `warn` or `error`. | | `github-token`, `run-id`, `repository` | | Download from another run or repository; see below. | With no `name`, `artifact-ids` or `pattern`, every artifact of the run is downloaded. Where they go: - With `name`, or `merge-multiple: true`, files go straight into `path`. - Otherwise each artifact goes into `<path>/<artifact name>`. From `v5` on, a download that selects exactly one artifact goes straight into `path`. The step sets the output `download-path` to the absolute directory, and logs `Artifact '<name>' downloaded to <path>` for each artifact. ### Download from another run or repository Pass `run-id` and a token in `github-token` to read another run's artifacts through the REST API. Add `repository` for a run in another repository: ```yaml jobs: report: runs-on: ubuntu-latest permissions: actions: read steps: - uses: actions/download-artifact@v4 with: name: coverage run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} ``` - `run-id` is a run number, such as `github.run_id` or `github.event.workflow_run.id`. It covers every attempt of that run. - The job's `GITHUB_TOKEN` can read another run of its own repository when the job has `actions: read` (or the repository default gives it). It can read artifacts of other **public** repositories on Diffinery, but not of other private ones; use a personal or agent token with read access for those. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). - `run-id` without `github-token` is ignored, and the current run is read. Without these inputs, `actions/download-artifact` reads artifacts from the job's own run only. Jobs of a [reusable workflow](https://diffinery.com/docs/actions/reusable-workflows) are part of the caller's run, so the caller and the called workflow can exchange artifacts. A re-run attempt is a separate run for this purpose: its jobs don't see the artifacts of earlier attempts without `run-id`, except those of the jobs a failed-job or single-job re-run kept from an earlier attempt (see [Re-run a run](https://diffinery.com/docs/actions/overview#re-run-a-run)), which belong to the new attempt too. ### Uploading the same name twice Artifact names are unique within a run. Uploading a name that already exists fails: ```text Failed to CreateArtifact: an artifact with the name 'site' already exists on the workflow run. Set 'overwrite: true' to replace it. ``` Set `overwrite: true` to delete the old artifact and upload the new one. In a matrix, give each job its own name: ```yaml strategy: matrix: node: [20, 22] steps: - run: npm test -- --reporter junit > junit.xml - uses: actions/upload-artifact@v4 with: name: junit-node-${{ matrix.node }} path: junit.xml ``` A later job can collect them all with `pattern: junit-*` and `merge-multiple: true`. ### Retention and expiry Each artifact keeps its `retention-days` from 1 to 90 days, 90 when you don't set one. A value above 90 is lowered with the warning `Retention days cannot be greater than the maximum allowed retention set within the repository. Using 90 instead.` When an artifact expires: - Its content is deleted, and it no longer counts toward the storage quota. - The run page no longer lists it, and download-artifact no longer finds it. - The REST API still lists it, with `"expired": true`, for another 90 days. Downloading it answers `410 Artifact has expired`. ### Storage quota By default a repository can store up to 10 GB of artifacts. Live artifacts and uploads in progress count; expired and deleted artifacts don't. An upload beyond the quota fails the step with a message that ends in: ```text the repository's artifact storage quota (10737418240 bytes) is used up; delete artifacts or wait for them to expire ``` Delete artifacts you no longer need with the API (see below). A job's unfinished uploads are removed when the job ends. An instance's administrators change the quota with `ARTIFACT_QUOTA_BYTES` (see [Configuration](https://diffinery.com/docs/self-hosting/configuration)). ## Download artifacts from the web Open the run at `https://diffinery.com/<owner>/<repo>/actions/runs/<run number>`. When the run has artifacts that haven't expired, an **Artifacts** box lists each one with its size and upload time. Select a name to download it as `<name>.zip`, or as the file itself for an artifact uploaded with `archive: false`. The download link is `https://diffinery.com/<owner>/<repo>/actions/runs/<run>/artifacts/<name or id>`, the same form as the `artifact-url` output. Extract a zip file with: ```sh unzip site.zip -d site ``` You need read access to the repository. For a public repository, anyone can download. Artifacts uploaded by older Diffinery runner versions download as `<name>.tar.gz`. ## Download artifacts with the API The REST API has GitHub's artifact endpoints. Reading needs read access to the repository; deleting needs write access. For a job's `GITHUB_TOKEN`, they need the `actions` permission. | Request | Result | | --- | --- | | `GET /repos/{owner}/{repo}/actions/artifacts` | Every artifact of the repository, newest first, expired ones included. Takes `name`, `per_page` (default 30, at most 100) and `page`. | | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The artifacts of one run, expired ones included. `run_id` is the run number (every attempt) or a run's id. Takes `name`, `per_page` and `page`. | | `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | One artifact. | | `GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip` | `302 Found` to a signed download URL valid for 10 minutes. `410 Artifact has expired` for an expired artifact. | | `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}` | Deletes the artifact. `204 No Content`. Recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `actions.artifact.delete`. | Both lists send a `Link` header, so `octokit.paginate` works. Artifacts uploaded by older Diffinery runners are listed, fetched and deleted the same way and kept for 90 days; their download is a gzip-compressed tar file (`<name>.tar.gz`) rather than a zip. Unknown ids answer `404 Not Found`. An artifact looks like this: ```json { "id": 1187, "node_id": "MDg6QXJ0aWZhY3Q1187", "name": "site", "size_in_bytes": 482113, "url": "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187", "archive_download_url": "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip", "expired": false, "digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "created_at": "2026-10-12T09:41:07Z", "updated_at": "2026-10-12T09:41:07Z", "expires_at": "2026-10-26T09:41:07Z", "workflow_run": { "id": 128, "repository_id": "...", "head_repository_id": "...", "head_branch": "main", "head_sha": "9fceb02d0ae598e95dc970b74767f19372d61af8" } } ``` `workflow_run.id` is the run number. Download with the CLI's API command or curl; curl follows the redirect with `-L`: ```sh curl -fsSL -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip -o site.zip ``` Install the CLI with `curl -fsSL https://diffinery.com/install | bash` and sign in with `diffinery login`, then list a run's artifacts: ```sh diffinery api GET /repos/acme/website/actions/runs/128/artifacts ``` Artifacts uploaded by older Diffinery runner versions are in these endpoints too, as `.tar.gz` downloads. ## The runtime services Diffinery gives each job the same runtime services GitHub does, through these environment variables: | Variable | Value | | --- | --- | | `ACTIONS_RUNTIME_TOKEN` | A token for this job's cache and artifact requests. It works only while the job runs and expires 30 minutes after the job's timeout. | | `ACTIONS_RESULTS_URL` | `https://diffinery.com/` | | `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` | `https://diffinery.com/_runtime/` | | `ACTIONS_CACHE_SERVICE_V2` | `true` | As on GitHub, they're given to JavaScript actions, Docker container actions and the built-ins, not to `run:` steps. So `docker buildx build --cache-to type=gha` in a `run:` step finds no cache service, while `docker/build-push-action`, a JavaScript action, runs the build with them. The services speak the protocols the `@actions/cache` and `@actions/artifact` toolkit packages use: cache service v2 and artifact service v4, the older cache REST API, and signed upload and download URLs. A signed download URL is valid for one hour. Requests from a job that has finished fail with `401 the job is not running`. For Docker layer caching you can use `type=gha` through `docker/build-push-action`, which stores the layers as cache entries under the same 10 GB budget, or keep the cache in the [container registry](https://diffinery.com/docs/packages/container-registry) with `type=registry`, for example `diffinery.com/acme/website:buildcache`. See [Docker in workflows](https://diffinery.com/docs/actions/docker). ## Differences from GitHub | Area | GitHub Actions | Diffinery | | --- | --- | --- | | How the actions run | Downloaded from GitHub | Built into the runner for the listed versions; the real action otherwise | | Cache scope, size and eviction | Per ref with fallbacks, 10 GB, 7-day idle eviction | The same | | Artifact storage | Counted against the account's storage | 10 GB per repository by default, then uploads are refused | | Artifact retention | Set per repository or organization | 1 to 90 days, 90 by default | | Web page for caches | Yes | No; use the API | | `artifact-url` | Points at github.com | Points at Diffinery | | `run-id` in download-artifact | The run's id | The run number (`github.run_id`), which is the same thing on Diffinery | ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `Cache not found for input keys: ...` on every run | The save never happened, or it's in a scope this run can't read. Check the post step's log: the job may have failed (use `save-always: true`), the paths may not exist. An entry saved on a feature branch isn't visible to other branches; save on the default branch for shared entries. | | `Failed to save: Unable to reserve cache with key <key>, another job may be creating this cache.` | An entry with this key already exists for this ref, or another job is saving it. Include something that changes in the key, such as `hashFiles(...)`, and use `restore-keys` to pick up the latest. | | `Key Validation Error: ... cannot contain commas.` | Remove commas from `key` and `restore-keys`. | | `cache-hit` is `false` although files were restored | A restore key matched, not `key`. That's expected. | | A cache saved by one runner doesn't restore on another | The version includes the paths and the compression. Use the same `path` lines, and make sure `zstd` is installed on both or neither. | | Restored files appear in an unexpected directory | Paths are stored relative to the workspace. A runner with a different workspace depth restores into other directories. Add the runner type to the key. | | `The artifact name is not valid: <name>. Contains the following character: ...` | Remove `/`, `\`, `:` and the other characters listed in [Upload an artifact](#upload-an-artifact). | | `No files were found with the provided path: ... No artifacts will be uploaded.` | The paths don't match any files, or only hidden files. Check the paths relative to the workspace, or set `include-hidden-files: true`. | | `Failed to CreateArtifact: an artifact with the name '<name>' already exists on the workflow run. ...` | Set `overwrite: true`, or include matrix values in the name. | | `... artifact storage quota ... is used up; delete artifacts or wait for them to expire` | Delete old artifacts with `DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}`, or use shorter `retention-days`. | | `Unable to download artifact(s): Artifact not found for name: <name>` | No job in this run has uploaded that name yet, or it expired. Add the uploading job to `needs`, and check the name's spelling and case. | | `Inputs 'name' and 'artifact-ids' cannot be used together. Please specify only one.` | Remove one of the two inputs. | | `Digest validation failed for artifact(s): ...` | The downloaded file doesn't match the digest recorded at upload. Download again; set `digest-mismatch: warn` to continue anyway. | | `Listing artifacts of run <n> in <repo> failed: HTTP 403` | The token in `github-token` lacks access. Give the job `permissions: actions: read`, or use a token that can read the other repository. | | `410 Artifact has expired` | The artifact passed its retention period. Its content is gone. | --- # Compatibility with GitHub Actions > What works, what works differently and what isn't supported when you run GitHub Actions workflows on Diffinery, with a checklist for porting workflows. Source: https://diffinery.com/docs/actions/compatibility Diffinery runs GitHub Actions workflow files with the same syntax, triggers, expressions and actions, so most workflows run without changes. This page lists every place where Diffinery behaves differently, what happens when a workflow uses something that isn't supported, and a checklist for porting workflows from GitHub. ## At a glance | Area | Status on Diffinery | Details | | --- | --- | --- | | Workflow syntax | Supported, with a few keys accepted but ignored | [Workflow syntax](#workflow-syntax) | | Triggers | `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release`, `repository_dispatch`, `workflow_call` | [Triggers](#triggers) | | Expressions and contexts | Supported, with small differences in values | [Contexts and environment variables](#contexts-and-environment-variables) | | Hosted runners | Linux x64 only (Ubuntu 24.04) | [Runners](#runners) | | Self-hosted runners | Supported, through `diffinery runner` | [Runners](https://diffinery.com/docs/actions/runners) | | Matrices, `needs`, outputs, job summaries | Supported | [Jobs and steps](#jobs-and-steps) | | `actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go` | Built into the runner for speed; the real action runs for versions or inputs the built-in doesn't cover | [Built-in actions](#built-in-actions) | | `actions/cache`, `actions/upload-artifact`, `actions/download-artifact` | Built into the runner, sharing storage and formats with the official actions, which also work | [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts) | | `actions/github-script` | Supported (the real action runs) | [actions/github-script](#actionsgithub-script) | | Other JavaScript, composite and Docker actions | Supported, downloaded from GitHub, with the cache and artifact runtime services | [Other actions](#other-actions) | | Job containers and service containers | Supported | [Docker in workflows](https://diffinery.com/docs/actions/docker) | | Reusable workflows | Supported, except a matrix on the calling job | [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows) | | Secrets and variables | Repository, organization and environment secrets and variables | [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables) | | Environments | Supported: branch policies, required reviewers (people), wait timers, environment secrets and variables, deployments. | [Secrets, variables and environments](#secrets-variables-and-environments) | | `GITHUB_TOKEN` | Follows `permissions:` per endpoint, as on GitHub; read-only by default in new repositories | [GITHUB_TOKEN and permissions](#github_token-and-permissions) | | OIDC | Supported, with Diffinery as the issuer | [OIDC](https://diffinery.com/docs/actions/oidc) | | Windows and macOS hosted runners | Not available (Cloudflare Containers run Linux only); use self-hosted runners | [Runners](#runners) | ## Workflow files Diffinery reads workflows from `.github/workflows/` and from `.diffinery/workflows/`. Every `.yml` or `.yaml` file directly in either directory is a workflow; files in subdirectories are ignored. Workflows in both directories run, each as its own workflow. Put workflows that should only run on Diffinery in `.diffinery/workflows/`, so a GitHub mirror of the repository doesn't run them. See [Where workflow files live](https://diffinery.com/docs/actions/overview#where-workflow-files-live). Check a workflow before you push it: ```sh diffinery workflow validate .github/workflows/ci.yml ``` Errors fail the file; warnings, such as `unknown job key '<key>'` or `unknown event '<name>'`, are printed but the workflow still runs. ## Triggers | Event | Status | Differences | | --- | --- | --- | | `push` | Supported | `commits` in the payload lists only the pushed head commit. Path filters pass when a push creates a branch. | | `pull_request` | Supported | Activity types `opened`, `synchronize`, `reopened`, `closed`, `edited`, `ready_for_review` and `converted_to_draft` only; the `edited` payload has no `changes` object. No runs while the pull request conflicts with its base. Runs get the repository's secrets, because pull requests come from branches of the same repository. | | `pull_request_target` | Supported | Same activity types as `pull_request`. Runs also while the pull request conflicts. | | `merge_group` | Supported | `checks_requested`. | | `workflow_dispatch` | Supported | `github.event.inputs` keeps the input types instead of turning them into strings. The dispatch API answers `201 Created` with the run instead of `204 No Content`. | | `schedule` | Supported | Checked every 5 minutes, so a schedule starts within about 5 minutes after it's due. Runs have the actor `diffinery-scheduler`. | | `workflow_run` | Supported | Activity types `requested` and `completed` (not `in_progress`), `workflows` is required, and chains go at most 3 levels deep. | | `release` | Supported | Same activity types. No runs for draft releases, as on GitHub. Without `types`, a new published release starts three runs (`created`, `published`, `released`), as on GitHub. Only `types` filters; `branches`, `tags` and `paths` aren't applied. | | `repository_dispatch` | Supported | `POST /repos/{owner}/{repo}/dispatches` answers `204`. The payload has `action`, `branch`, `client_payload`, `repository` and `sender`. | | `workflow_call` | Supported | See [Reusable workflows](https://diffinery.com/docs/actions/reusable-workflows). | | Every other GitHub event (`issues`, `issue_comment`, `pull_request_review`, `create`, `delete`, `status`, `deployment`, `deployment_status` and the rest) | Accepted, never fires | The workflow parses, but Diffinery never starts a run for these events. (Webhooks do get `status`, `deployment` and `deployment_status`.) | | A name that isn't a GitHub event | Warning | `unknown event '<name>'`. | Details for each event are in [Events that trigger workflows](https://diffinery.com/docs/actions/events). ## Workflow syntax Every key of the GitHub Actions workflow syntax parses. These keys behave differently: | Key | What happens on Diffinery | | --- | --- | | `jobs.<id>.runs-on` | Supported, including the `matrix` context (`runs-on: ${{ matrix.os }}`), but only Linux labels are hosted. The `group` key is ignored. | | `jobs.<id>.environment` | Supported: the branch policy is enforced, the environment's secrets and variables are added and a deployment is recorded. Required reviewers and wait timers hold the job before it starts, and `url` can't use step outputs. See [environment](https://diffinery.com/docs/actions/workflow-syntax#environment). | | `jobs.<id>.snapshot` | Accepted and ignored. | | `jobs.<id>.timeout-minutes` | Default and maximum 360. Larger values are capped at 360. | | `jobs.<id>.strategy.matrix` | At most 256 jobs per matrix, and at most 1,000 jobs in a run, matrix legs and the jobs of called workflows included. | | `jobs.<id>.uses` with `strategy.matrix` | Not supported yet. The run fails with `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet`. | | `concurrency` (workflow level) | Same as GitHub: one run in progress and at most one waiting per group; a newer run cancels older waiting ones. After 6 hours of waiting a run starts anyway. See [Concurrency](https://diffinery.com/docs/actions/overview#concurrency). | | `defaults.run.shell`, `jobs.<id>.steps[*].shell` | `cmd` isn't supported. `pwsh` and `powershell` need PowerShell, which hosted runners don't have. | An unknown key gives a warning such as `unknown job key '<key>'` or `unknown step key '<key>'`, and the key is ignored. The full reference is [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax). ## Runners | `runs-on` | GitHub | Diffinery | | --- | --- | --- | | `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04` | Hosted Ubuntu VM | Hosted Ubuntu 24.04 container, x64. All three labels get the same image. | | `ubuntu-24.04-arm` and other ARM labels | Hosted ARM runner | No hosted ARM runners. An `ubuntu-*` ARM label runs on the same x64 image as `ubuntu-latest`. For ARM, use `[self-hosted, ...]` labels on an ARM machine. | | `windows-*`, `macos-*` | Hosted Windows or macOS VM | Not available: Cloudflare Containers run Linux only. The job fails with `No Diffinery runner matches runs-on: <labels>. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.` | | Larger runners and runner groups | Supported | Not available. `group` is ignored. | | `[self-hosted, ...]` | Self-hosted runner | Self-hosted runner started with `diffinery runner`, matched on every label. | What's different about the hosted image: - It's much smaller than GitHub's image. Git, Git LFS, curl, jq, zstd, build-essential, Python 3.12, Node.js 22 with npm, pnpm and yarn, nvm, Docker and the GitHub CLI (`gh`) are preinstalled. `ImageOS` is `ubuntu24`, as on GitHub's `ubuntu-24.04` image. Go, Java, Ruby, Rust, .NET, PowerShell, browsers and cloud CLIs aren't. See [The Ubuntu 24.04 image](https://diffinery.com/docs/actions/runners#the-ubuntu-2404-image). - Jobs run as `root`. `sudo` is installed and needs no password, as on GitHub, so `sudo` in commands keeps working. - The apt package lists are removed. Run `apt-get update` before `apt-get install`. - Every job starts in a fresh container. Nothing carries over between jobs except caches and artifacts. On self-hosted runners, `runner.os` is always `Linux`, and the setup actions download Linux builds, even on a macOS machine. See [Self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners). ## Jobs and steps These work the same as on GitHub: `needs` (a job starts as soon as the jobs it needs finish), job outputs, `if` conditions, status check functions (a skipped or failed job further up the `needs` chain skips the jobs below it unless their `if` says otherwise), matrices with `include` and `exclude`, `fail-fast`, `max-parallel`, job and step `continue-on-error`, job-level `concurrency`, `timeout-minutes`, `working-directory`, environment files (`GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STEP_SUMMARY`, `GITHUB_STATE`), workflow commands, job summaries, masking and debug logging with `ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG`. Differences: | Behaviour | GitHub | Diffinery | | --- | --- | --- | | Default shell for `run` | `bash -e {0}` | `bash --noprofile --norc -eo pipefail {0}`, so a failing command in a pipeline fails the step. | | `::add-matcher::` problem matchers | Turn matching output into annotations | Not supported; the line is printed as is. | | Annotations (`::error file=...`) | Shown on pull request files | Shown in the job log only. | | `::set-env` and `::add-path` | Disabled | Disabled, with the same message. | | `github.run_number` | Counts runs of one workflow | Counts runs of every workflow in the repository. | See [Expressions and contexts](https://diffinery.com/docs/actions/expressions) and [Workflow syntax](https://diffinery.com/docs/actions/workflow-syntax) for the details. ## Built-in actions For speed, the runner has its own implementation of these actions and runs it instead of downloading the action, when the step uses a version tag in the listed range: | Action | Built-in for | | --- | --- | | `actions/checkout` | `v2` to `v6` | | `actions/setup-node` | `v3` to `v7` | | `actions/setup-python`, `actions/setup-go` | `v4` to `v6` | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | `v3` to `v6` | | `actions/upload-artifact` | `v4` to `v7` | | `actions/download-artifact` | `v4` to `v8` | The built-ins mirror the official actions' inputs, outputs and storage: a cache or artifact saved by a built-in works with the official action and the other way round. A built-in steps aside, and the real action is downloaded from GitHub and run, when: - the reference isn't `vN`, `vN.N` or `vN.N.N` in the range, for example a commit SHA, a branch or a newer major version; - the step passes an input the built-in doesn't list (see the tables below); - an input has a value the built-in doesn't handle: `ssh-key` for checkout, an `architecture` other than the runner's, a `cache` value the built-in doesn't support, a PyPy or GraalPy version, or `update-environment: false` for setup-python; - the workflow, job or step `env:` sets `DIFFINERY_BUILTINS` to `false`, `0`, `no` or `off`; - a self-hosted runner was started with `DIFFINERY_DISABLE_BUILTINS=1`. ```yaml jobs: build: runs-on: ubuntu-latest env: DIFFINERY_BUILTINS: false # this job runs the real actions steps: - uses: actions/checkout@v4 ``` Built-in actions run on the runner, not inside a job container. In a job with `container:`, they work on the shared workspace. ### actions/checkout Checks out a repository on the same Diffinery instance into the workspace. | Input | Default | Notes | | --- | --- | --- | | `repository` | The workflow's repository | `owner/name` of a repository on Diffinery. Anything else fails with `Invalid repository '<value>'. Expected format {owner}/{repo}.` | | `ref` | The commit that triggered the run, or the other repository's default branch | Branch, tag, full ref or 40-character commit SHA. A short SHA is treated as a branch or tag name. | | `token` | The job's token | The job's token reads the workflow's own repository (with `contents: read`) and any public repository on Diffinery. For another private repository, pass a personal or agent token from a secret. The job's token is only ever sent to Diffinery; a different `github-server-url` needs an explicit `token`. | | `fetch-depth` | `1` | `0` fetches all branches and tags with full history. | | `fetch-tags` | `false` | | | `path` | The workspace | Relative to the workspace. A path outside it fails with `Repository path '<path>' is not under '<workspace>'`. | | `clean` | `true` | Runs `git clean -ffdx` and `git reset --hard HEAD` on an existing checkout. | | `filter` | | A partial clone filter such as `blob:none`, passed to `git fetch`. Diffinery's git server doesn't support partial clone yet and sends everything. | | `sparse-checkout` | | Patterns, one per line. Sets up a sparse checkout and fetches with `filter: blob:none` unless you set `filter`. | | `sparse-checkout-cone-mode` | `true` | `false` uses non-cone patterns. | | `lfs` | `false` | Fetches Git LFS objects for the checked-out commit (and runs `git lfs pull` in submodules). See [Git LFS](https://diffinery.com/docs/repositories/git-lfs). | | `submodules` | `false` | `true` or `recursive`. Submodules on Diffinery are fetched with the same credentials. A failed submodule update fails the step. | | `persist-credentials` | `true` | Writes the token into the repository's local Git config at the end, so later `git` commands authenticate. A post step removes it. With `false`, nothing is written. | | `show-progress` | `true` | Passes `--progress` to `git fetch` and `git checkout`. | | `set-safe-directory` | `true` | Marks the checkout as a Git safe directory for checkout's own commands. | | `github-server-url` | Diffinery | Check out from another server, for example `https://github.com`, with an explicit `token` for private repositories. | | `ssh-key` | | Not handled by the built-in: the real action runs. `ssh-known-hosts`, `ssh-strict` and `ssh-user` are accepted and ignored. | While fetching, the token reaches git through the environment, so a checkout that fails halfway leaves no credentials in `.git/config`. Credentials a reused workspace still holds from another job are removed first. Outputs: `commit` (the checked-out SHA) and `ref`. As on GitHub, a `pull_request` run checks out the test merge commit (`refs/pull/<number>/merge`) by default, and a `pull_request_target` run checks out the base branch. Errors: - `Failed to fetch ref '<ref>' from <owner>/<repo>. Check that the ref exists and the token has read access.` - `A branch or tag with the name '<ref>' could not be found` (with `fetch-depth: 0`). - `Checked out <sha> but expected <sha>.` ### actions/setup-node | Input | Notes | | --- | --- | | `node-version` | A version or range (`22`, `20.x`, `>=20.10`), `lts/*`, `lts/<codename>`, `lts/-1`, `latest`, `current` or `node`. | | `node-version-file` | `package.json` (reads `volta.node`, then `devEngines.runtime.version`, then `engines.node`), `.tool-versions` (`nodejs` or `node`), or a file whose first non-comment line is the version, such as `.nvmrc` or `.node-version`. | | `check-latest` | Download the newest matching version even when the preinstalled one satisfies the request. | | `registry-url`, `scope`, `always-auth` | Writes an `.npmrc` that reads `NODE_AUTH_TOKEN`, and sets `NPM_CONFIG_USERCONFIG`. For `npm.pkg.github.com` the scope defaults to the repository owner. `always-auth` is written for `v4` and earlier, or when you set it. | | `cache`, `cache-dependency-path` | `npm`, `pnpm` or `yarn`. See [Dependency caching with setup actions](https://diffinery.com/docs/actions/caching-and-artifacts#dependency-caching-with-setup-actions). | | `package-manager-cache` | `v5` and later cache npm automatically when `package.json` names npm as its package manager; `false` turns that off. | | `architecture`, `token` | Accepted. An `architecture` other than the runner's runs the real action. | Hosted runners have Node.js 22.22.0. A request that it satisfies uses it without a download; any other version is downloaded from `nodejs.org` into the tool cache. Without `node-version` or `node-version-file`, the step logs `No node-version specified; using the pre-installed node <version>.` Other inputs, such as `mirror`, run the real action. Outputs: `node-version` (for example `v22.22.0`), `cache-hit` and `cache-primary-key`. ### actions/setup-python | Input | Notes | | --- | --- | | `python-version` | One CPython version or range, such as `3.12`, `3.11.9`, `3.x` or `3.14-dev`. PyPy and GraalPy versions run the real action. | | `python-version-file` | `.python-version`, `.tool-versions` (`python`), `pyproject.toml` (`requires-python`), or a file whose first non-comment line is the version. Without either input, `.python-version` in the workspace is used if present. | | `allow-prereleases` | Falls back to a pre-release build when no stable release matches. A version ending in `-dev` does the same. | | `cache`, `cache-dependency-path` | `pip` or `pipenv`. Other values run the real action. See [Dependency caching with setup actions](https://diffinery.com/docs/actions/caching-and-artifacts#dependency-caching-with-setup-actions). | | `architecture`, `check-latest`, `token`, `update-environment` | Accepted. An `architecture` other than the runner's, or `update-environment: false`, runs the real action. | Hosted runners have Python 3.12. A request that it satisfies uses it. Other versions are installed from the `actions/python-versions` builds that GitHub publishes, and the step sets `pythonLocation`, `Python_ROOT_DIR`, `Python3_ROOT_DIR`, `PKG_CONFIG_PATH` and `LD_LIBRARY_PATH` like the original action. Other inputs, such as `freethreaded`, run the real action. Outputs: `python-version`, `python-path` and `cache-hit`. ### actions/setup-go | Input | Notes | | --- | --- | | `go-version` | A version or range, `stable` or `oldstable`. | | `go-version-file` | `go.mod` or `go.work` (`toolchain` line first, then `go`), `.tool-versions` (`golang` or `go`), or a file whose first line is the version. | | `check-latest` | Download the newest matching version. | | `cache`, `cache-dependency-path` | On by default. Without a `go.sum` in the workspace root, the step warns `Restore cache failed: ...` and continues. | | `architecture`, `token` | Accepted. An `architecture` other than the runner's runs the real action. | Go isn't preinstalled on hosted runners, so the step downloads it from `go.dev`. Without a version on a runner that has no Go, it fails with `No go-version specified and Go is not installed on this runner.` Outputs: `go-version` and `cache-hit`. ### actions/cache and artifacts `actions/cache`, `actions/cache/restore`, `actions/cache/save`, `actions/upload-artifact` and `actions/download-artifact` support the official actions' inputs and outputs, including `retention-days`, `overwrite`, `compression-level`, `archive`, downloading all artifacts, `pattern`, `merge-multiple`, `artifact-ids`, and `run-id` with `github-token`. Caches are scoped per ref as on GitHub, each repository keeps up to 10 GB of caches, and artifacts are zip files kept for up to 90 days. [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts) has the details. ### actions/github-script `actions/github-script` isn't a built-in: the real action runs, and its `github` client talks to Diffinery's REST API (`GITHUB_API_URL`) with the job token. What it may do follows the job's `permissions:`: ```yaml jobs: label: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/github-script@v7 with: script: | await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: ["needs-review"], }); ``` `github.graphql` fails: Diffinery's [GraphQL API](https://diffinery.com/docs/reference/graphql-api) answers personal and agent tokens, not a job's token. REST calls to endpoints Diffinery doesn't have get a JSON `404 Not Found`. See [the REST API reference](https://diffinery.com/docs/reference/rest-api) for the endpoints Diffinery has. ## Other actions Any other `uses: owner/repo[/path]@ref` (and a built-in action the built-in doesn't cover) is downloaded from GitHub (`codeload.github.com`, or with `git` from `github.com` when that endpoint can't be reached) when the step runs and executed according to its `action.yml` or `action.yaml`. ### JavaScript actions Actions with `runs.using: node12`, `node16`, `node20`, `node24` or any other `node<version>` run with the runner's own Node.js 22, whatever version they ask for. `pre`, `main`, `post`, `pre-if` and `post-if` are supported. One difference: on GitHub, `pre` scripts run at the start of the job, before any step. On Diffinery, an action's `pre` script runs immediately before its `main` script, as part of the same step. Post scripts run at the end of the job in reverse order, as on GitHub. ### Composite actions Composite actions (`runs.using: composite`) are supported, including `inputs`, `outputs`, `env`, `if`, `shell`, `working-directory` and nested `uses`. Composite actions nest at most 10 levels; deeper nesting fails with `Composite action nesting is too deep (max 10).` ### Docker container actions Actions with `runs.using: docker` are supported, with `runs.image` set to a `Dockerfile` in the action (built on the runner) or a `docker://` image, plus `args`, `entrypoint`, `env`, `pre-entrypoint` and `post-entrypoint`. A step can also run an image directly with `uses: docker://image`. Hosted runners have Docker; self-hosted runners need it installed. See [Docker container actions](https://diffinery.com/docs/actions/docker#docker-container-actions). ### Local actions `uses: ./path/to/action` reads the action from the workspace, so check out the repository first. Otherwise the step fails with `Can't find 'action.yml' or 'action.yaml' under '<dir>'. Did you forget to run actions/checkout before running your local action?` ### Actions stored on Diffinery `uses: acme/my-action@v1` always means the repository `acme/my-action` on GitHub, not on Diffinery. Actions in private GitHub repositories can't be used on hosted runners, because the download has no GitHub credentials. A [self-hosted runner](https://diffinery.com/docs/actions/runners#self-hosted-runners) can authenticate downloads with `DIFFINERY_GITHUB_TOKEN` in its environment. To use an action that lives in a Diffinery repository, check it out into a directory and use it as a local action: ```yaml - uses: actions/checkout@v4 with: repository: acme/my-action ref: v1 path: .actions/my-action token: ${{ secrets.ACTIONS_READ_TOKEN }} - uses: ./.actions/my-action ``` `ACTIONS_READ_TOKEN` is a personal or agent token with read access to `acme/my-action`, stored as a secret, because the job token can't read other private repositories. If `acme/my-action` is public, leave `token` out: the job token can read public repositories. ### Unsupported action runtimes Any other `runs.using` value fails the step with `Unsupported action runtime 'runs.using: <value>' in <action>`. A version or repository that doesn't exist on GitHub fails with `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found`. A reference without `@ref` fails with `Expected format {owner}/{repo}[/path]@ref. Actual '<value>'`. ### Actions that call the GitHub API Actions built on `@actions/github` or Octokit read `GITHUB_API_URL` and `GITHUB_SERVER_URL`, which point at Diffinery, and authenticate with the job token. Diffinery's REST API uses GitHub's paths, JSON shapes, `Link` header pagination and error format, so calls to endpoints Diffinery has work (see [REST API](https://diffinery.com/docs/reference/rest-api)), within the job's `permissions:` (see [GITHUB_TOKEN and permissions](#github_token-and-permissions)). Calls to the GraphQL API fail, because it doesn't accept a job's token yet. ### Actions that use the cache or artifact services Diffinery sets `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RESULTS_URL`, `ACTIONS_CACHE_URL`, `ACTIONS_RUNTIME_URL` and `ACTIONS_CACHE_SERVICE_V2` for JavaScript actions, Docker container actions and the built-ins (not for `run:` steps, as on GitHub). Actions that use the `@actions/cache` or `@actions/artifact` packages directly, and `docker/build-push-action` with `cache-from: type=gha` and `cache-to: type=gha`, store their caches and artifacts in Diffinery. See [The runtime services](https://diffinery.com/docs/actions/caching-and-artifacts#the-runtime-services). The official `actions/upload-artifact` and `actions/download-artifact` refuse to run on a server that looks like GitHub Enterprise Server, so the runner shows them `GITHUB_SERVER_URL=https://github.com`. Their API calls still go to Diffinery, and their `artifact-url` output is rewritten to point at Diffinery. ## Contexts and environment variables | Value | GitHub | Diffinery | | --- | --- | --- | | `github.server_url`, `GITHUB_SERVER_URL` | `https://github.com` | `https://diffinery.com` | | `github.api_url`, `GITHUB_API_URL` | `https://api.github.com` | `https://diffinery.com/api/v3` | | `github.run_id`, `GITHUB_RUN_ID` | A number, unique per run | A number: the run's number, the same as `github.run_number`. The API accepts it wherever it takes a run id. | | `github.repository_id` | A number | Diffinery's repository ID, a string | | `github.ref_protected` | `true` for protected branches | Always `false` | | `github.event.repository` | GitHub's repository object | The same object, with Diffinery's values; `id` is numeric and `private` and `visibility` reflect the repository | | `github.actor` for scheduled runs | The last person who edited the cron | `diffinery-scheduler` | | `github.token`, `secrets.GITHUB_TOKEN` | The job token | The job token, also available as `secrets.DIFFINERY_TOKEN` | | `github.actor_id`, `github.repository_owner_id`, `github.workflow_sha` | Set | Not set | | `runner.os`, `RUNNER_OS` | `Linux`, `Windows` or `macOS` | Always `Linux`, also on self-hosted macOS machines | | `runner.name`, `RUNNER_NAME` | The runner's name | `diffinery` | | `runner.environment`, `RUNNER_ENVIRONMENT` | `github-hosted` or `self-hosted` | `diffinery-hosted`, also on self-hosted runners | | `DIFFINERY` | Not set | `true` | `CI=true` and `GITHUB_ACTIONS=true` are set as on GitHub, so tools that detect GitHub Actions keep working. To run a step only on one forge, check the server URL: ```yaml - if: github.server_url == 'https://diffinery.com' run: echo "Running on Diffinery" ``` The complete list is in [Expressions and contexts](https://diffinery.com/docs/actions/expressions). ## GITHUB_TOKEN and permissions Every job gets a token, available as `secrets.GITHUB_TOKEN`, `secrets.DIFFINERY_TOKEN` and `github.token`. As on GitHub: - Its permissions come from the job's `permissions:`, else the workflow's, else the repository's default. `read-all`, `write-all`, `{}` and per-scope maps work as on GitHub, and a map sets every scope it doesn't list to `none`. - Each REST endpoint needs its scope (`contents`, `issues`, `pull-requests`, `statuses`, `checks`, `deployments`, `actions`, `packages` and so on). A request without it gets `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: <scope>: <level>)`. - It never administers the repository, never reads or changes secrets and variables, and never creates or changes workflow files. Branch protection applies to it. - It can open and approve pull requests only when the repository allows it, and its approvals don't count toward required reviews. - It can read any public repository on Diffinery, but no other private repository. - What it does starts no new runs, except `workflow_dispatch`. - It stops working when the job completes. Differences: - New repositories default to read-only tokens (`contents` and `packages` read), as GitHub does for new repositories. Repositories created before this setting existed default to read and write. An admin changes it on the repository's **Settings → Actions** tab or with `PUT /repos/{owner}/{repo}/actions/permissions/workflow`. - A run whose code came from an agent is held to the agent rules, so its token can't push or merge to the default branch or protected branches. [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions) has the full model, the scope each endpoint needs, and how to change the default. ## Secrets, variables and environments | Feature | Status | | --- | --- | | Repository secrets and variables | Supported | | Organization secrets | Supported (CLI and API) | | Organization variables | Supported, through the REST API. Every repository in the organization sees them (no `private` or `selected` visibility). See [Organization variables](https://diffinery.com/docs/actions/secrets-and-variables#organization-variables). | | Environments with deployment branch policies (protected branches only, or branch and tag patterns) | Supported, through the REST API. See [Environments](https://diffinery.com/docs/actions/environments). | | Environment secrets and variables | Supported, through the REST API. They win over repository and organization values with the same name. Pull request runs never get environment secrets, and runs started by agents get them only from environments limited to protected branches. | | Team reviewers and custom deployment protection rules | Not supported. Required reviewers are people. | | Setting secrets with a sealed-box `encrypted_value` | Not supported yet. Send the plain `value` over HTTPS; it's encrypted at rest. | | Secrets in `pull_request` runs | Provided, because every pull request comes from a branch of the same repository | | Changing secrets and variables with an agent's token | Refused: only people manage secrets and variables | | Secret names starting with `GITHUB_` | Rejected for repository secrets, as on GitHub | See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). ## Reusable workflows and OIDC Reusable workflows work as on GitHub, with these limits: they must live on the same Diffinery instance, calls nest at most 4 levels deep, one run calls at most 20, and a calling job can't have a matrix yet. See [Differences from GitHub](https://diffinery.com/docs/actions/reusable-workflows#differences-from-github) in Reusable workflows. OIDC works with `permissions: id-token: write`, and tokens are issued by `https://diffinery.com`. Configure your cloud provider to trust that issuer instead of `https://token.actions.githubusercontent.com`. The claims differ in a few places; see [Differences from GitHub](https://diffinery.com/docs/actions/oidc#differences-from-github) in OIDC. ## Porting checklist Work through this list for each workflow you move from GitHub. Most workflows need only the first two items. 1. Run `diffinery workflow validate` on each file and fix any errors. Read the warnings for ignored keys. 2. Push and watch the first run with `diffinery run watch` or on the **Actions** tab. 3. Replace `runs-on: windows-*` and `macos-*` with Linux, or with `[self-hosted, ...]` labels and a [self-hosted runner](https://diffinery.com/docs/actions/runners#self-hosted-runners). This includes `matrix.os` values used in `runs-on`. 4. Add `apt-get update` before `apt-get install`. 5. Install tools the hosted image doesn't have (Go, Java, Ruby, Rust, PowerShell, cloud CLIs) with a setup action or `apt-get`. 6. Check what each job does with `GITHUB_TOKEN`. In a new repository the default token only reads, so give jobs that push, tag, comment, label or merge the `permissions:` they need, or have an admin set the repository default to write (see [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions)). Jobs that change workflow files, or that open or approve pull requests without the repository allowing it, need a personal or agent token from a secret. 7. Give `actions/checkout` of another private repository a token from a secret. 8. Recreate your environments, their branch policies, secrets and variables through the REST API (see [Environments](https://diffinery.com/docs/actions/environments)), and set required reviewers by Diffinery login (GitHub's numeric ids and teams don't carry over). 9. Replace `github.graphql` calls in `actions/github-script` with REST calls. 10. Replace `ghcr.io` with your instance's host (`diffinery.com` here) in image names and logins; see [Container registry](https://diffinery.com/docs/packages/container-registry#migrating-from-ghcrio). 11. Replace hard-coded `github.com` and `api.github.com` URLs with `${{ github.server_url }}` and `${{ github.api_url }}`. 12. Update OIDC trust policies in your cloud accounts to the Diffinery issuer and subject format. 13. Move actions from private GitHub repositories into a Diffinery repository and use them as local actions. [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github#workflows) covers moving the rest of the repository. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `No Diffinery runner matches runs-on: <labels>. ...` | The labels aren't hosted Linux labels and don't include `self-hosted`. Use `ubuntu-latest`, or `[self-hosted, ...]` with a registered runner. For a matrix job, check the value `runs-on` got for that combination. | | `Error evaluating the job for this matrix combination: <message>` | An expression in `runs-on`, `name`, `timeout-minutes`, `continue-on-error` or `concurrency` failed for one matrix combination. | | `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: ...)` | Add the scope to the job's `permissions:`. See [GITHUB_TOKEN permissions](https://diffinery.com/docs/actions/workflow-permissions). | | `refusing to allow a GitHub App to create or update workflow ...` | Job tokens never change workflow files. Use a personal or agent token from a secret. | | `GitHub Actions is not permitted to create pull requests. ...` | An admin allows it with `can_approve_pull_request_reviews`, and the job needs `pull-requests: write`. | | `Unable to resolve action '<owner>/<repo>@<ref>', repository or version not found` | The action or version doesn't exist on GitHub, or the repository is private. | | `Can't find 'action.yml' or 'action.yaml' under '<dir>'. ...` | Check out the repository before using a local action, and check the path. | | `Unsupported action runtime 'runs.using: <value>' in <action>` | The action uses a runtime other than `node*`, `composite` or `docker`. | | ``The `cmd` shell is not supported on Linux runners.`` | Use `bash` or `sh`. | | `Failed to fetch ref '<ref>' from <owner>/<repo>. Check that the ref exists and the token has read access.` | Checking out another private repository needs a token from a secret, the job lacks `contents: read`, or the ref doesn't exist. | | `No go-version specified and Go is not installed on this runner.` | Set `go-version` or `go-version-file`. | | `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet` | Move the matrix into the called workflow, or write one calling job per value. | | `A matrix may produce at most 256 jobs.` | Split the matrix. | | `A run may have at most 1000 jobs, ...` | Split the workflow, or trim its matrices. | | `Composite action nesting is too deep (max 10).` | Flatten nested composite actions. | | A built-in action behaves differently from the real one | Run the real action: set `DIFFINERY_BUILTINS: false` in the step's `env:`, or pin the action to a commit SHA. | | A job is skipped although its `if:` looks true | A job further up its `needs` chain failed, was cancelled or was skipped, and the `if:` doesn't call a status function. Use `if: always() && ...` or `if: ${{ !cancelled() && !failure() }}`. | --- # Container registry > Push and pull container images on your Diffinery instance, link them to repositories, control who can use them, and publish them from workflows instead of ghcr.io. Source: https://diffinery.com/docs/packages/container-registry Diffinery hosts container images at the instance's own address (`diffinery.com` in these examples), next to the code that builds them. The registry speaks the standard OCI distribution API, so `docker`, `podman`, `buildx`, `crane`, `skopeo`, `oras` and the `docker/*` actions work without changes. Permissions work like ghcr.io: images belong to a user or organization, are private by default, and can be linked to a repository whose collaborators then share access. ## Image names ```text diffinery.com/<owner>/<name>[:<tag>|@sha256:<digest>] ``` | Part | Rules | | --- | --- | | `<owner>` | A user or organization on Diffinery. Agents can't own packages. | | `<name>` | One or more path components separated by `/`, for example `website` or `website/worker`. Each component uses lowercase letters and digits, separated by `.`, `_`, `__` or one or more `-`. | | Whole name | `<owner>/<name>` is at most 255 characters. | | `<tag>` | Up to 128 characters: letters, digits, `_`, `.` and `-`, not starting with `.` or `-`. | | `<digest>` | `sha256:` followed by 64 lowercase hex characters. Other digest algorithms aren't supported. | Image names are lowercase. The owner part is matched without regard to case, so `diffinery.com/acme/website` refers to the organization `Acme` too. In Diffinery's documentation and UI, an image name such as `acme/website` is a **package**, and each manifest pushed to it is a **version**. Tags point at versions. ## Sign in The registry doesn't take passwords. Sign in with any username and a token as the password. ### With a personal access token Create a token under **Settings → Access tokens** (see [Personal access tokens](https://diffinery.com/docs/accounts/tokens)). Its scope decides what it can do in the registry: | Token scope | Registry access | | --- | --- | | `read` | Pull | | `write` | Pull and push | | `admin` | Pull, push and delete versions and packages, change package settings | A token restricted to some repositories only reaches packages linked to one of those repositories, plus public packages, which anyone can pull. ```sh echo "$DIFFINERY_TOKEN" | docker login diffinery.com -u diffy --password-stdin ``` ```text Login Succeeded ``` The same works with other clients: ```sh echo "$DIFFINERY_TOKEN" | podman login diffinery.com -u diffy --password-stdin crane auth login diffinery.com -u diffy -p "$DIFFINERY_TOKEN" ``` Without credentials, the registry answers `401 UNAUTHORIZED: sign in with a Diffinery token: docker login diffinery.com` on `/v2/`. Pulling a public package doesn't need a sign-in. ### With an agent token An [agent](https://diffinery.com/docs/agents/overview) signs in with its own `dfy_` token in the same way. An agent never administers packages through its owner: at most it pushes. What it can reach is described in [Agents](#agents). ### In a workflow A job signs in with its `GITHUB_TOKEN`, as with ghcr.io: ```yaml - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin ``` What the token can do depends on the job's `packages` permission. See [Workflows](#workflows). ## Push and pull ### Push an image Tag the image with its Diffinery name and push it: ```sh docker build -t diffinery.com/acme/website:1.4.0 . docker push diffinery.com/acme/website:1.4.0 ``` The first push to a name creates the package. Who may create it, and which repository it's linked to, is described in [Link a package to a repository](#link-a-package-to-a-repository). Pushing a tag that already exists moves it to the new image. The image it pointed at stays as an untagged version until you delete it. ### Pull an image ```sh docker pull diffinery.com/acme/website:1.4.0 docker pull diffinery.com/acme/website@sha256:4f9a... ``` Pulls work with every client, from anywhere, with no size limit. ### Multi-platform images and OCI artifacts The registry stores OCI image manifests and indexes, and Docker v2 manifests and manifest lists (schema version 2). Multi-platform images built with `docker buildx build --platform linux/amd64,linux/arm64 --push` are stored as one version, with the platforms listed on the package page. Manifests with other config media types, such as OCI artifacts pushed with `oras`, are accepted too. Signatures, SBOMs and attestations that refer to an image through the `subject` field are returned by the referrers API and aren't listed as separate versions on the package page. Schema 1 manifests are refused with `only schemaVersion 2 manifests (OCI or Docker v2) are supported`. ## Visibility | Visibility | Who can pull | | --- | --- | | **Private** (default) | The owning user, people with access to the linked repository, and, for a package that isn't linked to a repository, the members of the owning organization. See [Permissions](#permissions). | | **Public** | Anyone, without signing in. | There are only these two settings. Visibility only controls pulling. Pushing and deleting always need the permissions described below. Change it on the package page: in the **Settings** box, choose **Private: people with access to the linked repository or the owner** or **Public: anyone, without signing in** under **Visibility**, and click **Save**. You need the admin role on the package. Or use the API: ```sh curl -X PATCH https://diffinery.com/api/v3/orgs/acme/packages/container/website \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"visibility": "public"}' ``` Any other value fails with `422 visibility must be private or public`. A package's visibility is independent of its linked repository's: a public image can come from a private repository, and a private image can come from a public one. ## Link a package to a repository A package linked to a repository shares that repository's permissions, so you manage access to the image where you manage access to the code. The repository's **Packages** tab lists it. ### How a package gets linked A new package is linked when it's created in one of these ways: - **By a workflow.** A package created by a workflow's `GITHUB_TOKEN` is linked to the workflow's repository. - **By its name.** A person or agent who pushes `diffinery.com/<owner>/<repo>` or `diffinery.com/<owner>/<repo>/<anything>`, and can write to the repository `<owner>/<repo>`, creates a package linked to that repository. For example, pushing `diffinery.com/acme/website/worker` with write access to `acme/website` links the package `website/worker` to `acme/website`. Such a name belongs to the repository: someone who can't write to `acme/website`, or a workflow of another repository, can't create it ("acme/website/worker is named after the acme/website repository; only people who can write to it can create it"). - **By a label.** When an image has the label or annotation `org.opencontainers.image.source` set to a repository URL of the same owner, on `https://diffinery.com` or `https://github.com`, and the pusher can write to that repository on Diffinery, an unlinked package is linked to it. This happens on the first push of such an image, so an unlinked package also gets linked later. ```dockerfile LABEL org.opencontainers.image.source="https://diffinery.com/acme/website" LABEL org.opencontainers.image.description="The acme.example website" ``` The `org.opencontainers.image.description` label or annotation sets the package's description shown in lists, the first time an image with it is pushed. Only the first 500 characters are kept. A package created any other way, for example by an organization member pushing `diffinery.com/acme/tools`, isn't linked. An unlinked organization package is administered by the member who created it and by the organization's owners and admins; other members can only pull it. Link it to a repository to let that repository's writers and workflows push to it. ### Change the linked repository On the package page, in **Settings**, choose a repository under **Linked repository** (or **None**) and click **Save**. With the API, send the repository's name, or `null` to unlink: ```sh curl -X PATCH https://diffinery.com/api/v3/orgs/acme/packages/container/website \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{"repository": "website"}' ``` You need admin access to the package and admin access to the repository you link it to. The repository must belong to the package's owner. ### When the repository moves When a linked repository is [transferred](https://diffinery.com/docs/repositories/overview#transfer-a-repository) to another owner, its packages move with it and keep following its access: `diffinery.com/acme/website` becomes `diffinery.com/acme-labs/website`. The old name keeps working for pulls and pushes, for anyone the moved package allows (everyone else gets `NAME_UNKNOWN`), until the old owner gets a repository of that name again. Images under a renamed owner's old login resolve the same way. Unlinked packages stay with their owner. An image keeps its own name when it moves: transferring the repository under a new name, or renaming it, doesn't rename its images. Workflows that push to `diffinery.com/${{ github.repository }}` push to the new owner and name from then on, which creates a new package there on the first push. A redirect for an image name ends when the old owner creates a repository whose name is that image name (or its first part, for an image such as `acme/website/api`). A transfer is refused when the new owner already has an image with the name of one of the repository's images. ## Permissions Each caller gets one of four roles on a package: | Role | Can | | --- | --- | | None | Nothing. The package looks like it doesn't exist. | | Pull | Pull images, list tags, view the package page | | Push | Pull, push images and tags | | Admin | Push, delete tags, versions and the package, change visibility and the linked repository | ### People A person gets the highest role that any of these give them: | Relationship | Role | | --- | --- | | The package belongs to your user account | Admin | | You're an owner or admin of the organization that owns the package | Admin | | You're a member of the organization that owns the package | Pull on packages that aren't linked to a repository (a linked one follows your role on its repository), and you can create new packages under the organization | | You're the organization member who created the package (or whose agent did), and it isn't linked to a repository | Admin | | The package is linked to a repository where you have `read` | Pull | | The package is linked to a repository where you have `write` or `maintain` | Push | | The package is linked to a repository where you have `admin` | Admin | | The package is public | Pull | | You're a site administrator | Admin | See [Permissions](https://diffinery.com/docs/accounts/permissions) for repository roles. ### Agents An agent's role through its owner is at most push: | Agent | Role | | --- | --- | | Owned by the user that owns the package | Push | | Owned by the organization that owns the package | Push | | Owned by an owner or admin of the organization that owns the package | Push | | Owned by a member of the organization that owns the package | Pull on packages that aren't linked to a repository, and it can create new packages under the organization. Push on unlinked packages that its owning user, or one of that user's agents, created. | Linked repositories give an agent the same roles as a person, based on the agent's own access to the repository, so an agent with `admin` on the linked repository administers the package. ### Personal and agent tokens A token can't do more than its scope allows (`read`: pull, `write`: push, `admin`: admin), whatever its owner's role. A token restricted to repositories only reaches packages linked to one of them; other packages are treated as missing, except public ones, which it can pull. A restricted token can create a package only when the name links it to one of its repositories. For a server that only pulls, create a `read` token restricted to the repository the image is linked to. ### Workflows A workflow's `GITHUB_TOKEN` follows the `packages` permission of the job's effective permissions, the same ones the rest of the API checks (see [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions)): | `permissions` in the workflow or job | `packages` permission | | --- | --- | | No `permissions` anywhere | The repository's default: `read` when the default is read-only (the default for new repositories), `write` when it is read and write | | `write-all` or `packages: write` | `write` | | `read-all` or `packages: read` | `read` | | A mapping without `packages`, or `packages: none` | none | The job's `permissions` replace the workflow's. A job in a reusable workflow never gets more than the job that calls it, and a run for a pull request from another repository gets `read` at most. With that permission, the token gets these roles: | Package | `read` | `write` | | --- | --- | --- | | Linked to the workflow's repository | Pull | Admin: push and delete versions | | Same owner as the workflow's repository, not linked to any repository | Pull | Pull | | Same owner, linked to another repository | None (Pull if public) | None (Pull if public) | | A new name under the same owner | Can't create | Push: creates the package, linked to the workflow's repository, unless the name's first part is another repository's name | | Another owner's public package | Pull | Pull | | Another owner's private package, or a new name under another owner | None | None | A workflow pushes only to packages linked to its own repository. To let it push to an existing unlinked package, link that package to the workflow's repository first (see [Change the linked repository](#change-the-linked-repository)). A workflow can't change a package's visibility or linked repository itself, because those API calls need a personal or agent token. The token stops working when the job finishes. Sandbox tokens (`dst_`) used in [sandboxes](https://diffinery.com/docs/agents/sandboxes) follow the same rules, with `write` when the person who created the sandbox can write to the repository and `read` otherwise. ## Package pages ### An owner's packages `https://diffinery.com/<owner>/-/packages` lists the packages of a user or organization that you can pull, with each package's visibility, description, image name, linked repository, number of tags and last update. ### The package page `https://diffinery.com/<owner>/-/packages/container/<name>`, for example `https://diffinery.com/acme/-/packages/container/website/worker`, shows: - The visibility, the description, and the linked repository ("Linked to acme/website, which sets who can pull and push." or "Not linked to a repository."). - **Use this image**: the `docker login` and `docker pull` commands, and a workflow snippet that signs in with `GITHUB_TOKEN`. - **Versions**: a table with **Tags**, **Digest** (with a **Copy** button for `diffinery.com/<owner>/<name>@<digest>`), **Platforms**, **Size**, and **Pushed** (when and by whom). Versions without tags show "untagged". Admins see a **Delete** button on each row. - For admins, a **Settings** box with **Visibility** and **Linked repository**, and a **Delete this package** box. ### The repository's Packages tab `https://diffinery.com/<owner>/<repo>/packages` lists the packages linked to the repository, with the same **Use this image** box. Anyone who can read the repository can open it. ## Use the registry in workflows ### Build and push ```yaml name: Image on: push: branches: [main] tags: ["v*"] jobs: image: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - run: echo "IMAGE=diffinery.com/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: diffinery.com username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: | ${{ env.IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:latest ``` `github.repository` keeps the capitals of the owner and repository names, and image names must be lowercase, so the example lowercases it with `${GITHUB_REPOSITORY,,}`. More examples, including plain `docker build` and `docker push` in a `run:` step, are in [Docker in workflows](https://diffinery.com/docs/actions/docker#push-images-to-the-diffinery-registry). ### Use an image as a job container or service ```yaml jobs: test: runs-on: ubuntu-latest permissions: packages: read container: image: diffinery.com/acme/build-tools:2 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} services: api: image: diffinery.com/acme/website:${{ github.sha }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - run: curl -fsS http://api:8080/health ``` With `packages: read`, a workflow can pull its own repository's packages, its owner's packages that aren't linked to a repository, and every public package. A private package linked to another repository is only for that repository's workflows. A later job of the same run (or another workflow) can also pull an image that an earlier job pushed in a `run:` step: ```yaml - run: | echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin docker pull "diffinery.com/acme/website:${{ github.sha }}" docker run --rm "diffinery.com/acme/website:${{ github.sha }}" npm test ``` ### Delete old versions from a workflow A workflow with `packages: write` can delete versions of packages linked to its repository through the REST API: ```yaml - name: Delete untagged versions env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | api="$GITHUB_API_URL/orgs/acme/packages/container/website/versions" curl -fsS -H "Authorization: Bearer $TOKEN" "$api?per_page=100" \ | jq -r '.[] | select(.metadata.container.tags | length == 0) | .id' \ | while read -r id; do curl -fsS -X DELETE -H "Authorization: Bearer $TOKEN" "$api/$id" done ``` ## Delete versions and packages Deleting needs the admin role on the package. Anything that pulls a deleted tag or digest stops working. ### On the web - **A version:** on the package page, click **Delete** in the version's row and confirm. The version and every tag pointing at it are removed, and you see "Version deleted." - **A package:** in **Delete this package**, type the package's name and click **Delete package**. Every tag and version is removed, and you're taken back to the owner's packages with "Package deleted." ### With the REST API ```sh # Delete one version (and its tags) curl -X DELETE -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/orgs/acme/packages/container/website/versions/42 # Delete the package curl -X DELETE -H "Authorization: Bearer dfy_..." \ https://diffinery.com/api/v3/orgs/acme/packages/container/website ``` Both return `204 No Content`. ### With the registry API ```sh # Remove one tag; the version stays, untagged curl -X DELETE -u diffy:dfy_... https://diffinery.com/v2/acme/website/manifests/1.4.0 # Delete a version and every tag pointing at it curl -X DELETE -u diffy:dfy_... https://diffinery.com/v2/acme/website/manifests/sha256:4f9a... ``` Both return `202 Accepted`. Tools such as `crane delete` and `regctl` use these calls. Deleting a multi-platform image's index doesn't delete the per-platform manifests it listed. They appear as untagged versions afterwards, and you can delete them the same way. ## Storage cleanup Layers are stored once even when several images or packages use them. Diffinery cleans up storage on a schedule: - Uploads that weren't finished are discarded after a day. - Layers and manifests that no version uses any more, for example after you delete versions, are removed after a grace period that protects pushes still in progress. Deleting a version or package frees its storage through this cleanup, not immediately. Untagged versions aren't deleted automatically; delete them yourself, for example with the workflow above. ## REST API The packages API follows GitHub's packages REST API under `/api/v3`, so scripts written for it work after changing the base URL. `{package_type}` must be `container` (or `docker`); any other type returns `404 Diffinery hosts container packages only (got <type>)`. URL-encode a `/` in a package name as `%2F`, for example `website%2Fworker`. | Request | Description | | --- | --- | | `GET /user/packages?package_type=container` | Your own packages | | `GET /users/{user}/packages` | A user's packages you can pull. `?visibility=private` or `?visibility=public` filters. | | `GET /orgs/{org}/packages` | An organization's packages you can pull, with the same filter | | `GET /{owner path}/packages/container/{name}` | One package | | `PATCH /{owner path}/packages/container/{name}` | Diffinery only: change `visibility` (`private` or `public`) and `repository` (a repository name, or `null` to unlink). Needs a personal or agent token with admin access. | | `DELETE /{owner path}/packages/container/{name}` | Delete the package | | `GET /{owner path}/packages/container/{name}/versions` | Versions, newest first. `per_page` (default 30, at most 100), `page`, and `state=tagged` for tagged versions only. | | `GET /{owner path}/packages/container/{name}/versions/{id}` | One version | | `DELETE /{owner path}/packages/container/{name}/versions/{id}` | Delete a version and its tags | | `GET /repos/{owner}/{repo}/packages` | Diffinery only: packages linked to a repository | `{owner path}` is `user`, `users/{user}` or `orgs/{org}`. Without a token, these endpoints only show public packages. A package you can't pull returns `404 package not found` (`401` without a token), and an action your role doesn't allow returns `403` with a reason, such as `admin access to this package is required`. A package: ```json { "id": "0f3c2a9d8e7b6a5c4d3e2f1a0b9c8d7e", "name": "website", "package_type": "container", "owner": { "login": "acme", "type": "Organization" }, "version_count": 12, "visibility": "private", "description": "The acme.example website", "image": "diffinery.com/acme/website", "url": "https://diffinery.com/api/v3/orgs/acme/packages/container/website", "html_url": "https://diffinery.com/acme/-/packages/container/website", "repository": { "id": "8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f", "name": "website", "full_name": "acme/website", "html_url": "https://diffinery.com/acme/website" }, "created_at": "2026-09-01T10:00:00.000Z", "updated_at": "2026-10-12T08:15:00.000Z" } ``` A version: ```json { "id": 42, "name": "sha256:4f9a...", "url": "https://diffinery.com/api/v3/orgs/acme/packages/container/website/versions/42", "package_html_url": "https://diffinery.com/acme/-/packages/container/website", "html_url": "https://diffinery.com/acme/-/packages/container/website/sha256:4f9a...", "created_at": "2026-10-12T08:15:00.000Z", "updated_at": "2026-10-12T08:15:00.000Z", "media_type": "application/vnd.oci.image.index.v1+json", "size": 48213377, "platforms": ["linux/amd64", "linux/arm64"], "pushed_by": "diffy", "metadata": { "package_type": "container", "container": { "tags": ["1.4.0", "latest"] } } } ``` Every endpoint is also in the [API reference](https://diffinery.com/docs/api). ## Registry API The OCI distribution API is served at `https://diffinery.com/v2/`. It supports: | Feature | Notes | | --- | --- | | Blob uploads | Monolithic (`POST` then `PUT`), chunked (`PATCH` with `Content-Range`) and streamed uploads, and cross-repository mounts (`?mount=&from=`) between packages you can pull from | | Blob downloads | `GET` and `HEAD`, with `Range` requests | | Manifests | `GET`, `HEAD`, `PUT` and `DELETE` by tag or digest. OCI image manifests and indexes, Docker v2 manifests and manifest lists, at most 4 MiB each. | | Tags | `GET /v2/<owner>/<name>/tags/list`, with `n` (at most 10,000) and `last` for pagination and a `Link` header for the next page | | Referrers | `GET /v2/<owner>/<name>/referrers/<digest>`, with an `artifactType` filter | | Catalog | `GET /v2/_catalog` lists the packages you can pull, with `n` and `last` | Errors use the standard registry format, `{"errors":[{"code": "...", "message": "..."}]}`. Docker prints the code in lowercase words followed by the message, for example `denied: <message>` for `DENIED`. ## Size limits | Limit | Value | | --- | --- | | One request | 100 MB. Diffinery runs behind Cloudflare, which refuses larger request bodies. | | One manifest | 4 MiB. A larger one fails with `413 MANIFEST_INVALID: manifests are limited to 4 MiB`. | | Image name | 255 characters | | Pull size | No limit | `docker push` and most other clients send each layer in a single request. That affects where you can push large layers from: - **Hosted runners:** layers of any size push. The runner's Docker daemon and BuildKit builders (including those from `docker buildx create` and `docker/setup-buildx-action`) reach the registry through a local relay that splits uploads into chunks. Other clients run directly in a step, such as `crane` or `skopeo`, don't go through the relay. - **Laptops, servers and self-hosted runners:** a layer over 100 MB fails to push, with `413` or a reset connection. Push it from a hosted workflow instead, use a client that sends chunked uploads, or split the layer by putting large dependencies in their own `RUN` or `COPY` instructions. See [Limits](https://diffinery.com/docs/reference/limits) for the rest of Diffinery's limits. ## Use images outside Diffinery To pull private images on a server or in Kubernetes, create a `read` token restricted to the repository the package is linked to, and use it as the registry password. For Kubernetes: ```sh kubectl create secret docker-registry diffinery \ --docker-server=diffinery.com \ --docker-username=diffy \ --docker-password=dfy_... ``` ```yaml apiVersion: v1 kind: Pod metadata: name: website spec: imagePullSecrets: - name: diffinery containers: - name: web image: diffinery.com/acme/website:1.4.0 ``` Tokens expire after at most 366 days, so plan to rotate the secret. Public packages need no secret. ## Migrating from ghcr.io 1. Replace `ghcr.io` with your instance's host (`diffinery.com` here) in image names, in `docker login` commands, and in `registry:` for `docker/login-action`. 2. Keep `permissions: packages: write` and `secrets.GITHUB_TOKEN`; they work the same way. 3. Keep the `org.opencontainers.image.source` label. A label pointing at `https://github.com/acme/website` links the package to `acme/website` on Diffinery, if that repository exists here and the pusher can write to it. 4. Lowercase image names built from `github.repository`, as on ghcr.io. 5. Copy existing images you still need. To copy images with all their platforms, use `crane copy` from a machine signed in to both registries. This works when every layer is under 100 MB: ```sh crane auth login ghcr.io -u diffy -p "$GITHUB_PAT" crane auth login diffinery.com -u diffy -p "$DIFFINERY_TOKEN" crane copy ghcr.io/acme/website:1.4.0 diffinery.com/acme/website:1.4.0 ``` For larger layers, copy in a workflow on a hosted runner with `docker pull`, `docker tag` and `docker push`, which go through the relay. Store a GitHub token with `read:packages` as a secret, here `GHCR_TOKEN`: ```yaml name: Copy images from ghcr.io on: workflow_dispatch: inputs: tag: type: string required: true jobs: copy: runs-on: ubuntu-latest permissions: packages: write steps: - run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u diffy --password-stdin - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin - run: | docker pull "ghcr.io/acme/website:${{ inputs.tag }}" docker tag "ghcr.io/acme/website:${{ inputs.tag }}" "diffinery.com/acme/website:${{ inputs.tag }}" docker push "diffinery.com/acme/website:${{ inputs.tag }}" ``` `docker pull` copies only the runner's platform (`linux/amd64`). To move a multi-platform image with large layers, rebuild it on Diffinery with `docker buildx build --platform ... --push`. ## Differences from ghcr.io - Visibility is private or public. A public package is pullable by anyone; a private one only by the people and workflows described in [Permissions](#permissions). - A workflow's token with `packages: read` can pull its owner's packages that aren't linked to any repository; ghcr.io asks for each package to grant the repository access first. - The link to a repository can be changed after creation, on the package page or with `PATCH`. - Package settings, visibility and deletion are on the package page; there is no separate access list per package. - A single request is limited to 100 MB outside hosted runners. - Only container packages exist. npm, Maven, NuGet and RubyGems packages aren't hosted. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `denied: acme has reached its storage limit of N bytes (...)` | The package owner is at its [storage quota](https://diffinery.com/docs/reference/limits#account-quotas). Image blobs and manifests count toward it with the owner's repositories (a blob several images use counts once). Delete old image versions, or ask a site administrator to raise it. Pulls keep working. | | `unauthorized: sign in with a Diffinery token: docker login diffinery.com` | You aren't signed in. Run `docker login diffinery.com` with a token as the password. | | `unauthorized: authentication required` | The package is private and the client sent no credentials, or the credentials were rejected. Sign in again with a valid, unexpired token. | | `name unknown: repository acme/website not found` | The package doesn't exist, or you can't pull it. A reason in parentheses explains token limits, for example `(this token only has the read scope)`. | | `name unknown: no user or organization named <owner>` | Check the owner in the image name. | | `name invalid: invalid image name "...": use diffinery.com/<owner>/<name> with lowercase letters, digits, '.', '_', '-' and '/'` | Lowercase the name and remove other characters. | | `denied: you can't create acme/tools: ...` | You have no role under this owner that allows creating the package. Organization members can create packages; people outside the organization need write access to a repository and a name that starts with that repository's name, such as `acme/website/tools`. | | `denied: pushing to acme/website is not allowed: this token only has the read scope` | Use a token with the `write` scope. | | `denied: pushing to acme/tools is not allowed` | You can pull but not push. For an organization member, this happens on an unlinked package another member created: ask its creator or an organization owner to link it to a repository you can write to. | | `denied: deleting from acme/website is not allowed` | Deleting needs the admin role, and an `admin`-scoped token. | | `... the workflow's GITHUB_TOKEN has no packages permission; add permissions: packages: write` | The job's `permissions` leave out `packages`. Add `packages: write` (or `read` to pull). | | `... the workflow's GITHUB_TOKEN has packages: read; add permissions: packages: write to push` | Add `permissions: packages: write` to the workflow or job. Without `permissions`, the job gets the repository's default, which is read-only for new repositories. | | `... acme/website belongs to the acme/website repository, so only its workflows can push to it` | The package is linked to another repository. Push from that repository's workflows, or change the linked repository. | | `... acme/tools isn't linked to a repository; link it to acme/website in its settings so this workflow can push to it` | Workflows push only to their own repository's packages. Link the package to the workflow's repository on the package page. | | `... workflows in acme/website can only create packages under acme` | A workflow tried to create a package under another owner. Use a personal or agent token from a secret. | | `... the job token is no longer valid` | The job has finished. Use the token only while the job runs. | | A push fails with a `413` error, or the connection resets during a push | A layer is over 100 MB. Push from a hosted runner or split the layer; see [Size limits](#size-limits). | | `manifest blob unknown: blob sha256:... must be uploaded before the manifest that uses it` | The client pushed a manifest before its layers. Retry the push; for copies between registries, use a tool that copies layers first. | | `manifest blob unknown: manifest sha256:... must be pushed before the index that lists it` | Push the per-platform manifests before the index. | | `blob upload unknown: upload not found; it may have expired` | The upload was idle for more than a day or was cancelled. Push again. | | `manifest invalid: only schemaVersion 2 manifests (OCI or Docker v2) are supported` | Rebuild the image with a current Docker or Buildx. | | `404 Diffinery hosts container packages only (got <type>)` | The API path uses a package type other than `container`. | | `422 visibility must be private or public` | The `PATCH` body has another `visibility` value. Use `private` or `public`. | --- # Agents > Agent accounts for AI tools and automation, how to create them and their tokens, what they can and can't do, and how to connect a coding agent. Source: https://diffinery.com/docs/agents/overview An agent is an account for an AI tool or an automation, owned by a person or an organization. It has its own login and tokens, acts on behalf of its owner, and is held to guardrails that keep a human in the loop on protected branches. Give each AI coding agent, review bot or script its own agent account instead of a personal token. ## What an agent is - **An account of its own.** An agent has a login (for example `review-bot`), a display name and its own tokens. Everything it does is recorded under its login, with its owner alongside. - **Owned by a person or an organization.** The owner is set when the agent is created. Security emails about the agent go to its owner (for an organization, to the organization's owners and admins). - **Tokens only.** Agents authenticate with `dfy_` tokens over git, the REST API, the CLI and MCP. They can't have passkeys, so they can't sign in to the web UI, and they have no notification settings. - **Never more than its owner.** An agent's role on a repository is capped by a maximum role (write by default) and by its owner's own role there. - **Guardrails everywhere.** The same rules apply to git pushes, the REST API, MCP tools and sandboxes. See [Guardrails](#guardrails). ## Create an agent Only people can create agents. To create an agent owned by an organization, you must be an owner or admin of that organization. Creating an agent or an agent token needs a sign-in from the last 2 hours; if yours is older, Diffinery asks you to [Confirm it's you](https://diffinery.com/docs/security/overview#confirming-its-you) with your passkey first. The agent's login follows the same rules as usernames: letters, digits, `-` and `_`, starting with a letter or digit, up to 100 characters, no dots, and not a reserved name (such as `api`, `mcp`, `settings` or `admin`). ### In the web UI 1. Go to **Settings → Agents** (`https://diffinery.com/settings/agents`). 2. Under **Create an agent**, fill in: - **Agent username**, for example `review-bot`. - **Display name** (optional), for example `Review bot`. Without one, the agent's username is shown. - **Owner**: yourself, or an organization where you are an owner or admin. - **Maximum role**: the highest role the agent can have on any repository (default `write`). 3. Click **Create agent**. You see `Agent created. Create a token for it below.` 4. In the agent's row, expand **Create a token for review-bot**, set the token **Name**, **Scope** and **Days** (1 to 366, default 30), and click **Create token**. 5. Copy the token from **Token for review-bot**. It is shown only once. ### With the CLI Creating an agent mints its first token, and no token can create a token, so on a deployed instance create agents in the web UI (above). `diffinery agent create` works against a local development server. Listing and disabling agents work from the CLI with a personal token that has the `admin` scope and isn't restricted to repositories: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login --scope admin ``` On a local development server, create the agent like this. The command also creates its first token and prints it once: ```sh diffinery agent create review-bot --org acme --name "Review bot" --scope write --expires-days 90 ``` | Flag | Meaning | | --- | --- | | `-o, --org ORG` | Owned by this organization instead of you | | `-n, --name NAME` | Display name | | `-s, --scope SCOPE` | Token scope: `read`, `write` (default) or `admin`. Repeatable. | | `-e, --expires-days DAYS` | Token lifetime in days (default 90) | | `--json` | Print the agent and token as JSON | The output ends with `This token (expires 2027-01-11) will not be shown again. Give it to the agent as DIFFINERY_TOKEN.` The CLI creates agents with the default maximum role, `write`. `diffinery agent list` lists the agents you and your organizations own, and `diffinery agent disable review-bot` disables one. ### With the REST API Creating an agent mints a token, so it needs a browser session: a token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. Send it from a page on your instance while signed in, for example from the browser console: ```js await fetch("/api/v3/diffinery/agents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ login: "review-bot", name: "Review bot", org: "acme", policy: { maxRole: "write" }, token: { scopes: ["write"], expires_in_days: 90 }, }), }).then((r) => r.json()); ``` The response (`201`) contains the agent (`id`, `login`, `name`, `type: "Bot"`, `html_url`) and its first token (`token`, `id`, `expiresAt`). Leave out `org` to own the agent yourself, `policy.maxRole` for the default `write`, and `token` for a `write` token that lasts 90 days. `token.scopes` (and `scopes` when you create another token) must be a non-empty list of `read`, `write` and `admin`; leaving it out gives `["write"]`. An empty list or an unknown name returns `422 scopes must be a non-empty list of: read, write, admin`. The `POST` endpoints below create tokens, so they need a browser session. `DELETE` also accepts a personal token with the `admin` scope that isn't restricted to repositories. | Endpoint | Purpose | Who | | --- | --- | --- | | `GET /api/v3/diffinery/agents` | List agents owned by you and by organizations where you are an owner or admin | You | | `POST /api/v3/diffinery/agents` | Create an agent and its first token | People | | `POST /api/v3/diffinery/agents/{login}/tokens` | Create another token (`name`, `scopes`, `expires_in_days`) | The owner, or an owner or admin of the owning organization | | `DELETE /api/v3/diffinery/agents/{login}` | Disable the agent and revoke its tokens | The owner, an owner or admin of the owning organization, or a site admin | ## Tokens for agents Agent tokens work like personal tokens (see [Tokens](https://diffinery.com/docs/accounts/tokens)): - They start with `dfy_` and are sent as `Authorization: Bearer dfy_...` (or as the password for git over HTTPS). - Each has a scope that caps the role it acts with: `read` (read), `write` (up to maintain) or `admin`. Use `write` for coding agents. A `read` token can't change anything at all: pushes, commits, comments, reviews and every MCP tool that isn't read-only get `403 this token needs the write scope`. Give `read` tokens only to agents that just look. - Each expires after 1 to 366 days. Diffinery emails the owner before an agent token expires. - Creating a token for an agent sends a security email to the agent's owner (for an organization, its owners and admins): `A token was created for the agent review-bot`. - In **Settings → Agents**, each agent lists its tokens with scope and last use, and a **revoke** link. Give each running agent its own token, store it as a secret in the agent's environment (for example `DIFFINERY_TOKEN`), and revoke it when the agent no longer needs it. ## Roles and access An agent made on a repository's **Settings → Agent setup** reaches that one repository and nothing else, whatever token it uses ([Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents#the-token)). For every agent, its role on a repository is the lowest of: 1. **Its cap on that repository.** If the agent was added as a collaborator on the repository (**Settings → Collaborators**, **Username (person or agent)**), that role. Otherwise its **Maximum role** (default `write`). 2. **Its owner's role there.** - An agent owned by a person has at most that person's role on the repository, so it can reach exactly the repositories its owner can. - An agent owned by an organization can reach only the organization's own repositories, and nothing outside the organization. 3. **Its token's scope**, as for any token. Agents rarely need more than write. Write is enough to push branches, commit files, open and review pull requests, run workflows and use sandboxes. Whatever the role, the guardrails below still apply. The agent's profile page (`https://diffinery.com/review-bot`) shows who it **Acts on behalf of**, its **Maximum role**, its **Status** and when it was **Created**. ## Guardrails These rules apply to agents through git, the REST API, MCP and sandboxes: | Rule | Details | How to relax it | | --- | --- | --- | | No pushes to the default branch or protected branches | Pushes, `commit_files`, the contents API, the git data refs API, the merges API, update-branch and branch updates are refused with `agents may not push to protected or default branches; open a pull request`. One exception: in an empty repository (no branches yet), an agent may push the first commit of the default branch, as creating a repository with a README would, unless a protection rule covers that branch or the commit adds workflow files (those come in a pull request a person merges). | Tick **Allow agents to push directly** (`agentsMayPush`) on that branch's protection rule. | | No merges into the default branch or protected branches | The merge blocker `agents may not merge into this branch; a human must merge`. Agents can't add pull requests to a merge queue or turn on auto-merge there either. | An agent from **Settings → Agent setup** may merge what a person approved, with **Can merge pull requests** (on by default; see [Merging](https://diffinery.com/docs/connect/cloud-agents#merging)). Or tick **Allow agents to merge pull requests** (`agentsMayMerge`) on the rule, or, so that CI decides instead of the agent, **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`, see [Agents landing changes](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes)); the agent then turns on [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge-for-agents-and-workflow-tokens) with `enable_auto_merge` or `PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge`. | | Pull requests opened by agents need a human approval | At least one approval from a person, even on a branch with no protection rule. When an agent has Diffinery land its pull request on the default branch or a protected branch through auto-merge or the merge queue, the approval counts only for the commit it approved. | Only for landings an agent asked for: **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`), which lets green required checks stand in for the approval. See [Waiving approvals for agents](https://diffinery.com/docs/repositories/branch-protection#waiving-approvals-for-agents). To have Diffinery turn auto-merge on for chosen agents' pull requests by itself, name them in **Auto-merge pull requests from these agents** (`autoMergeAgents`, see [Auto-merging agents' pull requests](https://diffinery.com/docs/repositories/branch-protection#auto-merging-agents-pull-requests)), or tick the agent's own **Merge its pull requests on green**. The repository's **Settings → Merge on green** sets all of it up at once; see [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green). | | Agent approvals don't count | An agent's Approve review is shown but never counts toward required approvals. An agent's Request changes review still blocks. | None. | | No branch protection changes | `agents cannot change branch protection` | None. | | No secret management | Setting or deleting repository or environment secrets fails with `agents cannot manage secrets`, and agents can't use the organization secrets endpoints, whatever their role. | None. | | No variable changes | Creating, changing or deleting repository or environment Actions variables fails with `agents cannot manage variables`, whatever the agent's role. Agents with write or higher can still list and read variables. | None. | | No environment changes | Creating, changing or deleting an environment or its deployment branch policies fails with `only repository admins (people, not agents) can manage environments`. | None. | | Agent reports never satisfy required checks | Commit statuses and check runs an agent reports are shown on the commit, but never count toward a required status check, and aren't counted when a deployment checks the commit's statuses. A failure reported by the real source still fails. | None. Report required checks from workflows or from a person's token. | | Workflow runs of agent code act as the agent | A run counts as coming from an agent when an agent triggered it, an agent pushed the branch or tag it runs on last, (for pull request events) an agent opened the pull request or last pushed its head branch, or (off the default branch) its code includes a commit an agent pushed that a person hasn't merged into the default branch yet, whoever pushed, tagged or dispatched on top. Re-runs of such a run keep this, whoever starts them, and so do `workflow_run` runs that follow it, since they act on what it produced. The run's jobs act as an agent: their `GITHUB_TOKEN` gets the guardrails in this table, so it can't push or merge to the default or protected branches even with `permissions: contents: write`, and their deployments count as an agent's. Agent-written code can't reach a protected branch through a workflow. | The same per-branch settings, **Allow agents to push directly** and **Allow agents to merge pull requests**. | | No repository or organization secrets for agent runs | A run whose code came from an agent (see the row above) gets no repository or organization secrets, only its job token, so an agent can't push a workflow that sends them elsewhere. Once an agent lands a pull request on the default branch through auto-merge, runs on that branch are agent code too, so a deploy there gets no repository secrets. | A repository admin who is a person ticks **Give runs of agent code the repository's and organization's secrets** under **Settings → Actions** (`agent_runs_use_secrets`), or, tighter, keeps deploy credentials in an environment limited to protected branches (next row). See [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets). | | No environment secrets for agent runs on open environments | A job with `environment:` in a run whose code came from an agent (see the row above) gets the environment's secrets only when the environment is limited to protected branches. Pull request runs never get environment secrets. | Limit the environment to **protected branches**. | | Agent tags don't match environment tag rules | A tag last pushed or moved by an agent never matches an environment's tag pattern, so it can't deploy to that environment. | Have a person push the tag. | | No review dismissal | Dismissing a review fails with `agents can't dismiss reviews`, whatever the agent's role. | None. | | No repository deletion | `agents cannot delete repositories` | None. | | No repository transfers | `agents and workflow tokens cannot transfer repositories`. Only people move repositories to another owner. | None. | | No push protection bypass | Only repository admins who are people can commit past secret scanning, with `git push -o allow-secrets=<reason>` or `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits`. An agent's push or commit that contains a credential is always refused (and `commit_files` has no bypass at all); see [Push protection](https://diffinery.com/docs/security/push-protection). | None. | | No admin bypass of merge requirements | Merging past unmet branch rules (`"bypass_rules": true`) or past the merge queue (`"bypass_merge_queue": true`) is for repository admins who are people. An agent's merge with these flags is still refused with the usual blockers. | None. | | No creating agents | `only people can create agents` | None. | | Imports from GitHub only where the owner may import | A person's agent imports into the owner's own account or an organization the owner owns or administers; an organization's agent imports into that organization (`agents can import only where their owner may: ...`). Its maximum role must be at least `write`. | None. | | No push protection bypass in imports | An agent's import always stops when old history contains something that looks like a credential, whatever it asks for. | A person who administers the target starts the import. | | No variables, secret names or environments in imports | The import's variables steps are skipped (`skipped (agents cannot manage variables)`). | A person sets them up after the import. | | No import retry once the repository exists | `an agent cannot retry an import after the repository was created; a person who administers <owner> must retry it` | A person who administers the target retries it. | The default branch is protected from agents even if it has no protection rule. Agents can still push freely to other branches, open pull requests, comment, review, run workflows, report commit statuses and check runs, create releases and start sandboxes, within their role. To let a trusted agent push to a release branch, for example, add a protection rule for `release/*` with **Allow agents to push directly**. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection), [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses) and [Environments](https://diffinery.com/docs/actions/environments). ## How agent activity is recorded ### Commits Commits that Diffinery writes for an agent are stamped with an extra header naming the agent and its owner, `diffinery-agent review-bot on-behalf-of diffy`. This covers commits made through: - the `commit_files` MCP tool and `POST /repos/{owner}/{repo}/diffinery/commits`; - the contents API (`PUT` and `DELETE /repos/{owner}/{repo}/contents/{path}`); - the git data API (`POST /repos/{owner}/{repo}/git/commits`); - the merges API (`POST /repos/{owner}/{repo}/merges`) and updating a pull request's branch (`PUT /repos/{owner}/{repo}/pulls/{number}/update-branch`); - merge and squash commits of pull requests the agent merges, directly or through auto-merge. The author and committer are the agent, for example `review-bot <review-bot@users.noreply.diffinery.com>`, unless the request sets `author` or `committer` (the contents and git data APIs accept them). The header is added either way. The header is part of the commit, so it is covered by the commit's SHA and travels with it. See it with `git cat-file`: ```sh git cat-file -p 9be41d07 ``` ```text tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 parent 51c0e2aa... author review-bot <review-bot@users.noreply.diffinery.com> 1791883964 +0000 committer review-bot <review-bot@users.noreply.diffinery.com> 1791883964 +0000 diffinery-agent review-bot on-behalf-of diffy Fix the Contact sales link ``` Commits an agent makes with its own git client and pushes are stored as the client wrote them. Inside a [sandbox](https://diffinery.com/docs/agents/sandboxes), git is preconfigured to author commits as the agent. ### Everywhere else - **Web UI:** agents have a bot avatar, an `agent` badge and `on behalf of <owner>` next to their name on issues, pull requests, reviews, comments and workflow runs. - **REST API:** user objects for agents have `"type": "Bot"` (people are `"User"`, organizations `"Organization"`). `diffinery whoami` adds `[agent]`, and the MCP `whoami` tool returns `kind: "agent"` and `on_behalf_of`. - **Webhooks:** the `sender` of an event caused by an agent has `"type": "Bot"` and `on_behalf_of` set to the owner's login. - **Audit log:** entries for agent actions (pushes, merges and other audited actions) record the owner in `on_behalf_of`. Creating, disabling and enabling agents, and creating agent tokens, are audited (`agent.create`, `agent.disable`, `agent.enable`, `agent.token.create`). See [Audit log](https://diffinery.com/docs/security/audit-log). - **Email:** the person who creates an agent gets a security email, `A new agent was added: review-bot`, which can't be turned off. ## Imports from GitHub Agents can import a GitHub repository with the `start_github_import` MCP tool (or `POST /api/v3/diffinery/imports`, or `diffinery migrate github` with the agent's token), and follow it with `get_import` and `list_imports`. The import runs on the server and copies the Git history, issues and pull requests with their numbers, comments, reviews, labels, milestones, branch protection and releases. The agent rules: - The target must be somewhere the agent's owner may import: for a person's agent, the owner's own account or an organization the owner owns or administers; for an organization's agent, that organization. - The agent's maximum role must be at least `write`, and its token needs the `write` scope. - Push protection is never bypassed: the `secrets` option is always `stop`, so history with something that looks like a credential fails the import instead of being imported. - Actions variables, secret names, environments and organization variables are skipped. Agents never read or set secret values. - Once the repository exists, the agent can't retry a failed import; a person who administers the target retries it. - The agent sees only the imports it started. Everything the import writes fires no workflows, webhooks or notifications, and the repository is locked against other writes until the import ends. Imported reviews never count as approvals, so an imported open pull request still needs a fresh human approval. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github#agents-and-imports) and [Imports over MCP](https://diffinery.com/docs/agents/mcp#imports-over-mcp). ## Disable an agent Disabling an agent revokes all its tokens at once. - **Web UI:** **Settings → Agents**, click **Disable** next to the agent. **Enable** turns it back on, but its old tokens stay revoked: create a new token. - **CLI:** `diffinery agent disable review-bot` - **REST API:** `DELETE /api/v3/diffinery/agents/review-bot` (returns `204`). To cut off one running copy without disabling the agent, revoke just its token. ## Agent tasks, agent review and Today - **[Agent tasks](https://diffinery.com/docs/agents/tasks)**: on a repository's **Project** tab, give one of your agents a goal. Diffinery runs Claude Code or Codex (on their own providers, or via [OpenRouter](https://diffinery.com/docs/agents/tasks#openrouter) on a model you choose) or your own command in a sandbox as that agent, with your own model API key (saved under **Settings → Model access**, and never placed in the sandbox), then pushes a branch and opens a pull request for review. - **[Projects](https://diffinery.com/docs/agents/projects)**: the Project tab shows a repository's tasks on a board, with a conversation, a shared memory every task reads, project instructions, and a limit on how many tasks run at once. Your own coordinator agent can turn your posts there into tasks. - **[Agent review](https://diffinery.com/docs/agents/review)**: one of your agents reviews pull requests for bugs, security issues and logic errors, like Cursor's Bugbot. It posts inline findings and an **Agent review** check, and **Fix it** starts a task that pushes the fix. - **[Terminals](https://diffinery.com/docs/agents/terminal)**: a shell in your browser, in a sandbox with the repository checked out, acting as one of your agents. Start Claude Code or Codex there and sign in with your own Claude or ChatGPT subscription instead of an API key, then work in it yourself or [give it a task](https://diffinery.com/docs/agents/terminal#give-it-a-task) that runs on that subscription and opens a pull request. - **[Today](https://diffinery.com/docs/agents/today)**: the first page after signing in, also `GET /api/v3/diffinery/today` and the `today` MCP tool. It shows the pull requests that need you, your agents' open pull requests and tasks, and what merged, was released or failed in the last 24 hours. ## Set up a coding agent Coding agents such as Claude Code, Cursor or a CI bot can work with Diffinery in two ways: - **MCP:** the agent calls tools on `https://diffinery.com/mcp` to read files, commit, open pull requests, follow CI and run commands in sandboxes, with no git client needed. Best for agents that already speak MCP. - **git and the CLI:** the agent clones the repository, edits files locally, pushes a branch, and uses the `diffinery` CLI for pull requests and CI. ### Recommended setup For an agent in a cloud sandbox, such as a Claude Code cloud environment, the repository's **Settings → Agent setup** does steps 1, 3 and 4 for you: it makes an agent that can only reach that repository, and gives you the token, setup script and environment variables to paste. See [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents). 1. **Create an agent** owned by you or your organization, with maximum role `write`, and a `write` token. 2. **Protect the default branch:** add a branch protection rule with **Require a pull request before merging**, at least one **Required approvals**, and your CI checks in **Required status checks**. Agents can't push to it either way; the rule makes sure every change is reviewed and tested. 3. **Give the token to the agent** as an environment variable, for example `DIFFINERY_TOKEN`, never committed to a repository. 4. **Connect the agent** with MCP or git, as below. 5. **Tell the agent how to work.** For example, in your agent instructions file: work on a new branch, open a pull request with an `intent` that states the goal, wait for checks with `diffinery pr checks --watch` or `list_workflow_runs`, and fix failures before asking for review. ### Connect over MCP claude.ai, Claude Code and Codex can sign in through your browser instead of using a token, and a plugin teaches Claude Code and Codex how Diffinery repositories work: see [Connect AI apps](https://diffinery.com/docs/connect). With a token, for Claude Code: ```sh claude mcp add --transport http diffinery https://diffinery.com/mcp \ --header "Authorization: Bearer dfy_..." ``` For Cursor, add to `.cursor/mcp.json` (or `~/.cursor/mcp.json`): ```json { "mcpServers": { "diffinery": { "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_..." } } } } ``` `diffinery mcp-config` prints a ready-made snippet that reads the token from `${DIFFINERY_TOKEN}`. See [MCP server](https://diffinery.com/docs/agents/mcp) for other clients, the tool list and example calls. ### Connect with git and the CLI ```sh export DIFFINERY_TOKEN=dfy_... diffinery auth setup-git # git uses the token for https://diffinery.com git clone https://diffinery.com/acme/website.git cd website git switch -c fix-pricing-link # ...edit and commit... git push -u origin fix-pricing-link diffinery pr create --title "Fix the Contact sales link" \ --intent "Point the Contact sales link at /contact; no other changes." diffinery pr checks --watch # wait for this branch's pull request: exit 0 passed, 1 failed ``` As with `gh`, `pr view`, `pr checks`, `pr diff`, `pr comment`, `pr review`, `pr ready` and `pr merge` act on the open pull request for the current branch when you leave out its number. Without `--watch`, `diffinery pr checks` returns at once and exits `8` while checks are still pending. The CLI reads `DIFFINERY_TOKEN` (and `DIFFINERY_URL`, which defaults to `https://diffinery.com`) from the environment, so no `diffinery login` is needed. Without the CLI, use the token as the git password: `git clone https://x-token:dfy_...@diffinery.com/acme/website.git`. ## Differences from GitHub - Agents are accounts owned by a person or an organization, rather than GitHub Apps with installations, and authenticate with ordinary `dfy_` tokens. - Agents are held to built-in guardrails (no pushes or merges to default and protected branches, human approval for their pull requests, agent approvals not counted) that you relax per branch with `agentsMayPush`, `agentsMayMerge`, `allowAgentAutoMerge` and `agentAutoMergeWithoutApproval`; `autoMergeAgents` has Diffinery turn auto-merge on for chosen agents by itself. The guardrails also hold for workflow runs of code an agent wrote, so an agent can't use a workflow's `GITHUB_TOKEN` to get around them. - Commit statuses and check runs reported by agents never satisfy required checks, and agents can't manage environments, secrets or Actions variables, or dismiss reviews. - Agents can run GitHub imports, within the import rules above. - Commits Diffinery writes for agents carry a `diffinery-agent ... on-behalf-of ...` header. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `agents may not push to protected or default branches; open a pull request` | Push to a new branch and open a pull request, or enable **Allow agents to push directly** on that branch's rule. | | `agents may not merge into this branch; a human must merge` | A person must merge. Or turn on auto-merge (`enable_auto_merge`) on a branch whose rule allows it (`allowAgentAutoMerge`), or enable **Allow agents to merge pull requests** on the rule. | | `needs 1 more approving review(s)` on an agent's pull request | A person with write access must approve it. Agent approvals don't count. | | `only people can create agents` | You called the agents API with an agent's token. Use your own. | | `agents cannot manage secrets` | A person with admin access must set or delete repository, environment and organization secrets. | | `agents cannot manage variables` | A person with admin access must create, change or delete repository and environment variables. | | A workflow on the agent's branch fails to push to `main` with `agents may not push to protected or default branches; open a pull request` | The run's code came from an agent, so its job token acts as the agent. Open a pull request from the branch and have a person merge it. | | `push protection: possible secret detected (...)` from `commit_files` or the commits API | A file the agent committed looks like a credential. Remove it, or mark a test fixture with `diffinery:allow-secret`. Agents can't bypass push protection. | | `this token needs the write scope` | The agent's token has only the `read` scope. Create a `write` token for it. | | `this token needs the admin scope` | Creating agents or agent tokens, or disabling an agent, through the API or CLI needs your personal `admin` token. | | `this token is limited to repositories and can't change account settings` | Managing agents needs a personal token without a repository list. | | `scopes must be a non-empty list of: read, write, admin` | The `scopes` you sent were empty or had an unknown name. | | `only repository admins (people, not agents) can manage environments` | A person with admin access must create or change the environment. | | `agents can't dismiss reviews` | A person with maintain access must dismiss the review. | | `required check 'NAME' has not run` although an agent reported it | Agent reports never satisfy required checks. Report it from a workflow or with a person's token. | | `only organization admins can create organization agents` | You must be an owner or admin of the organization. | | `invalid or reserved agent name` | Use letters, digits, `-` and `_`, no dots, and avoid reserved names. | | `name already taken` | Pick another login; agents share the namespace with people and organizations. | | `not your agent` | Only the owner, or an owner or admin of the owning organization, can manage it. | | `repository not found` for a repository you can see | The agent's owner (or, for an organization agent, the organization) has no access to it. | | `agents can import only where their owner may: ...` | Import into the owner's own account or an organization the owner administers; an organization's agent imports only into that organization. | | `this agent's policy does not allow writing; an import needs at least write` | Raise the agent's **Maximum role** to `write` or higher. | | `skipped (agents cannot manage variables)` on an import | Expected for an agent's import. A person sets variables, environments and secrets afterwards. | | `invalid, expired or revoked token` | The token expired or was revoked, or the agent is disabled. Create a new token. | --- # Today > Your morning view of agent work across every repository you can see, with what needs you, what your agents are doing and what landed or failed since yesterday. Source: https://diffinery.com/docs/agents/today **Today** is the first page you see after signing in to Diffinery. It answers three questions across every repository you can see: what needs you, what your agents are doing, and what happened since yesterday. Open it in the morning to review the night's agent work, or any time you want to catch up. The same view is available to scripts and agents from the REST API and the `today` MCP tool, so your own coding agent can read your morning too. It can also come to you by email as a [morning digest](#the-morning-digest), and it fits a phone's screen. ![The Today page: counts of what needs you, agent pull requests open, merged since yesterday and failed runs, then the Needs you, Agents at work, Your agent tasks and Since yesterday sections](https://diffinery.com/docs/images/today.webp) ## What it shows At the top, four numbers link to the sections below them: how many items need you, how many agent pull requests are open, how many pull requests merged since yesterday (and how many of them were by agents), and how many workflow runs failed. Each section shows its newest 3 items, with **Show N more** for the rest and **Show fewer** to fold them back. Select a section's header to fold the whole section away; its count stays in the header. Diffinery remembers which sections you folded in this browser, and opens a folded one again when you follow one of the numbers at the top to it. A side column lists your repositories: those you or your organizations own, up to 40. If you own none yet but work in other people's repositories, it lists those instead, under **Repositories**. On a phone, Today comes first and the repository list after it. ### Needs you Pull requests waiting on you, newest activity first: | Label | When it appears | | --- | --- | | **Review requested** | Someone asked you to review a pull request that isn't a draft. It leaves the list once you submit a review. | | **Agent PR needs a person's approval** | A pull request opened by an agent, in a repository where you have write access or more, that no person has approved at its latest commit. Agent pull requests always need a human approval (see [Guardrails](https://diffinery.com/docs/agents/overview#guardrails)). It isn't listed while it is a draft, while its checks are failing, while someone has asked for changes, once auto-merge or the merge queue has it, when it has merge conflicts with its base branch (its agent has to update it first), or when its commits are already in its base branch (there is nothing left to merge). | | **Changes requested** | Your own pull request, where a reviewer's latest vote asks for changes. | | **Checks failing** | Your own pull request, where a check or commit status on its latest commit failed. | | **Merge conflicts** | Your own pull request, which conflicts with its base branch. | Diffinery knows about conflicts from the last mergeability check of the pull request's current commit and its base branch's current commit: when someone opens the pull request, when auto-merge looks at it, or when Today checks it after showing it. So right after a push to the base branch, a pull request that now conflicts can stay listed until the next time you open Today. A pull request opened by an agent you own is listed here too when it is waiting for a person: you are usually the person who approves it. ### Deployments waiting for you Workflow runs whose jobs wait for you as a required reviewer of an environment, newest first, linking to the run page where you approve or reject them. A run you started doesn't show here when the environment prevents self-review. See [Required reviewers and wait timers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers). ### Agents at work Open pull requests from agents that you own or that your organizations own, each with where it stands: | State | Meaning | | --- | --- | | **Draft** | The agent is still working on it. | | **Already in its base branch** | Its commits are already in the branch it targets, so there is nothing left to merge; close it. | | **Merge conflicts** | It conflicts with its base branch; the agent should update it. | | **Checks running** | Checks on the latest commit haven't finished. | | **Checks failing** | A check failed; the agent should push a fix. | | **Changes requested** | A reviewer asked for changes. | | **Waiting for approval** | Checks passed and it needs a person's approval. | | **Approved** | A person approved the latest commit. | | **Landing** | Auto-merge is on or it is in the merge queue. | Each row shows the repository, who opened it, when it last changed, failing check names, who asked for changes, and the pull request's intent. ### Since yesterday What happened in the last 24 hours: - **Releases** published, with their tag. - **Failed workflow runs** that you or your agents started, and failed runs on a repository's default branch (pull request runs only when they are yours or your agents'). - **Merged pull requests**, with who opened and who merged them. ### Your agent tasks [Agent tasks](https://diffinery.com/docs/agents/tasks) you started that are running or changed in the last 24 hours, with their state and pull request. The section appears only when you have some. ## The morning digest Turn on **Email me a morning digest** in **Settings → Notifications** to get Today by email once a day. Choose the hour and your time zone, and whether it also comes on Saturdays and Sundays. The digest has the same sections as the page, each with up to 8 lines and a link to the rest: - **Needs you** and **Deployments waiting for you**, as they are when it is sent. - **Agents at work**: your agents' open pull requests, without drafts. - **Your agent tasks**: running, or changed since the last digest. - **Failed runs**, **Merged** and **Released**, since the last digest. "Since the last digest" covers a weekend when weekends are off, and at most 7 days. A first digest covers the last 24 hours. - **When nothing needs you and nothing happened, no digest is sent.** The email log on the settings page lists it as skipped, with the reason. - The digest is gathered when it is sent, so it shows what is true then. - It goes out within 5 minutes of the hour. - **Turning it off:** use **Unsubscribe from emails like this** in its footer or your mail app's unsubscribe button. That turns off only the digest. - **Send me my digest now**, next to the test email button, sends it at once, even when it is off or empty. You can do that 3 times an hour. Like every email, the digest isn't sent to an address on the suppression list (see [When Diffinery stops emailing you](https://diffinery.com/docs/accounts/notifications#when-diffinery-stops-emailing-you)). Its subject leads with the counts, for example `[Diffinery] Today: 2 need you, 1 agent pull request, 3 merged`. ## Which repositories it covers Today looks at the repositories you can see that had activity in the last 30 days, such as a push, a pull request, a review, a workflow run or a release, most recent first, up to 100 of them. Archived repositories are left out. When some active repositories were left out, the page says so next to the repository count, and the API sets `incomplete_results`. For each repository it reads up to 50 open pull requests, the most recently updated first. ## Read it with the REST API ```sh curl https://diffinery.com/api/v3/diffinery/today \ -H "Authorization: Bearer dfy_..." ``` ```json { "generated_at": "2026-10-14T07:30:12.000Z", "since": "2026-10-13T07:30:12.000Z", "repositories": 12, "incomplete_results": false, "needs_you": [ { "reason": "agent_approval", "repository": "acme/website", "number": 42, "title": "Fix the Contact sales link", "html_url": "https://diffinery.com/acme/website/pull/42", "user": { "login": "review-bot", "type": "Bot" }, "head": "fix-pricing-link", "base": "main", "draft": false, "intent": "Point the Contact sales link at /contact; no other changes.", "requested_reviewers": [], "approved_by": [], "approved_head": false, "changes_requested_by": [], "checks": "success", "failing_checks": [], "auto_merge": false, "in_merge_queue": false, "created_at": "2026-10-13T22:04:51.000Z", "updated_at": "2026-10-13T22:31:09.000Z" } ], "agents_at_work": [], "landed": [], "releases": [], "tasks": [], "deployments_waiting": [], "failures": [] } ``` | Field | Meaning | | --- | --- | | `needs_you[].reason` | `review_requested`, `agent_approval`, `changes_requested`, `checks_failing` or `conflicting` | | `agents_at_work[].state` | `draft`, `in_base`, `conflicting`, `checks_running`, `checks_failing`, `changes_requested`, `waiting_for_approval`, `approved` or `landing` | | `merge_state` | What the last mergeability check of the current head and base found: `clean`, `conflicting` or `in_base` (the head is already in the base branch), or `null` when nothing checked them yet | | `checks` | The latest commit's checks and statuses together: `success`, `failure`, `pending` or `none` | | `approved_head` | Whether a person approved the latest commit (agent approvals never count) | | `landed[]` | Pull requests merged since `since`, with `merged_by` and `merged_at` | | `releases[]` | Releases published since `since` | | `failures[]` | Failed runs since `since`, with `workflow`, `ref`, `event`, `actor` and `pull_request` | | `deployments_waiting[]` | Runs waiting for you as an environment's required reviewer, with `environment`, `run_id`, `workflow`, `ref`, `actor` and `waiting_since` | | `tasks[]` | Your agent tasks that are running or changed since `since`, with `state`, `agent` and `pull_request` (an agent's token: the tasks it runs) | Any token works (a `read` token is enough). A token limited to repositories sees only those repositories. When an agent's token calls it, `agents_at_work` lists that agent's own open pull requests, and `needs_you` never contains agent approvals, because an agent's approval doesn't count. ## Read it over MCP The `today` tool takes no arguments and returns the same JSON: ```json { "name": "today", "arguments": {} } ``` It is read-only, so MCP clients can run it without asking. A good first instruction for a personal assistant agent: "Call `today` and tell me what needs me first." ## Troubleshooting | What you see | Cause and fix | | --- | --- | | A repository's pull requests don't appear | The repository had no activity in the last 30 days, it is archived, or more than 100 repositories were active and it wasn't among the most recent. Open the repository's **Pull requests** tab. | | An agent pull request isn't under **Needs you** | You need write access or more on its repository, and it must not be a draft, failing, waiting on requested changes, conflicting with its base, already in its base or already landing. It shows under **Agents at work** if the agent is yours. | | Many pull requests from one repository's agent fill **Needs you** | Each one needs a person's approval. Approve and merge them, close the ones you don't want, or fold the section away. To let that agent's work land by itself, give the repository a CI check that runs on pull requests, make it a required check, and name the agent in the branch rule's **Auto-merge pull requests from these agents** (see [Auto-merging agents' pull requests](https://diffinery.com/docs/repositories/branch-protection#auto-merging-agents-pull-requests)). | | Your agent's pull request isn't under **Agents at work** | The agent must be owned by you or by an organization you belong to. Check **Settings → Agents**. | | `401 sign in required` | Send a token, or sign in. | --- # Agent tasks > Give one of your agents a goal; it runs Claude Code, Codex or your own command in a sandbox with your own model key, then pushes a branch and opens a pull request for review. Source: https://diffinery.com/docs/agents/tasks An **agent task** is a goal you give one of your agents, such as "Fix the Contact sales link on the pricing page and add a test". Diffinery starts a sandbox as that agent with the repository checked out, runs a coding agent (Claude Code, Codex or a command of your own) with **your own model API key**, streams its transcript to the task page, then pushes the work to a branch and opens a pull request as the agent. The pull request needs a person's approval like any agent pull request. ![An agent task's page: the goal, Claude Code running as the acme-coder agent, the pull request it opened, and its transcript with file edits and test runs](https://diffinery.com/docs/images/agent-task.webp) Tasks are optional. Nothing changes for a repository until someone with a model key starts one. To use your Claude or ChatGPT subscription instead of an API key, run tasks in a [terminal](https://diffinery.com/docs/agents/terminal#give-it-a-task). The same machinery reviews pull requests: see [Agent review](https://diffinery.com/docs/agents/review). Reviews show in the repository's task list, labelled **Review**. The repository's **Project** tab shows tasks on a board, with a conversation and a memory every task reads: see [Projects](https://diffinery.com/docs/agents/projects). ## Before you start You need three things: 1. **Write access** to the repository. 2. **An agent** you manage: one you own, or one of an organization where you are an owner or admin. The agent needs write access to the repository too (agents get at most their owner's access). Create one under **Settings → Agents**; see [Agents](https://diffinery.com/docs/agents/overview). 3. **Your own model key** under **Settings → Model access**: an Anthropic API key for Claude Code, an OpenAI API key for Codex, or an OpenRouter key for either. See [Model access](#model-access). ## Start a task 1. Open the repository's **Project** tab (people with write access see it) and click **New task**. 2. Write the **goal**, as you would ask a colleague. Markdown works. 3. Pick the **runtime**: Claude Code, Codex or **Custom command** (see [Runtimes](#runtimes)). 4. Pick the **agent** and the **base branch**. 5. Tick **Plan first** to have the agent plan before it builds (see [Plan first](#plan-first)). 6. Choose **Attempts** to have up to 4 agents try the goal at once, then pick the best (see [Best of N](#best-of-n)). 7. Leave **Fix failing checks** ticked, and tick **Land when approved** if you want it merged by itself once approved (see [Verify and land](#verify-and-land)). 8. Click **Start task**. The task page shows the transcript live: what the agent says, the commands it runs and their output. ## What happens in a run | Step | What Diffinery does | | --- | --- | | Sandbox | Re-checks that you may run the task, then starts a fresh sandbox **as the agent**, on behalf of you, with the base branch (or the task's branch, for later runs) checked out. Its git credential is the agent's, at most write, and stops working when the run ends. | | Install | Installs the runtime from its vendor's official npm package, at a version Diffinery pins (see [Runtimes](#runtimes)). No model credential exists in the sandbox yet. | | Agent | Runs the runtime headless with the goal, plus the project's instructions and memory when it has them (see [Projects](https://diffinery.com/docs/agents/projects)), in a jail with a clone of its own (see [What a run can reach](#what-a-run-can-reach)). Its model requests go through Diffinery's model proxy with a token for this run, which stays outside the jail (see [How your key is protected](#how-your-key-is-protected)). | | Push | Commits what the agent left uncommitted, as the agent, in a jail with no network, and takes the new commits out as a git bundle. The sandbox's checkout then pushes them to `agent/<number>-<slug>`, with no hooks. Branch protection, push protection and the agent's guardrails apply as to any push. | | Pull request | Opens a pull request from that branch as the agent, with the goal as its `intent`, or updates the one the task already has. | | Close | Closes the sandbox. The run's git credential and model token stop working. | A run stops after 6 hours. Tasks show on [Today](https://diffinery.com/docs/agents/today) under **Your agent tasks** while they run and for a day after they change. ### Task states | State | Meaning | | --- | --- | | **Queued** | Waiting for a running task in the repository to finish, or, for a task a [coordinator](https://diffinery.com/docs/agents/projects#coordinator) started, for the tasks it waits on: a repository runs at most 3 at once unless its admins change it (see [How many at once](https://diffinery.com/docs/agents/projects#how-many-at-once)). | | **Running** | A run is in progress. | | **Ready** | The last run finished. Its work is on the task's pull request, if it changed anything. | | **Failed** | The last run failed: the runtime exited with an error, the push was refused, or you lost access. The transcript says why. | | **Stopped** | Someone stopped it. | A [plan-first](#plan-first) task also has a plan state: `planning` while its plan runs, `waiting` when the plan waits for you (the board shows it under **Waiting on you**), then `approved` or `split`. A task with attempts has a pick state: `running`, `waiting` for your pick (also under **Waiting on you**), then `picked`, with `picked_attempt`. ### Plan first A plan-first task's first run writes a plan instead of code. The agent gets a **read-only** sandbox, as an [agent review](https://diffinery.com/docs/agents/review) does: it reads the code and runs read-only commands, its git credential can't push, and nothing it changes is kept. The plan is its final message (for a custom command, everything it printed), up to 20,000 characters, and shows on the task page. Then you choose: - **Approve plan**: a build run starts with the goal and the plan. It, and every later run of the task, gets the plan in its prompt as the one you approved. - **Send a follow-up** to ask for changes: the agent plans again, with its previous plan and your message. - **Split into tasks**: your [coordinator](https://diffinery.com/docs/agents/projects#coordinator) gets the goal and the plan as a post of yours and starts tasks for its parts. Each says it was split from this task, which is then done with. You need a coordinator in the project for this. Repository admins can make plans the default, or require them for every task, including ones coordinators start (see [Plan first](https://diffinery.com/docs/agents/projects#plan-first)). ### Best of N With **Attempts** set to 2, 3 or 4, the task's first build runs as that many attempts at the same time. Each one gets its own sandbox and pushes its own branch: the first pushes to the task's branch and the others to `-a2`, `-a3` and `-a4` beside it. Together they count as that many toward [How many at once](https://diffinery.com/docs/agents/projects#how-many-at-once), and each one spends your model key as its own run. With **Plan first**, the attempts build the approved plan. When the last attempt ends, the task waits on you with a comparison. For each attempt it shows the files and lines it changed (with a link to its diff), the checks on its branch, the tokens it used and its last message. **Pick** one, and its branch becomes the task's branch, with a pull request from it. The other attempts' branches are deleted, and every later run builds on the attempt you picked. Follow-ups wait for the pick. **Retry** before a pick runs all the attempts again, and **Stop** stops all of them. A task on a pull request's branch (**Fix it**) runs one attempt. ### Verify and land Two options on each task, set on the new-task form and changed later under **Verify and land** on the task page: - **Fix failing checks** (on by default, except on someone else's pull request with **Fix it**). When every check on the task's pull request has finished on its latest commit and one of them failed, a fix run starts. It gets the failing checks' names and the end of their logs (the last 80 lines, or a commit status's description), labelled as output from the checks rather than instructions. One fix run starts per commit, and at most 3 per task. After that, the conversation says the checks still fail. Agent review findings aren't counted here: [autofix](https://diffinery.com/docs/agents/review) handles those. - **Land when approved** (off by default). When the task opens its pull request, or when you turn this on later, it turns on [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge) as the task's agent. The pull request then merges by itself once the base branch's rules are met: a person approves its latest commit (an agent's approval never counts) and the required checks pass. The usual rules for agents apply (see [Agents landing changes](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes)). Into the default branch or a protected branch, auto-merge needs `allowAgentAutoMerge` in the branch's protection and at least one required check, and only an admin's `agentAutoMergeWithoutApproval` waives the approval. When auto-merge can't turn on, the task page and the conversation say why, and a person merges by hand. The board shows a task as **Ready for review** only when its pull request's checks pass on its latest commit. ### Steer, retry and stop - **Send a follow-up** from the task page to continue: a new run starts on the task's branch with the goal, how the previous run ended and your message, and its work goes to the same pull request. Sent while a run is active, the message waits and starts when that run ends. - **Retry** runs the last run again when the task isn't running. - **Stop** stops the agent and closes its sandbox. Work already pushed stays. Only the person who started a task can send it messages or retry it, because each run spends their model key. That person, the task's agent and repository admins can stop it. ## Runtimes | Runtime | Needs | Runs | | --- | --- | --- | | **Claude Code** | Your Anthropic API key | `claude -p "$DIFFINERY_TASK_PROMPT" --output-format stream-json --verbose --permission-mode bypassPermissions` | | **Codex** | Your OpenAI API key | `codex exec --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$DIFFINERY_TASK_PROMPT"` | | **Claude Code via OpenRouter** | Your OpenRouter key | The same, with `ANTHROPIC_BASE_URL` on OpenRouter's Anthropic-compatible endpoint (through the proxy), the token as `ANTHROPIC_AUTH_TOKEN`, and the model you choose for every model role (`ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `_SONNET_`, `_HAIKU_`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`) | | **Codex via OpenRouter** | Your OpenRouter key | The same, with an `openrouter` model provider on OpenRouter's Responses API (`-c model_provider=openrouter -c model_providers.openrouter...`) and `-m "$DIFFINERY_MODEL"` | | **Custom command** | Nothing required | Your command, with the goal in `$DIFFINERY_TASK_PROMPT`, and `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY`, `OPENAI_BASE_URL` / `OPENAI_API_KEY` and `OPENROUTER_BASE_URL` / `OPENROUTER_API_KEY` set for each provider you have a key for. The base URLs point at the jail's model proxy and the keys are a stand-in (see [How your key is protected](#how-your-key-is-protected)) | ### OpenRouter With an OpenRouter key, Claude Code and Codex can run on a model you choose there. Pick **Claude Code via OpenRouter** or **Codex via OpenRouter** as the runtime, and name the model (an id from [openrouter.ai/models](https://openrouter.ai/models)). Left empty, Claude Code uses `~anthropic/claude-sonnet-latest` and Codex `~openai/gpt-sol-latest`. Over the API, send `"provider": "openrouter"` and `"model"`. OpenRouter supports Claude Code fully only with Anthropic models served by Anthropic, and Codex works best with OpenAI models. Other models may not handle the runtime's tools well. Usage, including OpenRouter's fees, is billed to your OpenRouter account. A run through OpenRouter is **pinned to the model you chose**. The model proxy refuses a request for any other model, and requests that set OpenRouter's routing or paid extras (`models`, `route`, `provider`, `plugins`, `web_search_options`, `speed`, `best_of`). So code in the repository can't switch the run to a pricier model. A custom command that uses your OpenRouter key isn't pinned, since it picks its own model. Diffinery names itself to OpenRouter as the app (`HTTP-Referer` and `X-Title`). The sandbox's own attribution and `X-OpenRouter-*` headers are dropped. The runtimes run unmodified, from the vendors' own packages, at pinned versions: Claude Code 2.1.296 (`@anthropic-ai/claude-code`) and Codex 0.162.1 (`@openai/codex`). Diffinery moves to newer versions deliberately rather than installing whatever was published last. The sandbox is its own container, so they run without permission prompts. The goal reaches the runtime in an environment variable, never pasted into a command line. The prompt tells the agent which task it is working on, that Diffinery pushes and opens the pull request, and not to push or change git credentials itself. ## Model access **Settings → Model access** holds your own API keys, one per provider: - **Anthropic**, for Claude Code. Create a key at console.anthropic.com. - **OpenAI**, for Codex. Create a key at platform.openai.com. - **OpenRouter**, for Claude Code or Codex on a model you choose there (see [OpenRouter](#openrouter)). Create a key at openrouter.ai/settings/keys. Saving a key asks you to confirm that you use that provider through Diffinery **under your own account and the provider's terms**, with links to them (Anthropic's [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) and [Usage Policy](https://www.anthropic.com/legal/aup); OpenAI's [Terms and Policies](https://openai.com/policies/); OpenRouter's [Terms of Service](https://openrouter.ai/terms) and [Privacy Policy](https://openrouter.ai/privacy)). The provider bills your account. Diffinery only provides the ability; whether and how you use it is your choice and your agreement with the provider. Your confirmation is stored with its date. - Keys are encrypted at rest and **write-only**: no page, API call or MCP tool returns one. You see the last four characters. - Only you, signed in in a browser, can save, replace or delete your keys. No token, agent, job or sandbox can. - Saving a key sends you a security email ("Your Anthropic API key was added to Diffinery"), which you can't turn off. - Keys belong to people. Agents never have model keys of their own. An organization's owners can give it keys of its own, and decide whose key pays in its repositories: see [Agents in organizations](https://diffinery.com/docs/agents/organizations). - Subscriptions (Claude Pro or Max, ChatGPT plans) can't be used for headless tasks: providers allow automated use with API keys. You can hand a task to the CLI signed in to [your own web terminal](https://diffinery.com/docs/agents/terminal#give-it-a-task) instead. ## Usage and cost Each task's page has a **Usage** box: per run, the model requests, input and output tokens, cache reads and writes, and the cost, with the task's total. The same numbers are in the REST API (`usage` on the task, and on each of its `runs`) and in MCP's `agent_task`. - **Through your key**, Diffinery's model proxy counts the tokens from each answer the provider sends (`"source": "proxy"`). These are the numbers budgets and limits use. - **In your terminal**, on a subscription, no request passes Diffinery, so the run shows what the CLI reported: Claude Code at the end of its run, Codex after each turn (`"source": "runtime"`). - **Cost** is only ever Claude Code's own figure (`total_cost_usd`), at API prices. Diffinery keeps no price list. Codex and custom commands show tokens without a cost, and a subscription covers a terminal run's cost. Your provider's console shows what you were actually billed. **Settings → Model access** shows **Usage in the last 30 days** for every run you pay for: your tasks and your coordinator's, and [agent reviews](https://diffinery.com/docs/agents/review) in repositories where you turned them on. Runs an organization's key paid for are on its Settings tab instead, for its owners (see [Agents in organizations](https://diffinery.com/docs/agents/organizations#usage)). It is broken down by repository and kind, with each repository named as it was when its runs ended. A repository renamed since shows a line for each name. `GET /api/v3/diffinery/model-usage?days=30` returns the same (1 to 90 days); a token limited to some repositories sees only those. Model requests are limited to **600 a minute per paying person**, across all their runs, on top of each run's own limits. Past that, the proxy answers `429` with `retry-after: 60`, and Claude Code and Codex wait and retry. This caps how fast a runaway task, or a leaked run token, can spend your key. Like the other [rate limits](https://diffinery.com/docs/reference/rest-api#rate-limits), it is counted in each Cloudflare location, so runs in several locations can go past 600 a minute in total; each run's own limits and budgets still apply. ## How your key is protected Your key never enters a sandbox: 1. When a run's agent starts, Diffinery creates a **model token for that run** (`dmt_...`). The token stays outside the agent's jail, with a small proxy there that forwards the agent's model requests, and only those (`/-/model/...`), to Diffinery's model proxy with the token added. Inside, the runtime's API key is a stand-in (`diffinery-jail`), and the provider's base URL points at that proxy (`http://127.0.0.1:3129/-/model/<provider>`). Whatever credential the agent sends is dropped. 2. The proxy accepts the token only while that run's agent is running. On every request it re-checks that you can still run the task, and that whoever pays still may (see [Whose key pays](https://diffinery.com/docs/agents/organizations#whose-key-pays)). Then it forwards the request to the provider with the key added, decrypted for that request only. 3. The proxy forwards only the model endpoints the runtimes use, and refuses everything else with `403`, so an agent can't reach the rest of your provider account (stored files, batches, assistants): | Provider | Endpoints | | --- | --- | | Anthropic | `POST /v1/messages`, `POST /v1/messages/count_tokens`, `GET /v1/models` and `GET /v1/models/{id}` | | OpenAI | `POST /v1/responses`, `POST /v1/responses/compact`, `GET /v1/responses/{id}`, `POST /v1/chat/completions`, `GET /v1/models` and `GET /v1/models/{id}` | | OpenRouter | `POST /v1/messages`, `POST /v1/messages/count_tokens`, `POST /v1/responses`, `POST /v1/chat/completions`, `POST /v1/completions` and `GET /v1/models`. Never keys, credits, generation stats or anything else on your OpenRouter account. | 4. When the agent exits, is stopped, or the run ends any other way, the token stops working. If you lose access to the repository or the agent, the task stops. Everything the sandbox returns, including the transcript, masks the model token and the git credential. **What this doesn't protect against.** Anything the agent runs in its jail (build scripts, tests, dependencies, code from the repository) can send model requests through the jail's proxy while the run lasts, on your key, and repository content can try to trick the agent into misusing that ("prompt injection"). It can't read the model token or the git credential, and it reaches only the package registries below. So: - Use a **dedicated API key with a spending limit** for Diffinery, and revoke it with the provider if you suspect misuse. - Run tasks on repositories whose content you trust. A steered agent can still publish what it read to npm or RubyGems with a token planted in the repository, and anything it read can end up in its branch and pull request. - A transcript can show a credential in another form (for example encoded) that masking can't recognise. Transcripts are visible only to people with write access. ### What a run can reach Every headless run (a task's, a plan's, a review's and a coordinator's) runs its agent under [bubblewrap](https://github.com/containers/bubblewrap), inside its sandbox, as a [terminal task](https://diffinery.com/docs/agents/terminal#what-a-task-can-reach) does: | In the jail | | | --- | --- | | Files | The run's own clone of the repository at its start commit (read and write), and the sandbox checkout's git objects (read only). Not the checkout or its git config (which holds the sandbox's git credential), the home directory, or `/tmp`. The rest of the system is read only. | | Processes | Its own: it can't see or signal anything else in the sandbox. No capabilities. | | Network | None of its own. Model requests go to the model proxy outside the jail, on `127.0.0.1:3129`, which forwards only `/-/model/...` to Diffinery. Everything else goes through a proxy (set as `HTTPS_PROXY`) that connects only to the package registries below, by name, on port 443, and only when the TLS handshake asks for that same host. Diffinery itself, the providers' APIs and any other host are refused. | | Environment | Only what the runtime needs: the model proxy's base URLs, the stand-in API keys and the model a route pins. No sandbox tokens. | The registries: npm and Yarn (`registry.npmjs.org`, `registry.yarnpkg.com`), PyPI (`pypi.org`, `files.pythonhosted.org`), Cargo (`index.crates.io`, `static.crates.io`), RubyGems (`rubygems.org`, `index.rubygems.org`), Maven Central (`repo1.maven.org`, `repo.maven.apache.org`) and apt (`deb.debian.org`, `security.debian.org`, `archive.ubuntu.com`, `security.ubuntu.com`, also over plain HTTP for downloads). Go modules aren't reachable, because the Go module proxy fetches any address it's given: vendor them (`go mod vendor`). A refused connection shows in the run's transcript. If the sandbox can't start the jail, the run fails with "The run's jail couldn't start in its sandbox, so nothing ran", and nothing runs: it never falls back to running without one. Task sandboxes are closed to the [sandbox API](https://diffinery.com/docs/agents/sandboxes): no call reaches them, for anyone, including the agent's own tokens and repository admins. ## Who sees what - Tasks and transcripts: people with **write** access to the repository (stricter than Actions logs, because transcripts show an agent's reasoning and command output). - The task's pull request: everyone who can read the repository, like any pull request. ## REST API | Endpoint | Does | Who | | --- | --- | --- | | `GET /repos/{owner}/{repo}/diffinery/tasks` | List tasks | Write access; any token | | `GET /repos/{owner}/{repo}/diffinery/tasks/{number}` | A task with its runs | Write access; any token | | `GET /repos/{owner}/{repo}/diffinery/tasks/{number}/transcript?after=0` | Transcript lines | Write access; any token | | `POST /repos/{owner}/{repo}/diffinery/tasks` | Start a task; `"plan_first": true` or `false` overrides the project's default, unless it requires plans; `"attempts": 1` to `4`; `"fix_checks"` and `"land"`, true or false | Browser session only | | `POST .../tasks/{number}/messages` | Send a follow-up | Browser session only, the person who started it | | `POST .../tasks/{number}/retry` | Retry | Browser session only, the person who started it | | `PATCH .../tasks/{number}` | Change options: `{ "fix_checks": true, "land": false }` | Browser session only, the person who started it | | `GET .../tasks/{number}/attempts` | The attempts compared: state, branch, pushed head, changes, checks, tokens, last message | Write access; any token | | `POST .../tasks/{number}/attempts/{attempt}/pick` | Pick an attempt: its branch becomes the task's, with a pull request | Browser session only, the person who started it | | `POST .../tasks/{number}/plan/approve` | Approve a waiting plan: the build run starts | Browser session only, the person who started it | | `POST .../tasks/{number}/plan/split` | Hand a waiting plan to your coordinator to split into tasks | Browser session only, the person who started it | | `POST .../tasks/{number}/stop` | Stop | The person who started it, its agent or a repository admin | | `GET /diffinery/tasks` | Tasks you started, across repositories | Any token | | `GET /diffinery/model-keys` | Your saved keys (provider, last four characters, dates) | Any token | | `GET /diffinery/model-usage?days=30` | The model usage you paid for, per repository and kind (`task`, `review`, `coordinator`), over 1 to 90 days. See [Usage and cost](#usage-and-cost). | Any token; a token limited to some repositories sees only theirs | | `PUT` / `DELETE /diffinery/model-keys/{provider}` | Save or delete a key | Browser session only | ```sh curl https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/7 \ -H "Authorization: Bearer dfy_..." ``` ```json { "number": 7, "title": "Fix the Contact sales link on the pricing page", "goal": "Fix the Contact sales link on the pricing page so it goes to /contact, and add a test.", "state": "ready", "runtime": "claude-code", "agent": { "login": "ayse-bot", "type": "Bot" }, "started_by": { "login": "ayse", "type": "User" }, "paid_by": "person", "plan_first": false, "attempts": 1, "fix_checks": true, "check_fixes": 0, "land": false, "base": "main", "branch": "agent/7-fix-the-contact-sales-link-on-the", "pull_request": { "number": 42, "html_url": "https://diffinery.com/acme/website/pull/42" }, "queued_message": null, "error": null, "html_url": "https://diffinery.com/acme/website/tasks/7", "usage": { "requests": 42, "input_tokens": 9120, "output_tokens": 14406, "cache_read_tokens": 2853120, "cache_write_tokens": 9366, "cost_usd": 1.4123 }, "runs": [{ "number": 1, "kind": "start", "message": null, "state": "done", "exit_code": 0, "error": null, "model_requests": 42, "tokens": 318204, "usage": { "source": "proxy", "requests": 42, "input_tokens": 9120, "output_tokens": 14406, "cache_read_tokens": 2853120, "cache_write_tokens": 9366, "cost_usd": 1.4123 }, "started_at": "2026-10-14T07:02:11.000Z", "ended_at": "2026-10-14T07:09:40.000Z" }] } ``` A run's `model_requests` and `tokens` are what its agent used through the model proxy, counted from the usage the provider reports (cache reads count as a tenth). An answer that reports no usage counts an estimate: a token per 3 bytes of request, plus the output it allows. `usage` breaks a run's tokens down, and says who counted them (`source`): `proxy`, or `runtime` for a run in your terminal. It is `null` for a run that used none, and the task's `usage` adds up its runs. See [Usage and cost](#usage-and-cost). `paid_by` is `person` or `organization`: whose key the task's model requests use, fixed when it started (see [Agents in organizations](https://diffinery.com/docs/agents/organizations#whose-key-pays)). Transcript lines have `seq`, `run`, `at`, `stream` (`out` and `err` for the runtime's output, `setup` for installing and pushing, `note` for Diffinery's own lines) and `text`. Claude Code writes `stream-json` lines and Codex writes `--json` lines to `out`. ## MCP Two read-only tools, so your own agent can follow tasks: | Tool | What it does | | --- | --- | | `agent_tasks` | With `owner` and `repo`: that repository's tasks. Without: the tasks you started. | | `agent_task` | One task with its runs and its transcript after `after`. | Starting, steering and retrying aren't available over MCP, because they spend a person's model key. ## Troubleshooting | What you see | Cause and fix | | --- | --- | | `Claude Code needs your Anthropic API key` | Save a key under **Settings → Model access**. | | `you can't run tasks as <agent>` | You must own the agent or be an owner or admin of the organization that owns it. | | `<agent> needs write access to this repository` | Give the agent write access, or a role through its organization. Agents get at most their owner's access. | | `starting or steering an agent task needs a browser session` | Use the web page; tokens can't start tasks. | | The run failed at **Installing** | The npm registry couldn't be reached or the package failed to install. Retry. | | `Pushing the work ... failed` | The push was refused, usually by branch protection, push protection (a secret in the changes) or the agent's guardrails. The transcript shows git's message. | | The task stopped with "can no longer run it" | You lost write access to the repository, or can no longer manage the agent. | | Model calls fail with `401 ... its task run has ended` | The run is over; that token no longer works. Start a new run. | | `diffinery: refused a connection to <host>:443 from the task` in the transcript | The agent or a tool it ran tried a host other than the [package registries](#what-a-run-can-reach). Commit what the work needs (vendored dependencies, for example), or do it outside a task. | | `diffinery: refused a connection to <host>:443 from the task: its TLS ClientHello asks for <other host>` (or `names no single server, or hides it (Encrypted Client Hello)`, or `no TLS ClientHello`) | The tunnel opens only when the TLS handshake names the registry the tool asked the proxy for. A tool that sends a different server name, none, or Encrypted Client Hello, or that doesn't start TLS within 10 seconds, is refused. Point the tool at the registry by its own name. | | `diffinery: refused ... (only Diffinery's model endpoints, /-/model/...)` | Something in the run called Diffinery outside the model endpoints through the jail's model proxy. Runs can't reach the rest of Diffinery. | | `The run's jail couldn't start in its sandbox, so nothing ran` | The sandbox couldn't create the jail's namespaces. The transcript says why. Retry; if it keeps happening, tell your instance's administrator. | | A custom command gets `403 ... isn't available to agent tasks` | The model proxy forwards only the model endpoints listed under [How your key is protected](#how-your-key-is-protected). | --- # Projects > A repository as a project for agent work, with a task board, a conversation, shared memory, project instructions and a limit on how many tasks run at once. Source: https://diffinery.com/docs/agents/projects Every repository is also a **project** for agent work. Its **Project** tab shows the agent tasks on a board by where they stand, a conversation for the people working on it, and a memory that every task reads. Each person can turn on a **coordinator**: an agent that reads their posts and splits the work into tasks. Admins set instructions every task follows and how many tasks run at once. Nothing here changes Git, pull requests or Actions. A repository that runs no [agent tasks](https://diffinery.com/docs/agents/tasks) just has an empty board. The Project tab is for people with **write** access, like tasks. ## The board Each task sits in one column, by its own state first and then by its pull request's: | Column | Tasks in it | | --- | --- | | **Waiting on you** | The task failed, or its pull request has failing checks. | | **Working** | The agent is running, or the pull request's checks are. | | **Queued** | Waiting for a running task to finish (see [How many at once](#how-many-at-once)). | | **Ready for review** | The pull request is open and its checks passed, or it has none. | | **Landing** | The pull request is approved on its latest commit, set to auto-merge, or in the merge queue. | | **Done** | The pull request was merged. | | **Idle** | Finished with nothing to review, stopped, or its pull request was closed. | Done and Idle show their latest tasks; **All tasks** lists every one. [Agent reviews](https://diffinery.com/docs/agents/review) don't show on the board: they belong to their pull requests. ## Conversation The conversation is a shared timeline for the project. People with write access post ideas, plans and notes, in Markdown. Tasks add a line when they start, open a pull request, finish with no changes, fail or stop. **Start a task** on a post opens a new task with the post as its goal, so an idea can become work in one step. Posting needs a browser session. Tokens can read the conversation over the [REST API](#rest-api) and [MCP](#mcp), but not post to it. ## Coordinator A coordinator is one of your agents working for you in the project conversation, on your own model key. When you post, it reads your post, the board, the memory and the recent conversation in a read-only sandbox of the default branch, plans, and then acts: - **Reply** in the conversation. - **Start tasks**, each with a goal written as a complete brief for another agent. A task can wait for earlier tasks in the same plan to be ready before it starts. - **Send a follow-up** to a task it started, or **stop** one of your own tasks. - **Rewrite the memory**. The coordinator holds no credential it could act with while it runs. It ends its final message with one block listing its actions, and Diffinery checks every action before applying any of them, as you: if any action is invalid, nothing is applied and the conversation says why. ### Turn it on Open **Your coordinator** from the side of the Project tab. Pick the agent, the runtime (Claude Code, Codex, either via OpenRouter, or a custom command) and the token budgets, and confirm that it uses your model key. You need write access, an agent you manage with write access to the repository, and a model key under **Settings → Model access**. Each person sets up their own coordinator, and it handles only their posts. Someone else's coordinator doesn't handle yours, even if you own the repository: to have your posts planned, turn on your own. Everyone with write access sees whose coordinators are on under **Other coordinators here** on the side of the Project tab, with each one's agent and model, but never their budgets, command or key. Untick **Hand this to my coordinator** to post without it. A post can also be handed over later: **Handle this** on someone's post gives it to your coordinator, on your key. Each post is handled once. ### Limits | Limit | Value | | --- | --- | | Tasks a coordinator starts per post | 5 | | Tasks your coordinator starts in a project per 24 hours | 20 | | A coordinator run | 30 minutes, 200 model requests, and its token budget (1M by default) | | Each run of a task it starts | Its token budget (2M by default), checked at the model proxy before each request | | Running at once | Tasks it starts count toward [How many at once](#how-many-at-once) like any other | A coordinator may only stop your own tasks, and only send follow-ups to tasks it started, whose runs all have a token budget; a follow-up says it came from the coordinator. Agents can't post in the conversation, so no task can start a coordinator run. Tasks a coordinator starts show **From a coordinator** in the task list, and their page names the post they came from. Every task it starts, follow-up it sends and task it stops is in the audit log under your name, marked as the coordinator's. ### Memory changes When a coordinator changes the memory, the conversation gets a line saying so, with the change as a diff and an **Undo** link that opens the version before it for restoring. Memory a coordinator wrote reaches every later task's prompt as notes, never as instructions, and never reaches agent reviews. Anything a coordinator reads, including the repository's files and the memory, can try to trick it. What it can do is bounded by the list above, and every memory change can be undone. Use a dedicated model key with a spending limit. ## Memory Memory is a Markdown document of notes that every agent task in the project reads: conventions, decisions, where things are. Open it from the side of the Project tab. - A task gets the memory in its prompt with its goal, marked as background notes, not as instructions. - Agent reviews don't read it. - Each save keeps a version. The last 50 are kept, and opening one shows how restoring it would change the current memory. **Restore this version** saves it as a new version, so a restore can be undone too. - Memory is up to 40,000 characters. Don't put secrets in it: every task's agent and its sandbox see it. ## Settings Repository admins set these under **Settings → Project**. ### Project instructions Text every agent task and [agent review](https://diffinery.com/docs/agents/review) in the repository gets in its prompt: for example, which tests to run before opening a pull request. Up to 10,000 characters. Reviews also follow `REVIEW.md` files. ### Plan first Whether new tasks [plan before they build](https://diffinery.com/docs/agents/tasks#plan-first): - **Off** (the default): tasks build at once, unless someone ticks **Plan first**. - **On by default**: **Plan first** is ticked on new tasks, and people can untick it. - **Required**: every task plans first, ticked or not, including the tasks coordinators start. ### How many at once How many tasks run at the same time in the repository, from 1 to 10. The default is 3. A task started beyond it is **Queued**: its page says so, and it starts, oldest first, when a running task finishes or stops, or when an admin raises the number. Follow-ups and retries queue the same way. Agent reviews don't count toward the number and never wait. In an organization's repository, the organization's [agent policy](https://diffinery.com/docs/agents/organizations#other-policies) can set a lower limit for all its repositories. The lower of the two applies, and this page says so. ## REST API | Endpoint | What it does | Who | | --- | --- | --- | | `GET /repos/{owner}/{repo}/diffinery/project` | The board, memory and settings | Write access; any token | | `GET .../diffinery/project/posts?after=0` | The conversation, oldest first: posts after `after`, or the latest 100 | Write access; any token | | `POST .../diffinery/project/posts` | Post `{ "body": "…" }`; add `"coordinator": false` to skip your coordinator | Write access; browser session only | | `GET .../diffinery/project/memory` | The memory with its versions | Write access; any token | | `PUT .../diffinery/project/memory` | Save `{ "body": "…" }`, or restore `{ "restore": 4 }` | Write access; browser session only | | `PUT .../diffinery/project/settings` | Set `{ "max_running": 2, "instructions": "…", "plan_first": "default" }` (`plan_first` is `off`, `default` or `required`) | Admin; browser session only | | `GET .../diffinery/project/coordinator` | Your coordinator's settings | Write access; any token | | `GET .../diffinery/project/coordinators` | Everyone's coordinator here: `person`, `agent`, `runtime`, `provider`, `model`, `opted_in_at` | Write access; any token | | `PUT .../diffinery/project/coordinator` | Turn yours on or change it: `{ "agent": "my-bot", "runtime": "claude-code", "tokens_per_run": 1000000, "tokens_per_task": 2000000, "accept": true }` | Write access; browser session only | | `DELETE .../diffinery/project/coordinator` | Turn yours off | Write access; browser session only | | `POST .../diffinery/project/posts/{id}/handle` | Hand a post to your coordinator | Write access; browser session only | ```bash curl https://diffinery.com/api/v3/repos/acme/website/diffinery/project \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ```json { "settings": { "max_running": 3, "instructions": "Run pnpm test before opening a pull request.", "plan_first": "off" }, "memory": { "body": "Docs live in docs/.", "version": 4, "updated_by": { "login": "ada", "type": "User" }, "updated_at": "2026-10-13T09:12:00Z" }, "board": { "waiting": [], "working": [{ "number": 7, "state": "running", "…": "…" }], "queued": [], "review": [], "landing": [], "done": [], "idle": [] } } ``` Saving memory and settings is recorded in the audit log as `project.memory` and `project.settings`, and coordinator changes as `project.coordinator`. A task a coordinator started has `started_via` (with the post), `token_budget` and `waits_for` in its JSON; a task split from a plan has `parent_task`. ## MCP Two read-only tools let your own agent follow a project: | Tool | What it does | | --- | --- | | `project` | The board, memory and settings of `owner`/`repo`. | | `project_posts` | The conversation after `after`, or the latest 100 lines. | --- # Agent review > One of your agents reviews pull requests for bugs, security issues and logic errors, like Cursor's Bugbot, with inline findings, an Agent review check and one-click fixes. Source: https://diffinery.com/docs/agents/review **Agent review** has one of your agents review pull requests, the way Cursor's Bugbot does. The agent runs Claude Code, Codex or a command of your own in a read-only sandbox, using a model key you bring. It reads the pull request's changes and looks for bugs, security issues and logic errors, not style. Diffinery then posts what it found as a review with inline comments, and reports an **Agent review** check that fails when a finding is blocking. ![A pull request an agent task opened, with an agent review: its summary and an inline finding with a suggested fix and a Fix it button](https://diffinery.com/docs/images/agent-pull-request.webp) Agent review is off until a repository admin turns it on. Nothing changes for a repository that doesn't use it. ## Turn it on You need to be a **repository admin**, with an agent you manage that has write access (see [Agent tasks](https://diffinery.com/docs/agents/tasks#before-you-start)) and your own model key under **Settings → Model access**. 1. Open the repository's **Settings → Agent review**. 2. Pick the **agent** and the **runtime**: Claude Code (your Anthropic key), Codex (your OpenAI key), either of them via OpenRouter on a model you choose (your OpenRouter key; see [OpenRouter](https://diffinery.com/docs/agents/tasks#openrouter)), or a custom command. 3. Choose **when to review**: - **Every pull request, when it opens and on every push** (the default). - **Once per pull request**, when it opens or is marked ready for review. - **Only when someone asks.** 4. Choose **which pull requests** to review: all of them, only ones from people, or only ones from agents. Drafts are skipped unless you tick **Review draft pull requests too**. 5. Optionally turn on **Autofix** (see [Fix it and autofix](#fix-it-and-autofix)). 6. Set the **daily limit**: the most reviews the repository runs in 24 hours (50 unless you change it; see [Limits](#limits)). 7. Tick the confirmation and click **Turn on agent review**. **Every review on the repository uses your model key**, automatic reviews and ones other people ask for alike. The settings page and every review's footer name whose key it is. Another admin who saves the settings becomes the one whose key is used. **Turn off** stops new reviews. ## What a review does 1. Diffinery re-checks that you can still run the agent and that your key is still saved. Otherwise the check reports why, and nothing runs. 2. It starts a **read-only sandbox** as the agent, with the pull request's head checked out. The sandbox can clone and read, but its git credential can't push and can't write through the API. 3. The runtime gets a prompt containing: - the pull request's title, description and changed files; - the base and head commits; - your project's review rules (below); - its own earlier findings on this pull request. 4. It reports findings as a JSON block at the end of its final message. A block anywhere else, such as in a file the agent read, doesn't count. (A custom command's whole output is read.) 5. Diffinery posts the findings as a **Comment** review by the agent: - Each finding becomes an inline comment marked **🔴 Blocking** or **🟡 Not blocking**, with the reasoning and, for a one-line fix, a `suggestion` block. - Findings outside the changed lines go in the review's summary. - At most 25 inline findings are posted per review. 6. It completes the **Agent review** check on that commit: | Result | Check | | --- | --- | | No blocking findings | success | | One or more blocking findings | failure | | The agent produced no findings block | failure: "The review didn't produce findings" | | The review was stopped, or a newer push superseded it | cancelled | | The review ran past its time or model-request limit | failure: "The review didn't finish" | | Your key or your access is gone, the author can't be reviewed automatically, or a daily limit was reached | action required | The review and its findings show on the pull request like any review. The run, with its full transcript, is in the repository's task list (**Project → All tasks**), labelled **Review**. Reviews don't count toward required approvals, and an agent review never requests changes. Whether findings block a merge is up to the check. ### Re-reviews On the next push, the review covers what changed since its last review and checks its earlier findings: - When the code now fixes a finding, the agent replies **Fixed in `abc1234`.** and resolves its own thread. - It doesn't repeat a finding a person dismissed by resolving its thread. - It never resolves a person's thread. Only one review of a pull request runs at a time. Pushes during a review queue one more review of the newest head. If the pull request changes while a review runs, that review's findings aren't posted, because their lines may be wrong, and the queued review covers the new head. ## Ask for a review People with **write access** can ask for a review at any time, whatever the trigger setting: - **Request agent review** in the pull request's Reviewers sidebar. - A comment starting with `@<agent> review`, for example `@review-bot review`. - Requesting the agent as a reviewer. - `POST /repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review`, with any of your tokens. Agents can't ask for reviews, so agents can't set off review loops. **Pull requests from people without write access aren't reviewed automatically.** Their check says it is waiting for a request. Someone with write access asks for the review, much like approving a workflow run for an outside contributor. ## Project rules: REVIEW.md Write review rules in `REVIEW.md` files: what matters in this codebase, what to ignore, and conventions to enforce. - Diffinery reads `REVIEW.md` at the repository root and in every directory above a changed file. A file nearer a changed file wins for it. - It also reads `.cursor/BUGBOT.md` files, so rules written for Bugbot carry over. Rules are read from the **base branch**, never from the pull request. A pull request can't rewrite the rules it is reviewed against. A change to `REVIEW.md` takes effect once it is merged. A review also gets the repository's [project instructions](https://diffinery.com/docs/agents/projects#project-instructions), which only admins set. It never gets the project's memory, which an agent may have written. ## Fix it and autofix - **Fix it** appears under every finding for people with write access. It opens a new agent task, prefilled with the finding, that works **on the pull request's own branch**. It runs as your agent with your key, and it pushes its fix to that branch under the branch's protection rules. The next review then checks the fix. - **Autofix** does this by itself when a review finds blocking problems: one task for all of them, as the agent and with the key of the admin who turned reviews on. - It runs at most 3 times per pull request. - It runs only for pull requests whose author has write access. ## Require it before merging Add **Agent review** to a branch's required checks under **Settings → Branch protection**. Diffinery binds a required "Agent review" to its own reviewer (`diffinery-agent-review`), so nobody else can post a passing check with that name, and a check an agent posts itself never counts. A passing Agent review means **the agent found nothing**, not that the change is safe. Pull request content can try to trick the agent ("prompt injection") into reporting nothing. Keep people's approvals required as well. ## Limits Diffinery enforces these itself, so an agent misled by a pull request can't run past them: | Limit | What happens | | --- | --- | | **30 minutes** per review | The review stops, and its check fails. | | **200 model requests** per review | The model proxy refuses the next request and stops the review, and its check fails. | | **Tokens per review** (default 2,000,000, from 100,000 to 50,000,000) | The same, once the next request could take the review past its budget. | | **10 automatic reviews** per pull request in 24 hours | Later pushes get an action-required "Waiting for a request" check. People with write access can still ask. | | **20 reviews** per pull request in 24 hours, asked for or automatic | The check asks for action, and asking returns `429`. | | The repository's **daily limit** (default 50, from 1 to 1000) | The check asks for action, asking returns `429`, autofix waits, and the admin whose key reviews use gets an email, at most once a day. | Daily limits count the reviews and autofix runs started in the last 24 hours, so reviews run again as older ones fall out of that window. Diffinery counts tokens itself, as requests pass through the model proxy: - Before a request goes to the provider, the proxy books the most it may cost: a token per 3 bytes of request, plus the output it allows (its `max_tokens` or the endpoint's equivalent, or 32,000 when it sets none; a Codex compaction, which can't be capped, books 128,000). If that could take the review past its budget, the request is refused and the review stops. A review has at most 4 requests on their way at once (a fifth gets `429` and is tried again), so requests sent at once can overrun its budget by at most that many: each one's output is capped at what it booked, and only its input can cost more than its estimate (dense text, or images and PDFs, which bill by pixels and pages). - Once the response has streamed through, the booking is replaced by the usage the provider reports: input, output and cache writes in full, cache reads as a tenth, since providers bill them at about a tenth. A response that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap. An error response costs nothing. If the agent hangs up while an answer is still streaming, Diffinery keeps reading it for about 30 seconds; an answer that takes longer stays charged at its booking, and its place among the 4 frees after 15 minutes. - A review can't start background responses, pull in input its request doesn't carry (`previous_response_id`, `conversation`, `prompt` or `item_reference` inputs, an Anthropic `container`, or images, documents and files given by URL or file id), send a body that isn't a JSON object, ask for more than one answer (`n`), use an OpenRouter web search model (`:online`) or preset, read back stored responses, or set routing and paid extras (`models`, `route`, `provider`, `plugins`, `web_search_options`, `speed`, `best_of`, `preset`). A review through OpenRouter asks only for its configured model. A review's requests keep only tools the agent runs itself (function and custom tools, and client-run ones such as bash, text editor, memory, a local shell and apply patch); every tool that runs on the provider is stripped, along with MCP servers, so web search, web fetch, code execution, file search, image generation and any server tool a provider adds later never reach it. Claude Code reviews also run with WebSearch and WebFetch turned off, and Codex reviews with web search disabled. A review's request is sent with every output cap set to the one booked (the largest it set, or 32,000 when it set none), so its booking holds. Every run's model requests and tokens show in its transcript and in the API's `runs` (`model_requests`, `tokens`). Change the daily limit and the token budget under **Settings → Agent review**. ## Who pays, and the risks - Every review spends the model key of the admin who turned reviews on. In an organization's repository, its [agent policy](https://diffinery.com/docs/agents/organizations#whose-key-pays) can have the organization's key pay instead, while that admin is a member; it can also turn agent review off for all its repositories. Use a **dedicated key with a spending limit** with your provider as well: Diffinery's [limits](#limits) count reviews, requests and tokens, not money. - While a review runs, anything in its jail, including code from the pull request, can use that key through the model proxy, though it can't read the key or the review's model token. The proxy forwards only the model endpoints, and the token stops working when the agent exits (see [How your key is protected](https://diffinery.com/docs/agents/tasks#how-your-key-is-protected)). Otherwise the jail reaches only the package registries (see [What a run can reach](https://diffinery.com/docs/agents/tasks#what-a-run-can-reach)). - The sandbox can't push or write repository content. Its credential acts as the agent with read access. ## REST API | Endpoint | Does | Who | | --- | --- | --- | | `GET /repos/{owner}/{repo}/diffinery/agent-review` | The repository's agent review settings | Write access | | `PUT /repos/{owner}/{repo}/diffinery/agent-review` | Turn on, change (`agent`, `runtime`, `provider` and `model` for OpenRouter, `command`, `trigger`: `push`, `open` or `request`, `authors`: `all`, `people` or `agents`, `drafts`, `autofix`, `daily_limit`: 1 to 1000, `tokens_per_review`, `accept_terms: true`) or turn off (`enabled: false`) | Repository admin, browser session only | | `GET /repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review` | The pull request's review task, the last reviewed head and every finding with its state (`open`, `fixed`, `dismissed`) | Write access | | `POST /repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review` | Ask for a review (`429` past a daily limit) | People with write access (not agents) | ```json { "task": { "number": 12, "kind": "review", "state": "ready", "html_url": "https://diffinery.com/acme/website/tasks/12" }, "reviewed_sha": "9f2c…", "findings": [ { "id": "F1", "path": "src/math.js", "line": 2, "side": "RIGHT", "severity": "blocking", "title": "Divides by zero", "state": "fixed", "fixed_in": "4be1…" } ] } ``` ## Moving from Bugbot | Bugbot | Agent review | | --- | --- | | Reviews on open and every push | **When to review: every pull request** | | `bugbot run` comment | `@<agent> review`, or **Request agent review** | | `.cursor/BUGBOT.md` | Read as is, with `REVIEW.md` | | Fix in Cursor / Autofix | **Fix it** / **Autofix** | | Bugbot check | **Agent review** check | --- # MCP server > Connect AI agents to Diffinery over the Model Context Protocol at https://diffinery.com/mcp, with client setup, the tool list, permissions and example calls. Source: https://diffinery.com/docs/agents/mcp Diffinery runs a Model Context Protocol (MCP) server at `https://diffinery.com/mcp`. An MCP client such as Claude Code, Cursor or VS Code can use it to read code, commit changes, open and review pull requests, follow CI, publish releases, run commands in sandboxes, import repositories from GitHub and look things up in these docs, all without a git client. Every tool goes through the same permission checks, branch protection and agent guardrails as git and the REST API. ## Endpoint and transport | Property | Value | | --- | --- | | URL | `https://diffinery.com/mcp`; on a [self-hosted Diffinery](https://diffinery.com/docs/self-hosting), `/mcp` on your instance, such as `https://git.example.com/mcp` | | Transport | Streamable HTTP. Send JSON-RPC 2.0 messages with `POST`; every response is a single JSON body (`application/json`), never an event stream. | | Sessions | None. The server is stateless and doesn't issue an `Mcp-Session-Id`; send the token with every request. | | Batches | A JSON array of requests gets a JSON array of responses. | | Notifications | Requests without an `id` (such as `notifications/initialized`) get `202 Accepted` with an empty body. | | `GET /mcp` | `405` with `{"message": "Diffinery MCP: POST JSON-RPC here with Authorization: Bearer <token>"}`. There is no server-to-client stream. | | Protocol versions | `2025-06-18` (default), `2025-03-26`, `2024-11-05`. `initialize` answers with the version you asked for when it is supported, otherwise `2025-06-18`. | | Capabilities | `tools` only (`listChanged: false`). No resources or prompts. | | Methods | `initialize`, `ping`, `tools/list`, `tools/call`, and the notifications `notifications/initialized` and `notifications/cancelled` | | Server info | `{"name": "diffinery", "title": "Diffinery", "version": "0.1.0"}`, plus short usage `instructions` for the model (how to read code, commit, open pull requests, watch CI, use sandboxes and search the docs) | ## Authentication There are two ways to authenticate, and every tool call goes through the same checks either way. **Sign in through the browser (OAuth).** Clients that support MCP's OAuth sign-in (claude.ai connectors, Claude Code, Codex, Cursor, VS Code) need nothing but the URL. On their first request they get a `401` whose header says where to sign in: ```http WWW-Authenticate: Bearer realm="Diffinery", resource_metadata="https://diffinery.com/.well-known/oauth-protected-resource/mcp" ``` The client then opens Diffinery in your browser, where you choose whom it acts as (you, or one of your [agents](https://diffinery.com/docs/agents/overview)) and its access, and connect it. It gets tokens that last 8 hours and renew themselves; the connection is listed under **Settings → Access tokens → Connected apps**, where you can disconnect it. See [How sign-in works](https://diffinery.com/docs/connect/how-sign-in-works) for the details. **A token in the header.** For scripts, CI and cloud sessions where nobody can click through a browser, send a Diffinery token: ```http Authorization: Bearer dfy_... ``` `token dfy_...` and HTTP Basic auth with the token as the password also work. Use an agent's token for an AI agent (see [Agents](https://diffinery.com/docs/agents/overview)), or your own personal token (see [Tokens](https://diffinery.com/docs/accounts/tokens)). Either way, the token's scope limits what the tools can do: every tool that isn't read-only needs the `write` or `admin` scope, so a `read` token can only use read-only tools (see [Token scope and write tools](#token-scope-and-write-tools)). | Response | Meaning | | --- | --- | | `401 {"message": "sign in required"}` | No `Authorization` header. The `WWW-Authenticate` header points at the OAuth metadata. | | `401 {"message": "invalid, expired or revoked token"}` | The token is wrong, expired or revoked, or its account (or agent) is disabled. `WWW-Authenticate` adds `error="invalid_token"`, so an OAuth client renews its token or signs in again. | | `401 {"message": "unrecognised token; create one under Settings → Access tokens"}` | The header doesn't hold a `dfy_` token. | ## Connect your client For Claude, Claude Code, Codex and other apps with browser sign-in, [Connect AI apps](https://diffinery.com/docs/connect) has step-by-step guides: a connector or a plugin, and a browser sign-in. The snippets below use a token in the header instead, for clients without OAuth and for headless use. Create the token in the web UI: for an agent, in **Settings → Agents**; for yourself, in **Settings → Access tokens**. Tokens can't create tokens. Keep it out of files you commit; most clients below can read it from an environment variable. ### Claude Code With a browser on hand, add the server without a header and sign in when Claude Code asks (or run `/mcp`): ```sh claude mcp add --transport http diffinery https://diffinery.com/mcp ``` With a token instead: ```sh claude mcp add --transport http diffinery https://diffinery.com/mcp \ --header "Authorization: Bearer dfy_..." ``` To share the server with a project without sharing the token, put this in the project's `.mcp.json` and set `DIFFINERY_TOKEN` in each developer's environment: ```json { "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" } } } } ``` The `diffinery` CLI prints exactly this snippet with `diffinery mcp-config` (add `--show-token` to embed the saved token instead of `${DIFFINERY_TOKEN}`). Run `/mcp` inside Claude Code to check that the server is connected. ### Cursor Add to `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for all projects: ```json { "mcpServers": { "diffinery": { "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_..." } } } } ``` ### VS Code Add to `.vscode/mcp.json`. VS Code prompts for the token and stores it securely: ```json { "inputs": [ { "type": "promptString", "id": "diffinery-token", "description": "Diffinery token (dfy_...)", "password": true } ], "servers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer ${input:diffinery-token}" } } } } ``` ### Claude Desktop and other clients that start local servers Clients whose configuration only launches local (stdio) servers can reach Diffinery through a bridge such as `mcp-remote`, which forwards to the remote URL with your header: ```json { "mcpServers": { "diffinery": { "command": "npx", "args": ["-y", "mcp-remote", "https://diffinery.com/mcp", "--header", "Authorization:${AUTH_HEADER}"], "env": { "AUTH_HEADER": "Bearer dfy_..." } } } } ``` ### Any other client Point an MCP client that supports the Streamable HTTP transport at `https://diffinery.com/mcp` and configure a custom `Authorization: Bearer dfy_...` header. The usual JSON form is: ```json { "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_..." } } } } ``` ## Tools Every tool that works on a repository takes `owner` and `repo`. A repository that was transferred or renamed is also found under its old owner and name, for anyone who can read it, so an agent configured with the old name keeps working. Tools marked read-only have the MCP annotation `readOnlyHint: true`, so clients can run them without asking. Every tool marked **No** needs a token with the `write` or `admin` scope, whatever its minimum role. The full input schema of each tool is on [MCP tools reference](https://diffinery.com/docs/reference/mcp-tools), and `tools/list` returns it at runtime. ### Content written by others Repository files, branch names, issue and pull request text, comments, workflow logs and sandbox output can be written by anyone who can push or comment, including on a public repository. Someone can write text there meant to steer your agent. Results from tools that return such content (`list_repos`, `list_tree`, `get_file`, `list_branches`, `compare`, `list_pull_requests`, `get_pull_request`, `list_issues`, `get_issue`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`, `sandbox_exec`, `sandbox_read_file`, `get_import` and `list_imports`) end with a second text block. It tells the model to treat the result as data and not to follow instructions inside it. These tools also carry the `openWorldHint` annotation in `tools/list`, and the server's `instructions` say the same. This lowers the risk but can't remove it, so give an agent a token with only the scopes and repositories it needs. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). ### Account and repositories | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `whoami` | Yes | The account the token acts as (`login`, `kind`) and, for an agent, `on_behalf_of` | Any token | | `today` | Yes | Your morning view: pull requests that need you, your agents' open pull requests and where each stands, and what merged, was released or failed in the last 24 hours. See [Today](https://diffinery.com/docs/agents/today) | Any token | | `agent_tasks` | Yes | Agent tasks: with `owner` and `repo` that repository's, without them the ones you started (an agent: the ones it runs). See [Agent tasks](https://diffinery.com/docs/agents/tasks) | Write on the repository; any token for your own | | `agent_task` | Yes | One agent task (`owner`, `repo`, `number`) with its runs, pull request and transcript after `after` | Write | | `project` | Yes | The repository's [project](https://diffinery.com/docs/agents/projects): its task board, memory and settings | Write | | `project_posts` | Yes | The project's conversation after `after`, or the latest 100 lines | Write | | `list_repos` | Yes | Repositories visible to you, optionally filtered by `owner` | Any token | | `get_organization` | Yes | An organization's `login` and `name`; to its members, its agents, agents of its members and site administrators also `default_repository_permission` (the base permission members get on its repositories) | Any token | | `list_invitations` | Yes | Pending invitations to a repository (`owner` and `repo`), an organization (`org`) or the instance (neither), each with an `id`, and the `email` or, for an invitation sent to a username, the `login` | Admin on the repository; organization owner or admin; site administrator | | `revoke_invitation` | No | Revoke a pending invitation by `invitation_id`, with the same `owner` and `repo`, `org` or neither; the address can then be invited again | Same as `list_invitations`; organization and instance invitations need an `admin` token | ### Code | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `list_tree` | Yes | Files and directories at a `path` and `ref` (default: the default branch) | read | | `get_file` | Yes | A file's contents at a `ref`; binary files return only their size and `binary: true` | read | | `list_branches` | Yes | Branches, marking the default and protected ones | read | | `compare` | Yes | Commits ahead and behind, and the diff, between `base` and `head` | read | ### Branches and commits | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `create_branch` | No | Create `branch` from another branch, tag or commit (`from`, default: the default branch) | write | | `commit_files` | No | One commit on `branch` that writes `files` and removes `deletes`; see [Commit without git](#commit-without-git) | write | ### Pull requests | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `list_pull_requests` | Yes | Up to 100 pull requests by `state` (`open`, `closed`, `all`) | read | | `get_pull_request` | Yes | A pull request with its comments, reviews and mergeability (including blockers); `include_diff: true` adds the files and patches. `mergeability.blockers` is what stops the caller merging it directly now. For agents and workflow tokens it also returns `auto_merge_blockers`: what stops Diffinery landing it through auto-merge or the merge queue (`[]` means it would land now). An agent on a protected branch sees `agents may not merge into this branch` in the first list but not in the second when the rule allows auto-merge. | read | | `create_pull_request` | No | Open a pull request: `title`, `head`, optional `base`, `body`, `draft`, `intent`, `reviewers`, and `auto_merge` (`true` turns on auto-merge as it opens, `false` keeps it off where your setting would turn it on; `autoMergeSkipped` says why it stayed off when asked). See [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green) | write | | `comment` | No | Comment on an issue or pull request | triage | | `review_pull_request` | No | Submit an `APPROVE`, `REQUEST_CHANGES` or `COMMENT` review, with optional line `comments` | read (write to approve or request changes) | | `merge_pull_request` | No | Merge with `method`, `expected_head_sha`, `delete_branch`; queues the pull request when the base branch uses a merge queue. Agents can't merge into the default branch or protected branches this way: they use `enable_auto_merge` | write | | `enable_auto_merge` | No | Turn on auto-merge, optionally with `merge_method`, `commit_title`, `commit_message`, `delete_branch` and `expected_head_sha`: Diffinery merges the pull request (or adds it to the merge queue) once every branch rule is met. Returns `state` (`enabled`, `queued` or `merged`), `autoMerge` and, once merged, `mergeCommit`. See [Auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge) and [Agents landing changes](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes) | write | | `disable_auto_merge` | No | Turn auto-merge off; returns `{"auto_merge": null}` | write | | `dequeue_pull_request` | No | Remove a pull request from the merge queue | write | ### Issues | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `list_issues` | Yes | Up to 100 issues by `state`, search text `q` and `label` | read | | `get_issue` | Yes | An issue (or pull request) with its comments and reviews | read | | `create_issue` | No | Open an issue with `title`, optional `body` and `labels` (`labels` from an account below triage are dropped, as on GitHub) | read | | `update_issue` | No | Change the `title`, `body`, `state` (`open`, `closed`) or `labels` of an issue or pull request | triage | ### Workflows | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `list_workflow_runs` | Yes | The 30 newest runs, filtered by `branch`, `sha`, `pr_number` or `status` | read | | `get_workflow_run` | Yes | A run with its jobs, their status, conclusion and failure reason | read | | `get_job_logs` | Yes | A job's log as text; the last `tail` lines (default 400), with `complete` and `truncated` flags | read | | `rerun_workflow` | No | Re-run a finished run as a new attempt; with `failed_only`, only its failed jobs and the jobs that depend on them | write | | `rerun_job` | No | Re-run one job (`job_id`) of a finished run and the jobs that depend on it | write | | `cancel_workflow` | No | Cancel a queued or running run | write | | `dispatch_workflow` | No | Start a `workflow_dispatch` run of `workflow` (for example `deploy.yml`) on `ref` with `inputs` | write | ### Releases | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `list_releases` | Yes | Releases, newest first, up to `limit` (default 30, at most 100), with `total`. Drafts are included only for writers. | read | | `get_release` | Yes | A release by `tag` or `id`, or the latest release when you pass neither | read | | `create_release` | No | Create a release for `tag_name`, creating the tag at `target_commitish` (default: the default branch) if it doesn't exist. Optional `name`, `body`, `draft`, `prerelease`, `make_latest` and `generate_release_notes`. | write | | `update_release` | No | Edit a release by `id`: `tag_name`, `target_commitish`, `name`, `body`, `draft`, `prerelease`, `make_latest`. Publishing a draft sends the `release` event. | write | | `upload_release_asset` | No | Attach a file of up to 25 MB to release `release_id`: `name`, and either `content` (text) or `content_base64` (bytes), with optional `label` and `content_type` | write | | `generate_release_notes` | Yes | Draft notes for `tag_name` from the pull requests merged since the previous release (or `previous_tag_name`). Creates nothing. | write | See [Releases](#releases-over-mcp) below for the result shape. ### Docs | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `search_docs` | Yes | Search this documentation for `query` (for example `merge queue` or `workflow_dispatch inputs`). Returns up to 8 matching pages, each with `page`, `title`, `section`, the best matching `heading`, `url`, `markdown_url` and a `snippet`. | Any token | | `read_docs` | Yes | Read one docs page as Markdown. `page` is a slug (`actions/workflow-syntax`), a docs URL, or `index` for the overview that links to every page. Returns `page`, `title` and `markdown`. | Any token | ### Sandboxes | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `sandbox_create` | No | Start a Linux sandbox with the repository checked out at `ref`; returns its `id` | write | | `sandbox_exec` | No | Run a shell `command` in the sandbox, with optional `cwd` and `timeout_seconds` | write | | `sandbox_read_file` | No | Read a file; text comes back as text, other files as base64 | write | | `sandbox_write_file` | No | Write a text file | write | | `sandbox_close` | No | Shut the sandbox down | write | See [Sandboxes](https://diffinery.com/docs/agents/sandboxes) for what is inside a sandbox and its limits. ### Imports from GitHub | Tool | Read-only | What it does | Minimum role | | --- | --- | --- | --- | | `start_github_import` | No | Start a server-side import of one GitHub repository: `source` (`owner/name` or a GitHub URL) and `owner` (the Diffinery organization or user to import into), optional `name`, `visibility` (`keep`, `private`, `public`), `github_token` and `github_api_url`. Returns the import, with its `id`. | See [Imports over MCP](#imports-over-mcp) | | `get_import` | Yes | An import by `id`: `status`, per-step progress, `git` transfer progress, `summary`, `warnings`, `error` and, for an organization import, its repositories | You started it; a person who administers its target, or a site administrator, can also read it | | `list_imports` | Yes | The imports you started, newest first | Any token | ## Commit without git `commit_files` creates a single commit from file contents: | Argument | Meaning | | --- | --- | | `branch` | Branch to commit to (required) | | `message` | Commit message (required) | | `files` | `[{ "path", "content", "encoding": "utf-8" or "base64", "executable": true or false }]` | | `deletes` | Paths to remove | | `create_branch_from` | Create `branch` from this branch, tag or commit if it doesn't exist | | `expected_head_sha` | Only commit if `branch` is still at this commit; otherwise `branch moved; refresh and retry` | It returns `{ "sha": "...", "branch": "..." }`. At least one file or delete is required (`nothing to commit`). Committing to a branch that doesn't exist without `create_branch_from` fails with `branch fix-pricing-link not found`. Commits made by agents are stamped with the agent and its owner; see [Agents](https://diffinery.com/docs/agents/overview#how-agent-activity-is-recorded). `commit_files` runs [push protection](https://diffinery.com/docs/security/push-protection) before it writes anything, like `git push`. If a file looks like a credential (an AWS key, a GitHub or Diffinery token, a private key and so on), nothing is committed and the tool fails with, for example, `push protection: possible secret detected (AWS access key) in config/deploy.env. Remove it, or rotate it and ask an admin.` The attempt is recorded in the audit log as `api.blocked_secret` against the token's account, and the agent's owner gets a `Your commit was blocked` email. `commit_files` has no bypass. For a test fixture with fake keys, put the text `diffinery:allow-secret` anywhere in the file. ## Releases over MCP Release tools return a compact release object: ```json { "id": 7, "tag_name": "v1.4.0", "target_commitish": "main", "name": "v1.4.0", "body": "## What's Changed\n...", "draft": false, "prerelease": false, "latest": true, "author": "review-bot", "created_at": "2026-10-13T09:30:00.000Z", "published_at": "2026-10-13T09:30:00.000Z", "html_url": "https://diffinery.com/acme/website/releases/tag/v1.4.0", "assets": [{ "id": 12, "name": "notes.txt", "label": "", "content_type": "text/plain", "size": 18, "download_count": 0, "download_url": "https://diffinery.com/acme/website/releases/download/v1.4.0/notes.txt" }] } ``` - `make_latest` is the string `"true"`, `"false"` or `"legacy"`; a new published release becomes the latest by default. - `draft: true` keeps a release visible to writers only. `update_release` with `draft: false` publishes it, which starts `on: release` workflows and sends the `release` webhook event. - `upload_release_asset` takes the file inline, so it is limited to 25 MB (`MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files`). Pass exactly one of `content` and `content_base64`. Text defaults to `text/plain`, bytes to `application/octet-stream`. For files up to 100 MB, use the release's `upload_url` in the [REST API](https://diffinery.com/docs/reference/rest-api#release-assets). - `generate_release_notes` only drafts text; pass its `body` to `create_release`, or set `generate_release_notes: true` there. - Publishing a release for a tag that doesn't exist creates the tag at `target_commitish`, and that follows the same rules as any ref update. A draft creates its tag only when it is published. ## Imports over MCP `start_github_import` starts the same server-side importer as the web UI, `diffinery migrate github` and `POST /api/v3/diffinery/imports`. It imports one repository with every part turned on: Git history and Git LFS, issues and pull requests with their numbers, comments, reviews and review comments, labels, milestones, branch protection, releases, and (for people) Actions variables, environments and secret names. Secret values are never read from GitHub. The tool returns at once; the import runs on the server. ```sh curl -s https://diffinery.com/mcp \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"start_github_import","arguments":{"source":"acme/website","owner":"acme","github_token":"ghp_..."}}}' ``` The result is the import object (`id`, `html_url`, `status`, `source`, `target`, `steps`, `git`, `summary`, `warnings`, `error`). Call `get_import` with the `id` every minute or so until `status` is `succeeded`, `failed` or `cancelled`. While the import runs, every other write to the new repository is refused: pushes with `repository is locked by an import in progress`, and tools such as `create_issue` with `repository is locked by an import in progress; try again when it finishes, or cancel the import`. A person can still set the repository's Actions secrets and variables, environments and collaborators while it runs. Who can start one: - The token needs the `write` or `admin` scope (`starting an import needs a token with write or admin scope`). - A person can import into their own account or an organization they own or administer. - An agent can import only where its owner may: a person's agent into the owner's own account or an organization the owner owns or administers, an organization's agent into that organization (`agents can import only where their owner may: the owner's own account or an organization the owner administers, not <owner>`). Its maximum role must be at least `write` (`this agent's policy does not allow writing; an import needs at least write`). Rules for imports an agent starts: - **Push protection is never bypassed.** If old history contains something that looks like a credential, the import stops and fails with `Push protection found possible secrets in the history (...)`. A person who administers the target starts a new import from the web or API to import it with an audited bypass. - **Variables, secret names and environments are skipped** (`skipped (agents cannot manage variables)`); a person sets them up afterwards. Agents never read or set secret values. - **No retry once the repository exists.** An agent's retry (through the REST API) is refused after the repository was created; a person who administers the target retries it instead. - The agent sees only the imports it started (`get_import`, `list_imports`). An import uses the GitHub token only for that import, deletes it when the import ends, and never returns it. For GitHub Enterprise, pass `github_api_url`, for example `https://ghe.example.com/api/v3`; when `source` is an Enterprise URL and you pass a token, `github_api_url` must be on the same host. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github) for what is copied, limits (at most 3 repository imports at a time per account and per target) and errors. ## Permissions The token's account (person or agent), its role on the repository and the token's scope decide what each tool may do, exactly as for the REST API. The minimum role for each tool is in the tables above. ### Token scope and write tools Diffinery checks the token's scope before a tool runs. A tool that isn't read-only (`readOnlyHint: false`) needs a token with the `write` or `admin` scope. With a `read` token, the call fails with `isError: true` and the text `this token needs the write scope`, even when the role the tool asks for is only read or triage: `create_issue`, `comment`, a `COMMENT` review with `review_pull_request`, and the sandbox tools all need a `write` token. Read-only tools work with any scope, including `read`. Use a `write` token for agents that change anything, and a `read` token for agents that only read code, issues, pull requests, runs, releases and docs. ### Other rules On top of the role and scope: - Agents can't push or commit to the default branch or protected branches, and can't merge into them, unless the branch's rule allows it (`agentsMayPush`, `agentsMayMerge`). Where the rule has `allowAgentAutoMerge` and a required check, an agent lands its pull request there with `enable_auto_merge` instead, and Diffinery merges once the rules are met (see [Agents landing changes](https://diffinery.com/docs/repositories/branch-protection#agents-landing-changes)). - Pull requests opened by agents need a human approval, and approvals submitted by agents with `review_pull_request` never count toward it. - Private repositories you can't see return `repository not found`, the same as a repository that doesn't exist. - Archived repositories refuse every tool that changes something (`repository is archived (read-only)`), whatever the role. Read-only tools keep working. - `commit_files` refuses files that look like credentials (see [Commit without git](#commit-without-git)). - `create_issue` drops `labels` when the account's role is below triage, as GitHub does for issues opened by people without triage access. - Release tools follow the same rules as the REST API: anyone who can read the repository can list published releases, writers create, edit and publish them and see drafts, and a release that creates a tag is a ref update, so agent branch and tag rules apply. - `start_github_import` follows the agent import rules in [Imports over MCP](#imports-over-mcp). - `search_docs` and `read_docs` read the public documentation. They work with any token, read-only included, and don't touch a repository. See [Agents](https://diffinery.com/docs/agents/overview#guardrails) and [Permissions](https://diffinery.com/docs/accounts/permissions). ## Results and errors A successful `tools/call` returns the tool's output twice: as pretty-printed JSON in a text content block, and as `structuredContent` (the object itself, or `{ "result": ... }` when the output is an array or a plain value): ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "{\n \"path\": \"README.md\",\n ..." }], "structuredContent": { "path": "README.md", "commit": "51c0e2aa...", "size": 412, "content": "# Website\n..." }, "isError": false } } ``` When a tool fails, the call still succeeds at the JSON-RPC level, with `isError: true` and the error message as text. The messages are the same as the REST API's: ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [{ "type": "text", "text": "agents may not push to protected or default branches; open a pull request" }], "isError": true } } ``` Missing or invalid arguments produce messages such as `owner is required`, `title must be a string` or `number must be a positive integer`. Tools that change something count toward your account's 60 changes a minute. Past that, the call fails with `Too many changes from your account: at most 60 a minute through MCP. Try again in a minute.` Every request to `/mcp` also counts toward the account's 2,000 requests a minute; past that, the HTTP response itself is `429` with `retry-after: 60`. See [Rate limits](https://diffinery.com/docs/reference/rest-api#rate-limits). Protocol-level errors use JSON-RPC error objects: | Code | Message | Cause | | --- | --- | --- | | `-32700` | `parse error` | The body isn't JSON (HTTP status 400). | | `-32600` | `invalid request` | A message without a string `method`. | | `-32601` | `method not found: <method>` | An unsupported method, such as `resources/list`. | | `-32602` | `unknown tool: <name>` | `tools/call` named a tool that doesn't exist. | ## Example calls with curl Set your token once: ```sh export DIFFINERY_TOKEN=dfy_... ``` ### Initialize ```sh curl -s https://diffinery.com/mcp \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' ``` ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { "name": "diffinery", "title": "Diffinery", "version": "0.1.0" }, "instructions": "Diffinery is a git forge built for agents. Read code with get_file/list_tree, ..." } } ``` ### List tools ```sh curl -s https://diffinery.com/mcp \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' ``` Each tool has `name`, `description`, `inputSchema` (a JSON Schema object with `properties` and `required`) and `annotations.readOnlyHint`. ### Read a file ```sh curl -s https://diffinery.com/mcp \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_file","arguments":{"owner":"acme","repo":"website","path":"README.md"}}}' ``` ### Commit on a new branch and open a pull request ```sh curl -s https://diffinery.com/mcp \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"commit_files","arguments":{ "owner":"acme","repo":"website","branch":"fix-pricing-link","create_branch_from":"main", "message":"Fix the Contact sales link", "files":[{"path":"src/pricing.html","content":"<a href=\"/contact\">Contact sales</a>\n"}]}}}' curl -s https://diffinery.com/mcp \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"create_pull_request","arguments":{ "owner":"acme","repo":"website","title":"Fix the Contact sales link","head":"fix-pricing-link", "intent":"Point the Contact sales link at /contact; no other changes."}}}' ``` ### Check CI ```sh curl -s https://diffinery.com/mcp \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_workflow_runs","arguments":{"owner":"acme","repo":"website","pr_number":12}}}' ``` Then `get_workflow_run` with a run's `id`, and `get_job_logs` with a failed job's `id`, to see why it failed. ## A typical agent workflow 1. `whoami` to confirm the account and its owner. 2. `search_docs` and `read_docs` when it needs to check how a Diffinery feature works (for example `workflow_dispatch inputs` or `branch protection`). 3. `list_tree` and `get_file` (or `compare`) to read the code. 4. `commit_files` with `create_branch_from` to put changes on a new branch, passing `expected_head_sha` on later commits to avoid overwriting someone else's. 5. `sandbox_create` and `sandbox_exec` to build and run tests when needed. 6. `create_pull_request` with an `intent` that states the goal. 7. `list_workflow_runs`, `get_workflow_run` and `get_job_logs` to follow CI, fixing failures with more commits. 8. `get_pull_request` to read reviews and the remaining merge blockers. A person approves and merges. Or the agent calls `enable_auto_merge` where the branch allows it (`allowAgentAutoMerge`), and Diffinery merges once the checks pass and a person has approved (or without the approval where an admin turned on `agentAutoMergeWithoutApproval`); `auto_merge_blockers` lists what auto-merge still needs. Or it merges with `merge_pull_request` where the branch allows agents to merge. ## List your instance in the MCP registry The [official MCP registry](https://registry.modelcontextprotocol.io) lists Diffinery's MCP server as `com.diffinery/diffinery`, with the URL `https://{diffinery_host}/mcp`: clients that read the registry ask for your instance's host name. To list your own instance under your domain as well, prove that you own the domain with a key: 1. Make a key pair and its proof record (OpenSSL 3): ```sh openssl genpkey -algorithm Ed25519 -out mcp-registry-key.pem echo "v=MCPv1; k=ed25519; p=$(openssl pkey -in mcp-registry-key.pem -pubout -outform DER | tail -c 32 | base64)" ``` 2. Add the printed line as `"MCP_REGISTRY_AUTH"` under `vars` in `wrangler.jsonc` and deploy. Check that `https://<your host>/.well-known/mcp-registry-auth` returns it. 3. Sign in to the registry with [`mcp-publisher`](https://github.com/modelcontextprotocol/registry) and publish a `server.json` named after your domain in reverse (`com.example/diffinery` for `git.example.com`), with `remotes` set to `[{"type": "streamable-http", "url": "https://git.example.com/mcp"}]`: ```sh mcp-publisher login http --domain git.example.com \ --private-key "$(openssl pkey -in mcp-registry-key.pem -noout -text | grep -A3 'priv:' | tail -n +2 | tr -d ' :\n')" mcp-publisher publish server.json ``` The record holds only the public key. Keep the `.pem` file to publish updates; if you lose it, make a new pair and replace the record. ## Limitations - The server offers tools only: no resources, prompts or sampling. - Responses are plain JSON. The server doesn't stream, keeps no session, and can't send messages to the client on its own. - OAuth sign-in supports the authorization code flow with PKCE only, for apps that register themselves or identify by a client metadata document. There is no device flow for MCP; on a machine without a browser, use a token in the header. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | The client says it can't connect or gets `401` | The `Authorization` header is missing or the token is wrong. Check the header name and the `Bearer ` prefix, and that the token hasn't expired. | | The browser says "Can't connect this app" | The app's registration expired (unused registrations are removed after a week) or it asked to send you back to an address it didn't register. Remove the server in the app and add it again. | | An app that used to work asks you to sign in again | Its connection lapsed after 30 days unused, or you (or a security check after a reused refresh token) disconnected it. Sign in again. | | `repository not found` | Wrong `owner` or `repo`, or the account (or, for an agent, its owner) can't see the repository. | | `this token needs the write scope` | The tool isn't read-only and the token has only the `read` scope. Create a `write` token (for an agent, in **Settings → Agents** while signed in). | | `write access required` (or `triage access required`) | The account's role, or the token's scope, is too low for the tool. A `read` token can only use read-only tools. | | `agents may not push to protected or default branches; open a pull request` | Commit to a new branch with `create_branch_from` and open a pull request. | | `agents may not merge into this branch; a human must merge` | `merge_pull_request` into the default branch or a protected branch. Use `enable_auto_merge` where the branch allows it, or ask a person to merge. | | `agents may not have pull requests merged into main automatically unless its branch protection allows it ...` | `enable_auto_merge` on a branch whose rule doesn't set `allowAgentAutoMerge`. A person merges, or an admin allows it. | | `Auto-merge was turned off: ...` (a comment on the pull request) | Auto-merge stopped, for example because someone else pushed to the branch. Read the reason and call `enable_auto_merge` again. See [When auto-merge turns itself off](https://diffinery.com/docs/collaboration/pull-requests#when-auto-merge-turns-itself-off). | | `branch moved; refresh and retry` | Someone else committed to the branch. Read the new head and retry with the new `expected_head_sha`. | | `push protection: possible secret detected (...)` | A file passed to `commit_files` looks like a credential. Remove it and commit again, or add `diffinery:allow-secret` to a file whose keys are fake. | | `repository is archived (read-only)` | The repository is archived. Only read-only tools work until a repository admin unarchives it. | | `method not found: resources/list` | The server only offers tools; the client can ignore this. | | `MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files` | `upload_release_asset` takes the file inline. Upload larger files (up to 100 MB) to the release's `upload_url` with the [REST API](https://diffinery.com/docs/reference/rest-api#release-assets). | | `pass exactly one of content or content_base64` | `upload_release_asset` needs the file as either text or base64, not both and not neither. | | `make_latest must be true, false or legacy` | Pass `make_latest` as one of those strings, not as a boolean. | | `query is required` | `search_docs` was called with an empty `query`. | | `agents can import only where their owner may: ...` | Pass as `owner` the owner's own account or an organization the owner administers (for an organization's agent, that organization). | | `too many repository imports are already running (at most 3 at a time); wait for one to finish` | Wait for one of the running imports to finish (check with `list_imports`). | | `no docs page "..."; pages: ...` | `read_docs` didn't recognize the slug. Pick one from the list in the message, or call `read_docs` with `index`. | --- # GitHub CLI and Claude Code > Use gh with Diffinery repositories, and get Claude Code's pull request badge and CI status for them, by signing gh in to Diffinery once. Source: https://diffinery.com/docs/agents/github-cli The GitHub CLI (`gh`) works with Diffinery the way it works with a GitHub Enterprise Server: in a clone of a Diffinery repository, `gh pr create`, `gh pr view`, `gh pr checks`, `gh pr merge` and the other pull request commands talk to Diffinery. Claude Code uses the same setup to show the pull request of the branch you're on and its CI, because it finds pull requests through gh's sign-in. Both go through Diffinery's [GraphQL API](https://diffinery.com/docs/reference/graphql-api) and [REST API](https://diffinery.com/docs/reference/rest-api). ## Set it up You need a recent `gh` (Diffinery is tested with 2.89) and a Diffinery token. With the [Diffinery CLI](https://diffinery.com/docs/accounts/cli-sign-in) signed in, one command signs gh in too: ```sh diffinery login diffinery auth setup-gh ``` `diffinery auth setup-gh` runs `gh auth login --hostname diffinery.com --with-token --git-protocol https` and hands gh your token on its standard input, so the token never shows in your shell history or on screen. gh keeps it in its own credential store. Without the Diffinery CLI, create a token under **Settings → Access tokens** (`write` scope, so gh can open and merge pull requests), save it to a file, and give it to gh the same way: ```sh gh auth login --hostname diffinery.com --with-token --git-protocol https < ~/diffinery-token.txt ``` In CI or a container, set the host and token in the environment instead. gh and Claude Code both read them: ```sh export GH_HOST=diffinery.com export GH_ENTERPRISE_TOKEN=... # from your secret store ``` Check it with `gh api user --hostname diffinery.com --jq .login`, which prints your login. ## Use gh In a clone whose `origin` is on Diffinery, gh picks the host from the remote: ```sh gh pr create --title "Fix the parser" --body "..." --draft gh pr view --web gh pr checks --watch gh pr ready gh pr merge --auto --squash ``` | Works | Commands | | --- | --- | | Reading | `gh pr view` (with `--json`), `gh pr list`, `gh pr status`, `gh pr checks`, `gh pr diff`, `gh repo view`, `gh api` (REST and `gh api graphql`) | | Changing | `gh pr create` (with `--draft`, `--reviewer`, `--assignee`, `--label`), `gh pr edit`, `gh pr ready`, `gh pr comment`, `gh pr close`, `gh pr reopen`, `gh pr merge` (`--merge`, `--squash`, `--rebase`, `--auto`, `--disable-auto`) | | Not yet | Team reviewers, projects, and commands outside pull requests that need GraphQL, such as `gh issue` and `gh release` (use `gh api` with the REST paths) | gh changes things with your token's permissions and Diffinery's rules: branch protection, required checks and reviews, and the [agent guardrails](https://diffinery.com/docs/agents/overview) apply as they do on the web and over REST. `gh pr merge --auto` turns on Diffinery's auto-merge, which merges once the required checks pass. When nothing blocks the merge, gh merges at once. ## Claude Code's pull request badge Claude Code shows the pull request for the branch a session is on, and follows it as it changes: - **In the terminal**, the footer shows `PR #12` for the current branch's open pull request. - **In the Claude desktop app**, the session's pull request chip shows the pull request and its CI. Claude Code reports the branch's pull request when a session starts, and a pull request it opens with `gh pr create` as soon as gh prints its URL. There's nothing to set up beyond signing gh in. Claude Code treats any host other than github.com as GitHub Enterprise: it reads the remote of the folder it's in, gets the token from `GH_ENTERPRISE_TOKEN` (when `GH_HOST` names the host) or from `gh auth token --hostname diffinery.com`, and asks Diffinery's REST and GraphQL APIs for the pull request, its review decision and its CI counts. In the desktop app, the folder has to be one you've trusted. Sessions in Claude Code on the web (claude.ai/code) and project threads run in Anthropic's cloud and get their pull request cards from Anthropic's GitHub integration, which doesn't reach Diffinery. Those sessions can still use gh and the [MCP server](https://diffinery.com/docs/agents/mcp) to open pull requests and follow CI, and the `diffinery-ci` plugin, which the repository's [Agent setup](https://diffinery.com/docs/connect/cloud-agents) script adds, tells Claude when CI on its pushed commit passes or fails. For a live view of every check in the terminal, the desktop app and VS Code, see [CI status in Claude](https://diffinery.com/docs/connect/ci-status-in-claude). ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | gh asks you to run `gh auth login` in a Diffinery clone | gh has no token for the host. Run `diffinery auth setup-gh`, or check that `GH_HOST` matches the remote's host. | | `HTTP 401` from gh | The token expired or was revoked. Create a new one and sign gh in again. | | `gh pr create` or `gh pr merge` fails with a permission error | The token has the `read` scope only, or you need write access to the repository. | | No badge in the terminal | The branch has no open pull request, or gh isn't signed in to the host. `gh pr view` in the same folder shows which. | | `gh auth status` shows most of an older token | gh hides a token only after its last `_`. Tokens Diffinery creates now have no `_` after `dfy_`; if yours does, create a new one and sign gh in again. | --- # Sandboxes > Start an isolated Linux container with a repository checked out, run commands and read and write files in it over MCP or the REST API. Source: https://diffinery.com/docs/agents/sandboxes A sandbox is a short-lived Linux container with a repository already cloned, which you drive over MCP or the REST API: run shell commands, read and write files, and push from it with git. Agents use sandboxes to build and test changes without a machine of their own; people can use them the same way from scripts. ## What a sandbox is - **A container per sandbox.** Each sandbox is its own Cloudflare Container, isolated from other sandboxes and from workflow jobs. It has outbound internet access (only HTTP and HTTPS on instances that set [`EGRESS=web`](https://diffinery.com/docs/self-hosting/configuration#egress)). - **The repository, checked out.** On start, the sandbox clones the repository and checks out the ref you asked for (the default branch if you don't say) into `/workspace/repo`. - **The hosted runner image.** Sandboxes run the same Ubuntu 24.04 image as hosted workflow jobs, so the same toolchains are there. See [What's inside](#whats-inside). - **An HTTP API, not a shell session.** Each command runs on its own in `bash -c`, so `cd` and `export` don't carry over to the next command; use `cwd` and `env` instead. Files stay between commands. - **Tied to its creator.** Only the account that created a sandbox (or a repository admin) can use it, and git inside it acts as that account. - **Not the sandboxes of [agent tasks](https://diffinery.com/docs/agents/tasks).** Those run a person's model access, so no sandbox call reaches them, for anyone. ## Lifecycle 1. **Create.** `sandbox_create` (MCP) or `POST /repos/{owner}/{repo}/diffinery/sandboxes` (REST) starts the container and returns the sandbox `id` once its server is listening. The clone runs in the background. Creating a sandbox usually takes seconds, but the first sandbox on a host after Diffinery is deployed has to pull the container image first, which can take a few minutes. The call waits up to 3 minutes; if the container still hasn't started, it fails with `503 the sandbox container did not start; try again`, and the sandbox is marked failed. Retry the call. 2. **Use.** `exec`, `read` and `write` calls wait for the clone to finish before running, so the first call can take a little longer. 3. **Idle stop.** A sandbox that gets no calls for 30 minutes stops. Its files are not kept; start a new sandbox to continue. 4. **Close.** `sandbox_close` (MCP) or `POST .../sandboxes/{id}/close` (REST) shuts it down and revokes its git credentials. Close sandboxes when you are done. Commit and push anything you want to keep before the sandbox stops or closes. ## Permissions - Creating and using a sandbox needs **write** access to the repository. A `read`-scoped token can't create one. - Each call on a sandbox is allowed for the account that created it and for repository admins; anyone else gets `not your sandbox`. - Inside the sandbox, git authenticates as the creator, with the role the creator had when the sandbox was created, capped at write (even if the creator is an admin) and at the creator's current role. The sandbox's credentials stop working 24 hours after it starts, when it is closed, or as soon as the creator's account is disabled or loses access to the repository. - The sandbox's git credential (a `dst_` token) also lets git clone and fetch other public repositories, for example public submodules, but no other private repository. Use a token of your own inside the sandbox if you need one. - Job and sandbox tokens can't create sandboxes (`403 job and sandbox tokens can't start sandboxes`). Pushes from the sandbox follow the same rules as any push: branch protection, push protection (secret scanning) and, for agents, the [agent guardrails](https://diffinery.com/docs/agents/overview#guardrails). An agent can't push to the default branch from a sandbox either. - Archived repositories can't have sandboxes: starting one, and every call on an existing one (running commands, reading or writing files, closing it), gets `403 repository is archived (read-only)`. A sandbox left running when its repository is archived stops on its own after 30 idle minutes. ## What's inside | Item | Details | | --- | --- | | OS | Ubuntu 24.04 (linux/amd64), running as root | | Working directory | `/workspace/repo`, the clone of the repository | | Node.js | 22 (22.22.0), with npm, and pnpm 10.28.0 and yarn 1.22.22 through corepack | | Python | Python 3 with `pip` and `venv` (`python` points to `python3`); `pip install` works system-wide (`PIP_BREAK_SYSTEM_PACKAGES=1`) | | Build tools | `build-essential` (gcc, g++, make), `pkg-config` | | Git | `git` and `git-lfs` | | Utilities | `curl`, `jq`, `zip`, `unzip`, `xz-utils`, `tar`, `gzip`, `gnupg`, `openssh-client`, `ca-certificates` | | Docker | Docker Engine with the buildx and compose plugins; the daemon starts the first time `docker` runs | Install anything else with `apt-get`, `npm`, `pip` and so on; the sandbox has internet access (only HTTP and HTTPS on instances that set [`EGRESS=web`](https://diffinery.com/docs/self-hosting/configuration#egress)). Environment variables available to commands: | Variable | Value | | --- | --- | | `CI` | `true` | | `DIFFINERY`, `DIFFINERY_SANDBOX` | `true` | | `DIFFINERY_CLONE_URL` | `https://diffinery.com/acme/website.git` | | `DIFFINERY_REF` | The ref the sandbox started on | | `DIFFINERY_API_URL` | `https://diffinery.com` | | `GIT_AUTHOR_NAME`, `GIT_COMMITTER_NAME` | The creator's login, for example `review-bot` | | `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_EMAIL` | `<login>@users.noreply.diffinery.com` | The sandbox's own credentials are not in the environment. The clone's git configuration holds them so that `git fetch` and `git push` work, and they are replaced with `***` wherever they appear in command output. ## Create a sandbox ### Create over MCP ```json { "name": "sandbox_create", "arguments": { "owner": "acme", "repo": "website", "ref": "fix-pricing-link" } } ``` The result is `{ "id": "...", "ref": "fix-pricing-link" }`. `ref` can be a branch, a tag or a commit SHA. ### Create with the REST API ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"ref": "fix-pricing-link"}' ``` ```json { "id": "SANDBOX_ID", "ref": "fix-pricing-link" } ``` The response is `201 Created`. An unknown ref fails with `422 unknown ref`. If the container doesn't start within about 3 minutes, the response is `503` with `{"message": "the sandbox container did not start; try again"}`; nothing is left running, so create the sandbox again. Give the request a client timeout of at least 3 minutes, for example `curl --max-time 240`. ## Run commands ### Run commands over MCP ```json { "name": "sandbox_exec", "arguments": { "owner": "acme", "repo": "website", "sandbox_id": "SANDBOX_ID", "command": "npm ci && npm test", "timeout_seconds": 900 } } ``` | Argument | Meaning | | --- | --- | | `command` | Shell command, run with `bash -c` (required) | | `cwd` | Directory to run in: relative to `/workspace/repo`, or absolute (default `/workspace/repo`) | | `timeout_seconds` | Time limit (default 600, that is 10 minutes) | ### Run commands with the REST API ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes/SANDBOX_ID/exec \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"cmd": "npm test -- --reporter=dot", "cwd": "packages/web", "env": {"NODE_ENV": "test"}, "timeoutMs": 900000}' ``` | Field | Meaning | | --- | --- | | `cmd` | Shell command (required) | | `cwd` | Directory, relative to `/workspace/repo` or absolute | | `env` | Extra environment variables, as an object of strings | | `timeoutMs` | Time limit in milliseconds (default 600000) | ### Command results ```json { "exitCode": 0, "stdout": "...", "stderr": "", "timedOut": false, "truncated": false, "durationMs": 48211 } ``` - `exitCode` is the command's exit status. A command that runs past its time limit is stopped (SIGTERM to the whole process tree, then SIGKILL 5 seconds later) and reports `exitCode: 124` with `timedOut: true`. - `stdout` and `stderr` are each capped at 1 MiB; `truncated` is `true` when either was cut. Redirect large output to a file and read the parts you need. - A non-zero exit code is a normal result, not an error: check `exitCode`. The call returns when the command finishes, so keep each command within your client's request timeout. Start long-running processes in the background (`nohup ... &`) and poll for their output in later commands. ## Read and write files Paths are relative to `/workspace/repo`, or absolute. ### Files over MCP ```json { "name": "sandbox_read_file", "arguments": { "owner": "acme", "repo": "website", "sandbox_id": "SANDBOX_ID", "path": "coverage/summary.txt" } } { "name": "sandbox_write_file", "arguments": { "owner": "acme", "repo": "website", "sandbox_id": "SANDBOX_ID", "path": "src/pricing.html", "content": "<a href=\"/contact\">Contact sales</a>\n" } } ``` `sandbox_read_file` returns `{ path, size, content }` with the content as text when the file is valid UTF-8, and otherwise `encoding: "base64"` with base64 content. `sandbox_write_file` writes `content` as UTF-8 text, creating parent directories as needed. ### Files with the REST API The REST endpoints use base64 for file contents in both directions: ```sh # Read: returns {"path": "/workspace/repo/coverage/summary.txt", "size": 812, "content": "<base64>"} curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes/SANDBOX_ID/read \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"path": "coverage/summary.txt"}' # Write: content is base64; mode (optional) is a numeric file mode, for example 493 for 0755 curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes/SANDBOX_ID/write \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d "{\"path\": \"scripts/check.sh\", \"content\": \"$(printf '#!/bin/sh\necho ok\n' | base64)\", \"mode\": 493}" ``` A write returns `{"ok": true, "path": "/workspace/repo/scripts/check.sh", "size": 18}`. Errors raised inside the sandbox (a missing `cmd` or `path`, a missing file, a file that is too large) come back from the REST `exec`, `read` and `write` endpoints as `{"error": "..."}` with a `4xx` status, rather than the usual `{"message": "..."}`. Over MCP the same text is the tool's error message. ## Close a sandbox - **MCP:** `sandbox_close` with `sandbox_id`. Returns `{ "closed": true }`. - **REST API:** `POST /repos/{owner}/{repo}/diffinery/sandboxes/{id}/close`. Returns `204`. Closing stops the container and revokes the sandbox's git credentials. ## Limits | Limit | Value | | --- | --- | | Time to start | Up to 3 minutes (usually seconds), then `503` | | Idle time before the sandbox stops | 30 minutes without calls | | Default command time limit | 10 minutes (`timeout_seconds` or `timeoutMs` to change it) | | Output per command | 1 MiB each for stdout and stderr | | Request body (REST) | 64 MiB | | File size for reads | 64 MiB | | Git access from inside | The creator's role, at most write | | Open sandboxes | 5 per account in each repository, until each is closed or its credentials expire after 24 hours (agent task sandboxes don't count) | ## Example: test a change and push it An agent fixes a bug on a branch, runs the tests in a sandbox, and pushes a follow-up commit from it: ```text 1. sandbox_create { owner: "acme", repo: "website", ref: "fix-pricing-link" } -> { id: "SANDBOX_ID" } 2. sandbox_exec { ..., command: "npm ci" } -> exitCode 0 3. sandbox_exec { ..., command: "npm test" } -> exitCode 1, stderr shows a failing snapshot 4. sandbox_exec { ..., command: "npm test -- -u && git add -A && git commit -m 'Update pricing snapshot' && git push origin HEAD:fix-pricing-link" } 5. sandbox_close { ..., sandbox_id: "SANDBOX_ID" } 6. list_workflow_runs { owner: "acme", repo: "website", branch: "fix-pricing-link" } -> CI runs on the new commit ``` Pushing to `fix-pricing-link` updates its pull request and starts its `pull_request` workflows as usual. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `write access required` | Sandboxes need write access, and a token with the `write` or `admin` scope. | | `you have 5 open sandboxes in this repository, the most allowed; close one first` (`429`) | Close a sandbox you no longer need (`sandbox_close`, or the REST `close` action), or wait for one to expire. | | `the sandbox container did not start; try again` (`503`) | The container didn't start within about 3 minutes, which can happen right after a deploy while the host pulls the image. Create the sandbox again. | | `sandbox_create` or the REST call seems to hang | The first sandbox after a deploy can take a few minutes to start. Wait, and keep your client's timeout above 3 minutes. | | `unknown ref` | The `ref` isn't a branch, tag or commit in the repository. | | `sandbox not found` | Wrong `sandbox_id`, or a sandbox from another repository. | | `not your sandbox` | Only the creator or a repository admin can use a sandbox. | | `unknown sandbox action` | REST actions are `exec`, `read`, `write` and `close`. | | `cmd` is required | The REST `exec` body needs `cmd` (MCP: `command`). | | `cwd does not exist: ...` | The directory doesn't exist. If it is `/workspace/repo` itself, the clone or checkout failed; create a new sandbox with a valid ref. | | `not found: ...` or `path is a directory` | Check the path; relative paths start at `/workspace/repo`. | | `file too large` or `request body too large` | Files over 64 MiB can't be read or written through the API. Compress them, or push them with git. | | `exitCode: 124` with `timedOut: true` | The command hit its time limit. Raise `timeout_seconds` or `timeoutMs`, or run it in the background. | | `agents may not push to protected or default branches; open a pull request` (from `git push`) | Push to a feature branch and open a pull request. | | Calls fail after a long pause | The sandbox stopped after 30 idle minutes. Create a new one. | --- # Terminals > A shell in your browser with the repository checked out, where Claude Code or Codex runs on your own subscription, and that can take agent tasks. Source: https://diffinery.com/docs/agents/terminal A **terminal** is a sandbox with a repository checked out, in your browser. You type into a real shell, and you can start Claude Code or Codex there and sign in with your own Claude or ChatGPT plan, instead of an API key. Whatever you push from it is pushed as one of your agents, so its pull requests need a person's approval like any agent's. Once you're signed in, you can also [give it a task](#give-it-a-task): Diffinery runs the goal with that sign-in, as an [agent task](https://diffinery.com/docs/agents/tasks), and opens a pull request. Terminals are optional. Nothing changes for a repository until someone opens one. ## Before you start You need: 1. **Write access** to the repository. 2. **An agent** you manage, with write access to the repository, as for [tasks](https://diffinery.com/docs/agents/tasks#before-you-start). 3. **A browser session.** Terminals open only from a signed-in browser. Tokens, the REST API and MCP can't open one or connect to one. In an organization's repository, its [agent policy](https://diffinery.com/docs/agents/organizations#other-policies) can turn terminals off, or stop them from running agent tasks. ## Open a terminal 1. Open the repository's **Project** tab and click **Terminals** (the **Agent tasks** page has the same button), or go to `/<owner>/<repo>/terminals`. 2. Pick the **agent** and the **branch**. Tick **Will run agent tasks** if you'll [give it tasks](#give-it-a-task). Without it, the terminal is only a shell and never runs one. 3. The first time, tick the notice: you use Claude Code and Codex with your own Anthropic or OpenAI account, under that provider's terms, and usage is billed to your account. Diffinery records when you accepted it and which version of the notice it was. When the notice changes, you're asked again. It also says what a task in your terminal can reach, and what that leaves exposed (see [What a task can reach](#what-a-task-can-reach)). 4. Click **Open a terminal**. The page shows **Starting** while the sandbox starts, then the terminal. Reloading the page, or opening it in another tab, connects to the same shell and redraws its recent output. Only one tab is connected at a time: the newest one wins, and the older tab says so. ## Signing in The toolbar has buttons that type a command for you: | Button | What it runs | | --- | --- | | **Start Claude Code** | Installs Claude Code at the version Diffinery pins for [tasks](https://diffinery.com/docs/agents/tasks#runtimes) if it isn't installed, then starts it. Type `/login`, choose your Claude subscription, open the link it shows, sign in with Anthropic and paste the code back. You can use an API key instead: `export ANTHROPIC_API_KEY=…` before starting it. | | **Start Codex** | Installs Codex the same way, runs `codex login --device-auth` if you aren't signed in, then starts it. Open the link it shows, sign in with OpenAI and enter the code. A ChatGPT workspace admin may have to allow device codes. Offered on **private repositories only**, because OpenAI allows signing in with a ChatGPT plan only there; on a public repository you can still use an OpenAI API key. | The sign-in happens in the provider's own flow and stays in that sandbox. Diffinery never sees or keeps it, and it's gone when the terminal ends. The providers' terms: [Anthropic's Consumer Terms](https://www.anthropic.com/legal/consumer-terms), [Claude Code's legal and compliance page](https://code.claude.com/docs/en/legal-and-compliance) and [OpenAI's Terms of Use](https://openai.com/policies/terms-of-use). ## Give it a task A terminal you open with **Will run agent tasks** can run [agent tasks](https://diffinery.com/docs/agents/tasks) with the Claude Code or Codex you signed in to there, on your own subscription, with no API key. A terminal opened without it never runs one; open another to give tasks. - **Start one from the terminal.** Under the terminal, **Give it a task** takes a goal and **Runs with** Claude Code or Codex (Codex on private repositories only). Click **Run with my subscription**. The task's page opens and shows the transcript as it runs. - **Run a task again in it.** On a task you started, with the same agent as an open terminal of yours that runs tasks, **Run in my terminal** runs it again there, and **Send to my terminal** sends your follow-up there. What happens: | Step | What Diffinery does | | --- | --- | | Check | Checks again, on each click, that the terminal is yours, open for tasks and acting as the task's agent, that you accepted the notice, and that you and the agent may still write to the repository. | | Clone | Gives the task a clone of its own of the task's branch (or the terminal's branch, for a new task), beside your checkout. Your checkout and anything you're doing in it are left alone. | | Run | Runs the CLI headless with the goal, in a jail (below), with no model key or token: it signs itself in with the account you signed in to in the terminal. If the CLI isn't installed yet, Diffinery installs the pinned version first, outside the repository. | | Push | Commits what the agent left, in a jail with no network, and takes the new commits out as a git bundle. Your checkout's credential then pushes them to the task's branch as the agent, and Diffinery opens a pull request, as for any task. The task's clone is then removed. | Only your own click starts a run in your terminal. Nothing else does: not a coordinator, a schedule, a retry, [Best of N](https://diffinery.com/docs/agents/tasks#best-of-n) or a failing check. A task whose last run was in a terminal doesn't start a fix run when its checks fail; run it again yourself. A terminal runs one task at a time, it counts as attached while it does (so it doesn't end for being idle), and ending the terminal stops the task. Tasks that plan first or have more than one attempt run in sandboxes of their own. The task's transcript is kept, as for any task: it is the CLI's output for that run, not what you type in the terminal. Its **Usage** shows the tokens the CLI reported, and Claude Code's cost at API prices, which your subscription covers. See [Usage and cost](https://diffinery.com/docs/agents/tasks#usage-and-cost). ### What a task can reach The agent runs under [bubblewrap](https://github.com/containers/bubblewrap), inside your terminal's sandbox. Diffinery never sees your sign-in or the agent's traffic: the jail is in the sandbox, not in Diffinery. | In the jail | | | --- | --- | | Files | The task's clone (read and write), your checkout's git objects (read only), and copies of the CLI's own sign-in and settings (`~/.claude/.credentials.json` and `~/.claude.json`, or `~/.codex/auth.json` and `~/.codex/config.toml`). The copies are never written back, so nothing the agent writes there reaches your terminal. If the CLI refreshes its sign-in during a task and the provider retires the old refresh token, the terminal's CLI may ask you to sign in again. Nothing else of your home directory, your checkout or its git config (which holds the sandbox's git credential), or `/tmp`. The rest of the system is read only. | | Processes | Its own: it can't see or signal your shell or anything else in the sandbox. No capabilities. | | Network | None of its own. Its only way out is a proxy outside the jail (set as `HTTPS_PROXY`) that connects only to the hosts below, by name, on port 443, and only when the connection's TLS handshake asks for that same host (some of them share addresses with every other site on their CDN). The proxy doesn't decrypt anything. | | Environment | Only what the CLI needs: no sandbox tokens. Web search and fetch are off, and Claude Code is told not to read or edit `~/.claude`, `~/.claude.json`, `~/.codex`, `~/.config` or the checkout's git config. | The hosts: | For | Hosts | | --- | --- | | Claude Code | `api.anthropic.com`, `console.anthropic.com`, `platform.claude.com` | | Codex on a ChatGPT plan | `chatgpt.com`, `auth.openai.com` | | npm and Yarn | `registry.npmjs.org`, `registry.yarnpkg.com` | | PyPI | `pypi.org`, `files.pythonhosted.org` | | Cargo | `index.crates.io`, `static.crates.io` | | RubyGems | `rubygems.org`, `index.rubygems.org` | | Maven Central | `repo1.maven.org`, `repo.maven.apache.org` | | apt | `deb.debian.org`, `security.debian.org`, `archive.ubuntu.com`, `security.ubuntu.com` (also plain HTTP, downloads only) | The registries are there for downloads. PyPI and crates.io publish through other hosts (`upload.pypi.org`, `crates.io`), so a task can't publish there; npm and RubyGems take uploads on the same hosts they serve downloads from (see the risks below). Anything else is refused, and the refusal shows in the task's transcript. Go modules aren't reachable, because the Go module proxy fetches any address it's given. Vendor them (`go mod vendor`). Codex signed in with an API key instead of ChatGPT needs `api.openai.com`, which isn't on the list, so use a ChatGPT sign-in. If the sandbox can't start the jail, the task fails and nothing runs. **Give it tasks only in repositories whose contents you trust.** Anything in the repository the agent reads (a CLAUDE.md, a file, an issue the goal quotes) can steer it, because it runs without asking before each step. The jail limits what a steered agent can do, but not all of it: - **Your sign-in is in the jail.** The CLI needs it there. A shell command the agent runs, such as `cat ~/.claude/.credentials.json`, can read it. The deny rules only cover Claude Code's own file tools, and Codex has no such rules. - **It can send what it read to an allowed host under someone else's account.** For example, the agent could send your sign-in or the repository's code to an AI provider with an attacker's credentials, or publish it to npm or RubyGems with a token planted in the repository. - **The task pushes to this repository.** Anything it read can end up in its branch, its commits or its pull request. A task in its own sandbox runs in the same kind of jail, without the first risk: it has no sign-in, and its model token stays outside the jail (see [What a run can reach](https://diffinery.com/docs/agents/tasks#what-a-run-can-reach)). The notice you accept says this too. ## Who can reach it - **Only you.** Nobody else sees your terminal or connects to it: not a repository admin, not the repository's owner, not a site administrator. The terminal may hold your provider sign-in. - **Only from a browser on this Diffinery.** The connection must come from a page on the instance's own address with your session cookie. A request without that `Origin`, from another site, or with a token is refused. - **Not through the sandbox API.** A terminal's sandbox isn't listed by the [sandbox API or MCP tools](https://diffinery.com/docs/agents/sandboxes), and they can't run commands in it, read its files or close it, whoever asks. ## What the sandbox can do - Git in the sandbox acts as the agent you picked, at most with write access to this one repository. The credential stops working when the terminal ends, and it's checked again every minute: if you or the agent lose write access, the terminal ends. - Your provider sign-in and anything else you type into the shell can be read by any process in the sandbox, including the sandbox's own server and its credentials, which reach only this sandbox. Don't paste secrets you wouldn't keep on that machine. - The sandbox has outbound internet access, as other sandboxes do (only HTTP and HTTPS on instances that set [`EGRESS=web`](https://diffinery.com/docs/self-hosting/configuration#egress)). ## When it ends A terminal ends, and its sandbox stops with everything on its disk, when: | Reason | What the page says | | --- | --- | | You click **End** | You ended it. | | No tab was connected for 30 minutes | Nobody was attached for 30 minutes. | | It ran for 8 hours | It reached the 8-hour limit. | | You or the agent lost write access | You or the agent no longer have write access here. | | The sandbox stopped or didn't start | Its sandbox stopped. / Its sandbox didn't start. | | An administrator switched terminals off | Terminals were switched off on this Diffinery. | Push your work before it ends: nothing in the sandbox is kept. Exiting the shell starts a new one in the same sandbox. ## Limits You can have **2** terminals open in a repository and **5** in all. Opening another one is refused with `429` until you end one. A terminal's sandbox counts toward the [account quotas](https://diffinery.com/docs/reference/limits#account-quotas) of the account that owns the repository, like any agent sandbox: its sandboxes open at once and its hosted minutes for the month. Past either, opening a terminal is refused with `429` and the quota's message. ## Nothing is stored What you type and what the shell prints go only between your browser and the sandbox. Diffinery keeps no copy and doesn't log it: the recent output that redraws the screen lives in the sandbox's memory and is gone when the terminal ends. The [audit log](https://diffinery.com/docs/security/audit-log) records only that a terminal was opened or ended, by whom, in which repository, with which agent and branch and the Claude Code and Codex versions its buttons install, and that you accepted the notice (`terminal.terms`, `terminal.open`, `terminal.task`, `terminal.end`). ## Troubleshooting | What you see | Cause and fix | | --- | --- | | No **Give it a task** box, or `the terminal was opened without agent tasks` | The terminal was opened without **Will run agent tasks**. End it and open another with the box ticked. | | `diffinery: refused a connection to <host>:443 from the task (only the hosts in /docs/agents/terminal#what-a-task-can-reach)` in the transcript | The agent or a tool it ran tried a host outside the [list](#what-a-task-can-reach). Work that needs other hosts belongs in the terminal itself, outside a task. | | `diffinery: refused a connection to <host>:443 from the task: its TLS ClientHello asks for <other host>` (or `names no single server, or hides it (Encrypted Client Hello)`, or `no TLS ClientHello`) | The tunnel opens only when the TLS handshake names the host the tool asked the proxy for. A tool that sends a different server name, none, or Encrypted Client Hello, or that doesn't start TLS within 10 seconds, is refused. Point the tool at the allowed host by its own name and turn off Encrypted Client Hello in it. | | `go: ... dial tcp ...` or another Go module download error | Go modules aren't reachable from a terminal task, or from any other agent run. Commit them with `go mod vendor`. | | The task fails with "The task's jail couldn't start in this terminal's sandbox, so nothing ran." | bubblewrap couldn't start in the sandbox; the transcript has its error. Nothing ran and nothing was pushed. End the terminal, open a new one and run the task again. | | The terminal's Claude Code or Codex asks you to sign in again after a task | The CLI refreshed its sign-in inside the task, on its private copy, and the provider retired the old refresh token. Sign in again in the terminal. | | Codex fails to reach `api.openai.com` in a task | Codex is signed in with an API key, and only ChatGPT sign-ins work in terminal tasks. Run `codex login` with your ChatGPT plan, or use a task with a sandbox of its own. | ## For administrators Terminals are on by default. Set the Worker variable `TERMINALS` to `off` to switch them off: nobody can open one, and the ones that are open end within a minute. See [Configuration](https://diffinery.com/docs/self-hosting/configuration#worker-variables). --- # Agents in organizations > An organization's agent policy and model keys. Whose key pays for agent runs in its repositories, what agents may use there, and a monthly allowance per person. Source: https://diffinery.com/docs/agents/organizations An organization's owners decide how agents work in its repositories, on the organization's **Settings** tab under **Agents**: - whose model key pays: each person's own, or the organization's; - which runtimes and providers agents may use; - how many tasks run at once per repository; - whether web terminals and agent review are allowed; - how many tokens each person may spend on the organization's keys in a month. Nothing changes until an owner changes something. By default people use their own keys, as in [Agent tasks](https://diffinery.com/docs/agents/tasks#model-access), and everything is allowed. A repository owned by a person has no policy. ## The organization's model keys An owner can save an Anthropic, OpenAI or OpenRouter API key for the organization, under **Settings → Agents → Model keys**. - Saving a key asks the owner to accept the provider's terms for the organization. The provider bills the organization's account. The acceptance is recorded with its date and the owner who accepted. - **Only owners** can see or change the organization's keys. Org admins can't, and neither can members. - Saving or deleting a key needs a browser session with a sign-in from the last 2 hours. Diffinery asks you to sign in again if yours is older. No token can do it. - Keys are **write-only**, like personal keys: pages and the API show only the provider, the last four characters and who accepted the terms when. - Every owner gets a security email when a key is added ("An Anthropic API key was added to acme"). - Saves and deletions are in the audit log as `org.model_key.set` and `org.model_key.delete`. A [site administrator](https://diffinery.com/docs/admin/site-administration) can act as an owner, and is recorded as themselves. The organization's key **never enters a sandbox, a container or a terminal**. As with personal keys, a run gets only its own model token. Diffinery's model proxy decrypts the key inside each request it forwards, after checking that the request may use it (see [How your key is protected](https://diffinery.com/docs/agents/tasks#how-your-key-is-protected)). ## Whose key pays | Setting | Who pays | | --- | --- | | **Each person's own keys** (default) | The person who starts the work, with their own key, as anywhere else. | | **The organization's keys, when it has them** | A member's run uses the organization's key when it has every provider the run needs, otherwise the member's own. An outside collaborator always uses their own key. | | **Only the organization's keys** | Personal keys are never used in the organization's repositories. Only members can start tasks, agent reviews and coordinators there. An outside collaborator gets `403 acme pays for agents in its repositories with its own keys, which only its members can use`. | **Only members spend the organization's key.** A member here is a person with the member, admin or owner role. An agent account never counts, even an agent the organization owns: what counts is the person behind the run. - A task's payer is the person who started it, never its agent. A task a coordinator starts is paid like the coordinator's own run. - An agent review's payer is the admin who turned reviews on (see [Agent review](https://diffinery.com/docs/agents/review)). - A coordinator's payer is the person whose coordinator it is (see [Projects](https://diffinery.com/docs/agents/projects)). **A task keeps whose key pays from when it starts.** The REST task shows it as `"paid_by": "organization"` or `"person"`. - Messages, retries and plan approval keep it. Only the person who started the task can do those. - An agent review decides when each review starts, and a coordinator when each run starts. - A task the organization paid for can't be retried by someone who is no longer a member: `only members of acme can run agents on its keys`. **Every model request checks again**, before the key is decrypted. Nothing is cached, so a change applies at the next request. The proxy checks that: 1. the repository still belongs to the organization, so a repository transferred out of it mid-task stops spending its key; 2. the policy still lets the organization's keys pay; 3. the person is still an active member; 4. the provider is still allowed; 5. the organization still has the key; 6. the person is under their monthly allowance. For a personal key in an organization's repository, it checks that the policy doesn't now require the organization's keys, and that the provider is still allowed. When a check fails, the proxy answers `403` with the reason, for example `mia is no longer a member of acme, whose key this task uses`. The task stops with a note saying why, and anything booked for that request is refunded. ## Monthly allowance per person **Monthly tokens per person on the organization's keys** caps how much one person's runs can spend of the organization's keys in a calendar month (UTC). It goes from 10,000 to 10 billion, and is off by default. - Tokens are counted as [run budgets](https://diffinery.com/docs/agents/projects#limits) count them: input, output and cache writes in full, and cache reads as a tenth. - Before a request goes to the provider, the proxy books an estimate against the person's allowance, in one step: a token per 3 bytes of the request, plus the output it allows. The answer then settles the booking to what the provider reported. - A request that would take the person over the allowance is refused with `403` and never reaches the provider: `this month's allowance of 2,000,000 tokens per person on acme's keys is used up`. The task stops. - While an allowance is set, a person has at most 4 model requests on the organization's keys on their way at once, across all their runs. A fifth gets `429` with `retry-after`, and the agent's CLI tries again. - So requests already on their way can take a person past the allowance by at most 4 requests. Each one's output is capped at what it booked; its input can cost more than its estimate when it tokenizes densely or carries images or PDFs, which bill by pixels and pages. - A request still streaming after 15 minutes stops counting toward the 4. - If the agent hangs up while an answer is still streaming, Diffinery keeps reading it for about 30 seconds. An answer that takes longer stays charged at its booking, and its place among the 4 frees after 15 minutes. - An answer that reports no usage, or whose usage can't be read (a streamed line too long to keep, for example), is charged a token per byte sent, plus its output cap. - A request books at most 1,000,000 output tokens, whatever cap it asks for, and is sent with that cap. - Runs on a person's own key don't count. So that a booking holds what a request can use, runs on the organization's key send only what their booking bounds, as [agent reviews](https://diffinery.com/docs/agents/review) do: - A request that sets no output cap is sent with the one it was booked for, 32,000 tokens. - Requests are refused with `403` when they pull in input the request doesn't carry: - context stored at the provider: `previous_response_id`, `conversation`, `prompt` or `item_reference` inputs in the Responses API, and an Anthropic `container`; - images, documents and files given by URL or file id rather than as their content. - So are requests that start a background response (any `background` but `false`), ask for more than one answer (`n`), use an OpenRouter web search model (`:online`) or preset (`@preset/...`), or whose body isn't a JSON object, and routing and paid extras (`models`, `route`, `provider`, `plugins`, `web_search_options`, `speed`, `best_of`, `preset`). Reading back a stored response (`GET /v1/responses/...`) is refused too. Claude Code and Codex don't send any of them in normal use. - Tools that run on the provider (web search, web fetch, code execution, file search, MCP servers, a shell in a hosted environment) are stripped, as in reviews, since each of their steps reads input the request doesn't show. Tools the agent runs itself stay. - The request is sent as Diffinery read it, written out again, and OpenAI's `openai-organization` and `openai-project` headers are dropped: the key's own defaults apply. - A refused request costs nothing, and the task goes on. ## Other policies | Policy | Default | Checked | | --- | --- | --- | | **Allowed runtimes**: Claude Code, Codex, custom command | All | When a task, review, coordinator or terminal task starts, and when review or coordinator settings are saved. | | **Allowed providers**: Anthropic, OpenAI, OpenRouter | All | As above, and on every model request. A custom command gets only the allowed providers' keys. A terminal's task counts as its runtime's provider: Claude Code as Anthropic, Codex as OpenAI. | | **Most tasks at once per repository**, 1 to 10 | 10 | Each project's own "tasks at once" is held to it. More tasks wait in the queue. The project's settings page says when the organization's limit applies. | | **Allow web terminals** | On | When a terminal opens. Turning it off closes no open terminal, but no new ones open. | | **Let terminals run agent tasks** | On | When a terminal opens for tasks, and when a task is given to one. Under **Only the organization's keys**, only members can run tasks in a terminal, though the person's own subscription pays for them. | | **Allow agent review** | On | When review settings are saved and when each review starts. Turning it off stops new reviews; the pull request's check says why. | Saving the policy is recorded in the audit log as `org.agent_policy.update`, with the new values, and `site_admin: true` when a site administrator saved it. A repository transferred into the organization takes its limit on tasks at once, and one transferred out drops it. ## Usage The **Paid with the organization's keys** box on the Settings tab shows owners: - each person's tokens on the organization's keys this month, against the allowance; - the runs the organization's keys paid for in the last 30 days, per repository, person and kind. A person's own **Usage in the last 30 days** (**Settings → Model access**) shows only what they paid for, with their own keys or in their terminal. ## REST API | Endpoint | Does | Who | | --- | --- | --- | | `GET /orgs/{org}/diffinery/agent-policy` | The policy | Owners; any token not limited to repositories | | `PUT /orgs/{org}/diffinery/agent-policy` | Change it. Fields left out stay as they are. | Owners, browser session only | | `GET /orgs/{org}/diffinery/model-keys` | The organization's keys: provider, last four characters, who accepted the terms and when | Owners; any token not limited to repositories | | `PUT /orgs/{org}/diffinery/model-keys/{provider}` | Save a key: `{ "key": "sk-ant-...", "accept_terms": true }` | Owners, browser session with a recent sign-in | | `DELETE /orgs/{org}/diffinery/model-keys/{provider}` | Delete a key | Owners, browser session with a recent sign-in | | `GET /orgs/{org}/diffinery/model-usage?days=30` | The runs its keys paid for (1 to 90 days), and each person's tokens this month | Owners; any token not limited to repositories | A token limited to some repositories gets `403` on all of them. Anyone who isn't a member gets `404`, and a member who isn't an owner gets `403 organization owners only`. ```json { "keys": "either", "runtimes": ["claude-code", "codex", "custom"], "providers": ["anthropic", "openai", "openrouter"], "max_running": 4, "terminals": true, "terminal_tasks": true, "review": true, "member_monthly_tokens": 2000000, "updated_by": "ayse", "updated_at": "2026-10-14T07:02:11.000Z" } ``` `keys` is `personal`, `either` or `organization`. `member_monthly_tokens` is `null` for no limit. ## Deleting the organization Deleting an organization removes its model keys, its policy, its allowance counts and the usage its keys paid for (see [Organizations](https://diffinery.com/docs/accounts/organizations)). Deleting a person's account keeps the runs the organization's keys paid for in its owners' report, under **Deleted account**. --- # Docs for AI agents > How AI agents and tools can fetch these docs as Markdown, llms.txt, llms-full.txt, llms-small.txt, per-section files, OpenAPI and over MCP. Source: https://diffinery.com/docs/agents/docs-for-agents These docs are written to be read by AI agents as well as people. Every page is available as plain Markdown, the whole set is available as one file, the REST API is described in OpenAPI, and agents connected over MCP can search and read the docs with tools. The URLs below need no account; the MCP tools need a Diffinery token, like every MCP call. ## Formats | What | URL | Content type | | --- | --- | --- | | Index of every page, the site's and the docs' (the [llms.txt](https://llmstxt.org) convention) | `https://diffinery.com/llms.txt` | `text/plain` | | Every page, the site's and the docs', in one Markdown file | `https://diffinery.com/llms-full.txt` | `text/plain` | | The site's pages (what Diffinery is, comparisons, performance, FAQ) and the essential docs pages (overview, quickstart, concepts, the guides, compatibility with GitHub Actions and connecting AI apps) in one smaller file | `https://diffinery.com/llms-small.txt` | `text/plain` | | One docs section: its index, and all its pages in one file | `https://diffinery.com/docs/<section>/llms.txt` and `/docs/<section>/llms-full.txt`, for example `/docs/actions/llms-full.txt` | `text/plain` | | One page as Markdown | `https://diffinery.com/docs/<page>.md`, for example `/docs/actions/workflow-syntax.md` | `text/markdown` | | The overview as Markdown | `https://diffinery.com/docs/index.md` | `text/markdown` | | The site's other pages as Markdown: the home page, product pages and comparisons with other products | `https://diffinery.com/index.md`, and `.md` added to any page, for example `/compare/github.md` | `text/markdown` | | REST API as OpenAPI 3.1 | `https://diffinery.com/docs/api/openapi.json` | `application/json` | | Search index used by the docs search box | `https://diffinery.com/docs/search.json` | `application/json` | The section names are the docs sections in lowercase with hyphens: `get-started`, `guides`, `connect-ai-apps`, `accounts`, `repositories`, `collaboration`, `actions`, `packages`, `agents-and-ai`, `security`, `administration` and `reference`. `llms.txt` links each section's file under its heading. `/docs/llms.txt` and `/docs/llms-full.txt` redirect to the root files. These responses send `Access-Control-Allow-Origin: *`, so browser-based tools can fetch them too. A `.md` URL for a page that doesn't exist returns `404` with a short Markdown body that points to `llms.txt`. ## Markdown pages Each Markdown page starts with the page title as a level-1 heading, its one-line description as a blockquote and its source URL, followed by the page body: ```text # Merge queue > Merge pull requests in order, each tested on top of the base branch and the pull requests ahead of it, with merge_group workflows and required checks. Source: https://diffinery.com/docs/collaboration/merge-queue ... ``` Links inside Markdown pages are absolute (`https://diffinery.com/docs/...`), so they work outside the site. Add `.md` to any of them to fetch that page as Markdown too. ### Content negotiation A request for an HTML docs URL with `Accept: text/markdown` gets the Markdown version instead, so an agent can fetch the URL it was given: ```sh curl -H "Accept: text/markdown" https://diffinery.com/docs/collaboration/pull-requests ``` HTML pages also point at their Markdown version with `<link rel="alternate" type="text/markdown">` in the page head and a `Link` response header. ### Copy page Every page in the browser has **View as Markdown** and **Copy page** buttons. **Copy page** puts the page's Markdown on your clipboard, ready to paste into a chat with an AI assistant. ## Which format to use - **Answering one question:** fetch [llms.txt](https://diffinery.com/llms.txt), pick the page from its one-line descriptions, then fetch that page's `.md` URL. This keeps the context small. - **Explaining or comparing Diffinery:** load [llms-small.txt](https://diffinery.com/llms-small.txt). It holds the product, comparison, FAQ and company pages with their dates and sources, plus the essential docs, in a fraction of the size of `llms-full.txt`. - **Working on one area for a while (for example, porting CI):** load that section's file, such as [the Actions section](https://diffinery.com/docs/actions/llms-full.txt). - **Working on Diffinery broadly:** load [llms-full.txt](https://diffinery.com/llms-full.txt) once. It holds every page in reading order, separated by `---`, including the generated REST API reference, so it's large. - **Calling the REST API:** read the [OpenAPI document](https://diffinery.com/docs/api/openapi.json) or the [REST API reference](https://diffinery.com/docs/api). Both are generated from the server's router, so every endpoint that exists is listed. Use the [REST API guide](https://diffinery.com/docs/reference/rest-api) for authentication, errors and pagination. - **Using MCP:** the [MCP tool reference](https://diffinery.com/docs/reference/mcp-tools) is generated from the server's tool list and matches `tools/list` exactly. ## Searching and reading docs over MCP An agent connected to the Diffinery MCP server (`https://diffinery.com/mcp`, with `Authorization: Bearer dfy_...`) has two read-only tools for the docs. They work with a token of any scope: | Tool | Arguments | Returns | | --- | --- | --- | | `search_docs` | `query` | Up to 8 matching pages, docs and site pages (comparisons, performance, FAQ) alike, each with `page`, `title`, `section`, the matching `heading`, `url`, `markdown_url` and a `snippet`. | | `read_docs` | `page`: a slug like `actions/workflow-syntax`, `index`, a docs URL (a `.md` suffix or `#anchor` is ignored), or a site page's path or URL like `/compare/github` | `page`, `title` and `markdown`: the page in the same format as its `.md` URL. An unknown page returns an error that lists every slug and site page. | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"workflow_dispatch inputs"}}} ``` Every term in the query must appear in a page for it to match. Title matches rank highest, then section headings, then body text. See [MCP](https://diffinery.com/docs/agents/mcp) for connecting a client. ## Pointing a coding agent at the docs Add a line like this to your agent's instructions (`CLAUDE.md`, `AGENTS.md`, `.cursorrules` or similar) in repositories hosted on Diffinery: ```text This repository is hosted on Diffinery (https://diffinery.com), a GitHub-compatible forge. Docs index: https://diffinery.com/llms.txt (append .md to any docs URL for Markdown). CI uses GitHub Actions syntax; check https://diffinery.com/docs/actions/compatibility.md before using a GitHub-only feature. gh works after `diffinery auth setup-gh`; the diffinery CLI and the MCP server at https://diffinery.com/mcp work too. ``` ## Staying current The docs ship with the server: each deploy of Diffinery serves the docs for exactly that version, and the API, CLI and MCP references are generated from the code (the API and MCP references at runtime, the CLI reference when the server is built), so they can't fall behind it. The Markdown, `llms.txt`, `llms-full.txt`, `llms-small.txt`, per-section, OpenAPI and search index responses are cached for at most five minutes. CI checks on every change that each page builds, that every link and anchor resolves, and that every endpoint and tool appears in the references. --- # Security overview > Diffinery's security model: sign-in, sessions, tokens, agent guardrails, branch and push protection, secrets and key rotation, Actions, webhooks, the Markdown image proxy and auditing. Source: https://diffinery.com/docs/security/overview This page explains how Diffinery protects accounts, code and secrets, from how people sign in to how workflow secrets are encrypted and how every sensitive action is recorded. Read it to understand the model as a whole; each section links to a page with the details. ## At a glance | Area | What Diffinery does | |---|---| | Sign-in | Passkeys only (WebAuthn). No passwords. | | Browser sessions | Random session cookie, stored hashed, `HttpOnly`, `Secure`, `SameSite=Lax`, 30 days sliding | | Cross-site requests | Double-submit CSRF token on every form, plus refusal of cross-site state-changing requests | | Headers | HSTS, nonce-based Content Security Policy, `X-Frame-Options: DENY`, `nosniff` | | Images in Markdown | Images from other sites are served through Diffinery's own image proxy, so those sites never see who views a page | | Anonymous access | Read-only: anonymous callers can read public repositories but every change gets `401` | | Machines and agents | Tokens (`dfy_...`), stored hashed, with scopes, repository restrictions and expiry; `read` tokens never write | | Agents | Accounts owned by a person or organization, with server-enforced limits | | Branches | Branch protection rules, enforced for git, the API, MCP and the web UI | | Required checks | Checks reported by agents never satisfy a required check; a check can be bound to one reporter | | Pushes and commits | Credential scanning on every push and on every commit made without Git (web editor, REST API, MCP); blocked writes leave nothing behind | | File names | Writes through the API, MCP and the web editor refuse `.git`, `..` and other unsafe tree entry names | | Archived repositories | Every write refused, except unarchiving, deleting, watching and linking packages | | Actions secrets | Envelope encryption with a per-repository, per-environment or per-organization key derived from `MASTER_KEY` | | Workflow logs | Secret values are replaced with `***` | | Workflow credentials | A job token valid for one job, limited by the workflow's `permissions:` (read-only by default for new repositories), and short-lived OIDC id tokens | | Webhooks | HTTPS only, signed with HMAC-SHA256, failed deliveries retried | | Accountability | Append-only audit log and security emails | | Isolation | One Durable Object per repository | ## Signing in with passkeys People sign in to the web UI with a passkey: a WebAuthn credential kept by their device, password manager or security key. Diffinery has no passwords to guess, reuse or phish. - Passkeys are bound to the site's hostname (`diffinery.com`), so a look-alike site cannot use them. - Diffinery requires user verification (a fingerprint, face or device PIN) and discoverable credentials, and does not ask for attestation. - Each sign-in uses a fresh challenge that expires after 5 minutes, can be used once, and is tied to the browser that requested it by a `__Host-dfy_webauthn` cookie (`HttpOnly`, `Secure`, `SameSite=Strict`). - New people create their first passkey from an invite link, which expires after 14 days and works once. - If someone loses all their passkeys, a site admin can create a one-time sign-in link that lets them add a new passkey. It expires after 24 hours and works once. Its token, like an invite's email proof, travels in the URL fragment (`/signin#<token>`), which browsers never send to the server, so it stays out of request and proxy logs. An instance can also offer email recovery through Cloudflare Access at `/auth/access`; Access guards only that path. - Agents cannot sign in to the web UI. Only people get browser sessions. Manage your passkeys under **Settings**, **Passkeys and sessions**. Removing a passkey also signs out every other browser that signed in with it. See [Passkeys and sign-in](https://diffinery.com/docs/accounts/passkeys-and-sign-in). ## Sessions After a passkey sign-in, Diffinery sets a session cookie: | Property | Value | |---|---| | Name | `__Host-dfy_session` (the `__Host-` prefix pins it to this origin, `Path=/`, no `Domain`) | | Flags | `HttpOnly`, `Secure`, `SameSite=Lax` | | Lifetime | 30 days, extended by another 30 days on the first use of each day, and never more than 90 days after the sign-in | | Storage | Only a SHA-256 hash of the session token is stored | Signing in again replaces the browser's previous session. **Settings**, **Passkeys and sessions** lists your **Signed-in browsers**. **Sign out** next to one ends that session, and **Sign out everywhere else** ends every session except the current one. A disabled account cannot use its sessions or tokens. ### Confirming it's you Some actions need a sign-in from the last 2 hours, much like GitHub's sudo mode: - adding or removing a passkey; - creating a personal access token, an agent, or an agent token; - changing your username; - issuing a sign-in link (site administrators); - deleting or transferring a repository, or purging its unreachable objects; - deleting an organization; - exporting your account, downloading the export, or deleting your account; - deleting someone's account, or making someone a site administrator or removing them (site administrators). When a browser's sign-in is older than that, Diffinery asks you to **Confirm it's you** with your passkey (or by email recovery, where that is set up), then takes you back to the page. Then submit the form again. The same calls through the API with a browser session get `403 Confirm it's you: sign in with your passkey again to do this.`, with a `confirm` URL in the body. So a session left open on a shared computer, or a stolen session cookie, can't add its own passkey, mint a lasting token or delete your work. Tokens aren't affected: their scopes bound what they can do. ## Protection against cross-site requests Browser sessions ride on cookies, so Diffinery applies two independent defenses against cross-site request forgery. **Cross-site refusal.** Every request that changes state (any method other than `GET`, `HEAD` and `OPTIONS`) and carries no `Authorization` credential is checked first: - If the browser sends `Sec-Fetch-Site` and its value is not `same-origin` or `none`, the request is refused. - If there is no `Sec-Fetch-Site` header but there is an `Origin` header that differs from the site's own origin, the request is refused. The response is `403` with `{"message": "cross-site request refused"}`. Requests authenticated with a token (git, the CLI, MCP clients, runners) are exempt because they carry no ambient credentials. **Double-submit CSRF token.** Every page sets a random `dfy_csrf` cookie (`HttpOnly`, `Secure`, `SameSite=Strict`, 30 days), and every form carries the same value in a hidden `_csrf` field. A form submission is accepted only when: - its `Origin`, if present, is the site's own origin (otherwise `403` with `cross-origin form submission rejected`), - it is a form submission (`application/x-www-form-urlencoded` or `multipart/form-data`; otherwise `415` with `expected a form submission`), and - the field matches the cookie, compared in constant time (otherwise `403` with `your session token expired or is missing; reload the page and try again`). The passkey endpoints take JSON, which forces a CORS preflight that other sites cannot pass. ## Security headers and content security policy Every response carries: | Header | Value | |---|---| | `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` | | `X-Content-Type-Options` | `nosniff` | | `Referrer-Policy` | `strict-origin-when-cross-origin` | | `X-Frame-Options` | `DENY` (unless a response sets its own) | | `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Web pages also get `Cache-Control: private, no-store` and a Content Security Policy with a new random nonce for each response: ```text default-src 'self'; script-src 'nonce-...'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' wss://diffinery.com ws://diffinery.com; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none' ``` Only scripts that carry the page's nonce run, so injected markup cannot execute scripts. Raw file views and downloadable logs are served with `Content-Security-Policy: sandbox`, so a file that contains HTML cannot run scripts in Diffinery's origin. Pages load images only from Diffinery itself. Images from other sites in Markdown come through Diffinery's image proxy (see [Images from other sites](#images-from-other-sites)). ## Images from other sites An image in Markdown (an issue, pull request, comment, review, README, Markdown file, release notes, job summary or agent task) that points at another site would tell that site the IP address, browser and time of everyone who views the page, even in a private repository. So Diffinery fetches those images itself and serves them from its own address, as GitHub does: - The page shows the image from `/-/camo/<signature>/<encoded URL>`. Only URLs Diffinery signed while rendering are fetched, so the proxy can't be used to fetch anything else. - Diffinery fetches the image over HTTPS without your cookies, IP address or browser details. An `http://` image is fetched over `https://`, as browsers already do on an HTTPS page. - Only public hosts are fetched: never Diffinery itself, `localhost` or private, link-local or internal addresses, also after a redirect (at most 4 are followed). - The answer must be a PNG, JPEG, GIF, WebP, AVIF or SVG image of at most 10 MB, within 10 seconds. SVG images are served with a sandboxing Content Security Policy, so scripts in them can't run. - Images are cached for as long as the other site allows (between 5 minutes and 7 days; a day if it doesn't say), so the other site sees only an occasional request from Diffinery. An image that can't be fetched shows as broken. Links in Markdown still go to their original addresses, and images from the repository itself (relative paths) or from Diffinery load directly. ## Tokens and scopes Everything that is not a browser (git, the [CLI](https://diffinery.com/docs/reference/cli), scripts, MCP clients, agents) authenticates with a token in the `Authorization` header (`Bearer`, `token` or HTTP Basic with the token as the password). | Property | Detail | |---|---| | Format | `dfy_` followed by 40 random characters | | Storage | Only a SHA-256 hash is stored; the token is shown once, at creation | | Expiry | 1 to 366 days; 90 by default (30 for agent tokens created in the web UI) | | Scope | `read`, `write` or `admin` | | Repositories | Optionally restricted to a list of repositories | | Revocation | Immediate; disabling an account revokes all of its tokens | A token never grants more than its account has. The scope caps the account's role on each repository: | Scope | Highest role it allows | |---|---| | `read` | read: clone and view | | `write` | maintain: push, issues, pull requests, runs, and moving tags | | `admin` | admin: everything the account can do, including settings | A `read` token can't change anything: every repository request other than `GET` or `HEAD` gets `403 this token needs the write scope`, even for changes the `read` role allows, such as opening an issue or commenting. `git fetch` and Git LFS downloads still work with it. The scope is checked only after Diffinery has found a repository the caller can see, so a `read` token's write to a private repository it can't see gets `404 repository not found`, not a scope error that would reveal the repository. Account-level changes check the scope directly and refuse tokens restricted to repositories (`403 this token is limited to repositories and can't change account settings`). Creating repositories needs `write`; creating organizations, inviting people, managing organization secrets, disabling agents and revoking tokens need `admin`. No token can create a token or an agent: those need a browser session, so a leaked token can't mint another one. Job and sandbox tokens can't start sandboxes either, and a sandbox's credentials last 24 hours at most, and a new token can only be restricted to repositories the account can see (any other name gets `422 unknown repository <name>`). Scopes sent to the API are checked: an empty list or an unknown name is refused with `422 scopes must be a non-empty list of: read, write, admin`, and leaving them out gives `["write"]`. Every new token triggers a security email, and for tokens valid longer than 8 days you get a reminder about 7 days before the token expires. See [Tokens](https://diffinery.com/docs/accounts/tokens) and [Permissions](https://diffinery.com/docs/accounts/permissions). ## Repository access Each request to a repository resolves the caller's role (read, triage, write, maintain or admin) from ownership, organization membership and collaborator grants, then applies the token's scope and repository restriction. Roles are computed on every request, so removing someone from an organization or a repository takes effect immediately, including for tokens they already have. Private repositories return `404 repository not found` to anyone without access, so their existence is not revealed. See [Permissions](https://diffinery.com/docs/accounts/permissions). ### Anonymous callers only read Without a token or a session, a caller gets the `read` role on public repositories and nothing on private ones. A signed-out caller gets the same answer for a private repository as for one that doesn't exist: `401 authentication required` from Git, Git LFS and the web UI, and `404` from the REST API. As on GitHub, anonymous callers can only read: any request that changes a repository (any method other than `GET` or `HEAD`), such as opening an issue, commenting, reacting or watching, is refused with `401 authentication required`, even on a public repository. Anonymous `git clone`, `git fetch` and Git LFS downloads of public repositories keep working, although they use `POST`, because they only read. An anonymous `git push` gets `401` with a `WWW-Authenticate: Basic` challenge, even to a public repository, so git asks for (or its credential helper supplies) your token and retries. Unknown paths under `/api/` answer with a JSON `404` (`{"message": "Not Found", "documentation_url": "..."}`), not the web UI's error page. ### Archived repositories An archived repository is read-only, for everyone including admins, as on GitHub: - `git push` is refused, and scheduled workflows (`on: schedule`) stop running. - The web UI shows code, issues and pull requests without any forms to change them. A conversation shows `This repository is archived; the conversation is read-only.` - Every write through the web UI, the REST API and MCP is refused with `403` and `repository is archived (read-only)`, whatever role the endpoint needs: issues, comments, reviews, pull requests, merges, labels, reactions, releases, statuses, check runs, deployments, git data, and the admin settings (collaborators, branch protection, webhooks, secrets and variables). Reading still works. - Only these still work: unarchiving, deleting the repository, watching or unwatching it, and linking a container image to it. See [Archive a repository](https://diffinery.com/docs/repositories/overview#archive-a-repository). A repository admin archives or unarchives a repository under **Settings**, **General**, with the **Archive** (or **Unarchive**) button and a confirmation. Both are recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `repo.archive` and `repo.unarchive`. ## Agent guardrails Agents are accounts that act on behalf of a person or an organization. They use tokens only, and Diffinery enforces these limits on the server, for git, the REST API, MCP and every other path: - An agent's role is capped by its **Maximum role** (default write) and never exceeds its owner's role. - Agents cannot push to the default branch or to protected branches, unless a branch protection rule allows it (`agentsMayPush`). - Agents cannot merge pull requests into the default branch or protected branches, unless a rule allows it (`agentsMayMerge`). - Pull requests opened by agents need at least one approval from a person. Only people's approvals count toward required approvals; approvals from agents and workflow tokens never do. - Agents cannot change branch protection, delete repositories, create agents, or bypass push protection. - Whatever their role, agents cannot create, update or delete Actions secrets (repository, environment or organization), and cannot create, change or delete Actions variables (repository or environment). They get `403 agents cannot manage secrets` or `403 agents cannot manage variables`. - Agents never bypass branch protection when merging, even with the admin role. - Agents cannot create, change or delete [environments](https://diffinery.com/docs/actions/environments) or their deployment branch policies, and cannot set or delete environment secrets (`only repository admins (people, not agents) can manage environments`). - Agents cannot dismiss pull request reviews (`agents can't dismiss reviews`). - Commit statuses and check runs that agents report are shown, but never satisfy a required check in branch protection or in a merge queue. A deployment created without `required_contexts` also ignores contexts reported by agents. See [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). - Runs of code an agent wrote get no repository or organization secrets unless a repository admin who is a person allows it (`agent_runs_use_secrets`). Workflow runs started by agents receive an environment's secrets only when the environment is limited to protected branches. Tags created or moved by agents never match an environment's tag patterns. - A workflow run whose code came from an agent (an agent triggered it, pushed the branch last, or opened the pull request, every re-run of such a run, whoever starts it, and every `workflow_run` run that follows it) acts as an agent: its job token is held to these same rules, so it can't push or merge agent-written code into the default branch or a protected branch. - Commits that agents create through the API or MCP (including commits made with the git data API and the merge commit of `update-branch`) carry a `diffinery-agent` header naming the agent and who it acts for. - The audit log records who an agent acted for, and security emails about an agent (blocked pushes, new tokens) go to its owner. See [Agents](https://diffinery.com/docs/agents/overview). ## Branch protection Branch protection rules decide how matching branches may change: required pull requests, approvals and checks, stale approval dismissal, linear history, force pushes, deletion, agent permissions and merge queues. When several rules match a branch, the rule that names the branch exactly wins; otherwise the oldest matching pattern applies. With linear history required, a merge commit is refused (`405`); squash or rebase instead. The default branch can never be deleted. Repository admins can bypass rules unless the rule sets `enforceAdmins`. A merge never bypasses silently: an admin who is a person has to ask for it (**Merge without waiting for requirements (bypass)** in the web UI, `"bypass_rules": true` on `PUT /repos/{owner}/{repo}/pulls/{number}/merge`, or `diffinery pr merge --admin`), only when the pull request has no conflicts and isn't a draft, and each bypassed merge is recorded in the audit log as `pull_request.merge_bypassed` with the unmet requirements. Each bypass covers only what it names: `"bypass_rules": true` skips unmet rules (including a merge commit on a linear-history branch), and `"bypass_merge_queue": true` skips only a required merge queue; bypassing linear history or the queue is audited too. See [Branch protection](https://diffinery.com/docs/repositories/branch-protection). Required checks are guarded against spoofing. A required check matches check runs and commit statuses by name, and Diffinery decides which reports count: - When Diffinery Actions reported a check with that name, only the Actions result counts, so another account cannot post a passing status with the same name. - A rule can bind a check to one reporter with `requiredCheckSources` (for example `{ "ci/external": "ci-bot" }`); then only that account's reports count. - Reports from agents never count. - Only the account that created a check run can update it, and check runs from Diffinery Actions cannot be changed through the API at all. ## Push protection Every `git push` is scanned for credentials (AWS, GitHub, Diffinery, Slack, Stripe live, Google, Anthropic and OpenAI keys, Diffinery's own job, sandbox and model tokens, and private keys) before anything is stored, in file contents (UTF-8 and UTF-16 text) and in commit and tag messages. A push that contains one is rejected as a whole and nothing from it is kept. A file can opt out with a `diffinery:allow-secret` marker (a credential let through that way is still recorded in the audit log), and a repository admin who is a person can push anyway with `git push -o allow-secrets=<reason>`, which is audited and emailed to the repository's admins. The same scan runs on every commit made without a Git client: the web editor, `POST /repos/{owner}/{repo}/diffinery/commits`, the contents API (`PUT /repos/{owner}/{repo}/contents/{path}`) and the MCP `commit_files` tool, and on the git data API (blobs, trees with inline `content`, and commit and tag messages). A match is refused with `422` and recorded as `api.blocked_secret` against the caller. Only the commits API has a bypass: a repository admin who is a person sends a reason in `allow_secrets`, which is recorded as `api.secret_bypassed` and emailed to the repository's admins. See [Push protection](https://diffinery.com/docs/security/push-protection). ## Tree entry names Every write that builds a tree on the server refuses file and directory names that could be dangerous in a checkout. This covers commits made through the web editor, the contents API, `POST /repos/{owner}/{repo}/diffinery/commits`, the MCP `commit_files` tool and `POST /repos/{owner}/{repo}/git/trees`. A path is refused when any of its segments: - is `.` or `..`, - is `.git` in any letter case, or a spelling that Windows or macOS treat as `.git`: with trailing dots or spaces (`.git.`, `.git `), the short name `git~1`, or with invisible characters that macOS ignores (such as zero-width joiners), - contains `\` or a NUL character, - is longer than 255 characters, or when the whole path is longer than 4,096 characters. The commit APIs answer `422` with `invalid path "<path>": <reason>` (for example `invalid path ".git/config": '.git' is reserved`), and the git data API with `invalid tree path ...`. Git's own `fsck` rejects the same names. Downloaded `zipball` and `tarball` archives leave such entries, and everything under them, out. Pushes made with git are checked the way GitHub checks them with git's `fsck`. A push is refused as a whole, and nothing from it is kept, when a tree in it has an entry that is empty, `.`, `..` or a spelling of `.git`, contains `/`, or appears twice; when `.gitmodules` (in any spelling) is a symbolic link; or when a `.gitmodules` file names a submodule with a `..` part, or gives a `url` or `path` that starts with `-`, which older git clients run as an option. Git prints `remote: Push refused: <reason>`. Names with `\` are allowed in pushes, as in git. ## Secrets encryption Actions secrets (repository, environment and organization) are encrypted at rest with envelope encryption: 1. The instance has one master key, `MASTER_KEY`, a base64-encoded secret configured on the Worker. 2. For each scope (a repository, `repo:<id>`, an environment, `repo:<id>:env:<name>`, or an organization, `org:<id>`), Diffinery derives a separate 256-bit AES-GCM key from the master key with HKDF-SHA-256. 3. Each secret value is encrypted with its scope's key and a random 96-bit IV. The scope is also bound in as additional authenticated data, so a ciphertext copied from one repository into another fails to decrypt. 4. Only the ciphertext is stored, with the ID of the master key that encrypted it. Values are decrypted inside the repository's Durable Object only when a workflow run needs them. 5. The master key can be rotated: while the replaced key is configured as `MASTER_KEY_PREV`, secrets encrypted with it still decrypt, and everything new is encrypted with the new key. See [Rotating MASTER_KEY](https://diffinery.com/docs/self-hosting/configuration#rotating-master_key). Secret values are never returned by the API or shown in the web UI after they are saved; you can only replace or delete them. Only accounts with the admin role (organization owners and admins for organization secrets) can manage secrets, and agents can neither set nor delete them. Without `MASTER_KEY`, secrets cannot be saved. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables) and, for operators, [Configuration](https://diffinery.com/docs/self-hosting/configuration). Environment secrets have extra limits: - They are never given to runs of pull request refs (`refs/pull/*`). - An environment that has no deployment branch policy gets a protected-branches-only policy (`protected_branches: true`) when its first secret is added. - Runs started by agents get them only when the environment allows protected branches only. See [Environments](https://diffinery.com/docs/actions/environments). ## Secret masking in logs The runner replaces every secret value with `***` in job logs, step names, job summaries and failure messages: - A job's runner receives only the secrets the job names (`secrets.NAME`), as on GitHub, so a compromised step can't read the others. - All secrets the job receives are masked, including the job's `GITHUB_TOKEN`. - A multi-line secret is also masked line by line. - Values shorter than 3 characters are not masked, to keep logs readable. - `::add-mask::<value>` masks additional values, as on GitHub. - A job output whose value contains a secret is dropped with the warning `Skip output '<name>' since it may contain secret.` Masking is a safety net: a workflow that transforms a secret (for example, base64-encodes it) can still print it. See [Secrets and variables](https://diffinery.com/docs/actions/secrets-and-variables). ## Job tokens Each workflow job gets its own token, available as `secrets.GITHUB_TOKEN` and `DIFFINERY_TOKEN`: - It is issued for one job of one repository and stops working as soon as the job completes. Diffinery stores only its hash. - It acts as `diffinery-actions[bot]` with the job's effective `permissions:` (the job's, else the workflow's, else the repository default). New repositories default to GitHub's restricted set, `contents` and `packages` read; repositories created before the setting existed default to write. Only a repository admin who is a person can change the default, with `PUT /repos/{owner}/{repo}/actions/permissions/workflow`, and each change is audited as `repo.actions_workflow_settings`. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions). - Each API endpoint needs its permission; without it the request gets `403 Resource not accessible by integration`. Reads of public repositories are always allowed, and the token can read other public repositories but no other private one. - Its role never exceeds write, so branch protection applies. It can't change repository settings, secrets, variables, webhooks, runners or branch protection, and it never creates, changes or deletes workflow files (`.github/workflows/`, `.diffinery/workflows/`), by push, API commit or merge. - It can't open, approve or request changes on pull requests unless an admin allows it (off by default), and its approvals never count toward required approvals. - A run whose code came from an agent acts as an agent (see [Agent guardrails](#agent-guardrails)). - For the [container registry](https://diffinery.com/docs/packages/container-registry), it can pull and push packages of its own repository according to the workflow's `packages` permission. [Sandboxes](https://diffinery.com/docs/agents/sandboxes) get a similar token bound to one sandbox, which acts as the person or agent that created it with at most the write role, can also read public repositories, and stops working when the sandbox is closed. ## OIDC id tokens Jobs that declare `permissions: id-token: write` can request a short-lived OIDC id token to authenticate to cloud providers without stored credentials. Tokens are signed with ES256 by a key configured on the instance, expire after 10 minutes, use the instance URL as issuer (and as the default audience, unless the job asks for another one), and have GitHub's subject forms (`repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for pull request runs, `repo:<owner>/<repo>:ref:<ref>` otherwise), with numeric `repository_id` and `repository_owner_id` claims that a reused name doesn't inherit. The public keys are at `https://diffinery.com/.well-known/jwks`. A job without the permission gets `403` with `this job lacks permissions: id-token: write`. See [OIDC](https://diffinery.com/docs/actions/oidc). ## Webhook signatures Webhook payload URLs must use HTTPS and name a public host: `localhost`, private, loopback and link-local addresses, and Diffinery's own host are refused when the webhook is created and checked again on every delivery, so a webhook can't reach the internal network or call Diffinery itself. A repository can have at most 20 webhooks. Every delivery is signed with an HMAC-SHA256 of the body using the webhook's secret, sent as `X-Hub-Signature-256: sha256=<hex>`, the same scheme as GitHub. Diffinery does not follow redirects from your endpoint and gives it 10 seconds to respond. A delivery that fails with a network error or timeout, `408`, `429` or a `5xx` status is retried up to five times, after 1 minute, 5 minutes, 30 minutes, 1 hour and 3 hours, with the same `X-GitHub-Delivery` ID, so your endpoint can deduplicate. Retries stop when the webhook is deleted. See [Webhooks](https://diffinery.com/docs/repositories/webhooks). ## Audit log Security-relevant actions go to an append-only audit log: sign-ins, passkey and token changes, agent and organization changes, profile pictures, repository settings (collaborators, branch protection, secrets, variables, webhooks, topics, workflow permissions, whether changed on the web or through the API), organization secrets, environment, environment secret and environment variable changes, cache and artifact deletions, pushes, force pushes and ref deletions, merges, admin merges that bypass branch protection or the merge queue, push protection decisions (for pushes and for commits made without Git), and package changes. Each entry records the actor, who an agent acted for, the action, the target and details. Site admins view it under **Settings**, **Audit log**. See [Audit log](https://diffinery.com/docs/security/audit-log). ## Security emails Diffinery emails you about security-relevant events on your account. These emails cannot be turned off in your notification settings: | Email | When | |---|---| | New sign-in to your account | A passkey sign-in from a device not seen before, or any recovery sign-in | | A passkey was added / removed | A passkey is added to or removed from your account | | A personal access token was added / A token was created for the agent ... | A token is created for you, or for an agent you own (for an organization's agent, its owners and admins get it) | | Your token expires on ... | About 7 days before a token expires | | A new agent was added | You create an agent | | Your push was blocked: possible secrets | Push protection blocked your push (or your agent's) | | Push protection was bypassed | An admin pushed or committed past push protection in a repository you administer | | Your account was disabled / enabled | A site admin changes your account's status | See [Notifications](https://diffinery.com/docs/accounts/notifications). ## Data isolation - **One Durable Object per repository.** Each repository's refs, Git objects, issues, pull requests, reviews, checks, workflow runs, Actions secrets, webhooks and protection rules live in that repository's own Durable Object with its own SQLite storage. It is the only writer for that repository, which also makes ref updates and merges strongly consistent. - **Authorization before routing.** The Worker resolves the caller and checks their role before a request reaches a repository's Durable Object. - **Scoped credentials.** Job and sandbox tokens name their repository and are verified by that repository's Durable Object, so they cannot change another repository or read another private one (like anyone, they can read public repositories). Secret encryption keys are derived per repository and per organization. - **Global data** (accounts, organizations, organization secrets, tokens, invites, notifications and the audit log) lives in a separate D1 database. See [Architecture](https://diffinery.com/docs/self-hosting/architecture). ## Reporting a vulnerability Found a security problem? Report it privately with the [report form](https://diffinery.com/security/report), the first contact in [`/.well-known/security.txt`](https://diffinery.com/.well-known/security.txt). [Reporting a vulnerability](https://diffinery.com/docs/security/disclosure) explains what is in scope, the safe harbour for good-faith research and the response times we aim for. --- # Push protection > How Diffinery scans git pushes, imported history, web editor commits and API and MCP writes for leaked credentials, and how to opt out or bypass. Source: https://diffinery.com/docs/security/push-protection Push protection scans the files in every `git push` for credentials such as cloud keys, API tokens and private keys, and rejects the whole push if it finds one. The same scan runs on every commit made without a Git client: the web editor, `POST /repos/{owner}/{repo}/diffinery/commits`, the contents API and the MCP `commit_files` tool, and on files written through the git data API. It is always on for every repository: there is nothing to enable, and nothing from a blocked push or write is stored. History brought in by an [import from GitHub](https://diffinery.com/docs/migrate-from-github) is scanned too (see [Imported history](#imported-history)). ## How it works When you run `git push`, Diffinery receives your packfile and checks it before it writes anything: 1. It unpacks the packfile in memory. No object is written to the repository yet. 2. It scans the new file contents (blobs) in the pack against a fixed list of credential patterns. 3. If nothing matches, it stores the objects and then applies the usual checks (permissions and [branch protection](https://diffinery.com/docs/repositories/branch-protection)) to each ref you pushed. 4. If anything matches, it rejects every ref in the push, prints what it found, records the attempt in the [audit log](https://diffinery.com/docs/security/audit-log) and emails you. Nothing from the pack is kept, so a retry starts from a clean state, like git's own object quarantine. A push is all or nothing: if one commit on one branch contains a secret, every branch and tag in that push is rejected, not only the affected one. ## What is detected Diffinery looks for these credential types. Each name below is exactly what appears in the push output, the audit log and the email. | Name | What matches | |---|---| | `AWS access key` | `AKIA` or `ASIA` followed by 16 uppercase letters or digits | | `GitHub token` | `ghp_`, `gho_`, `ghu_`, `ghs_` or `ghr_` followed by 36 letters or digits, or `github_pat_` followed by 82 letters, digits or underscores | | `Diffinery token` | `dfy_` followed by at least 32 letters, digits, `_` or `-` (a Diffinery personal or agent token) | | `Diffinery job, sandbox or model token` | `djt_`, `dst_` or `dmt_`, a repository id, `_`, and at least 32 more letters, digits, `_` or `-` (a workflow job's `GITHUB_TOKEN`, an agent sandbox's token, or an agent task's model token) | | `Slack token` | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-` or `xoxs-` followed by at least 10 letters, digits or `-` | | `Stripe live key` | `sk_live_` or `rk_live_` followed by at least 20 letters or digits | | `Google API key` | `AIza` followed by 35 letters, digits, `_` or `-` | | `Anthropic API key` | `sk-ant-` followed by at least 20 letters, digits, `_` or `-` | | `OpenAI API key` | `sk-` or `sk-proj-` followed by at least 40 letters, digits, `_` or `-` | | `Private key` | A PEM header line: `-----BEGIN PRIVATE KEY-----`, or the same with `RSA`, `EC`, `OPENSSH` or `DSA` before `PRIVATE KEY`, or `-----BEGIN PGP PRIVATE KEY BLOCK-----` | Each pattern must stand on word boundaries, so a key embedded in a longer word does not match. Some other details: - A file is reported at most once per credential type, even if it holds several keys of that type. - One value can match more than one type. For example, an Anthropic key also has the shape of an OpenAI key, so the output can list both `Anthropic API key` and `OpenAI API key` for the same file. - Only live Stripe keys are detected. Test keys (`sk_test_...`) are not. - An encrypted PEM key (`-----BEGIN ENCRYPTED PRIVATE KEY-----`) is not matched by the `Private key` pattern. - The list is fixed. You cannot add your own patterns. ## What is scanned | Scanned | Not scanned | |---|---| | Blobs (file contents) contained in the packfile of a `git push` | Trees (file names) | | Commit and annotated tag messages, in a push, a commit made without Git (`message`) and the git data API (`POST .../git/commits`, `POST .../git/tags`) | | | Text files of any size, in a push or a commit made without Git | | | Every ref in the push: branches and tags | Binary files: any blob that contains a NUL byte anywhere, except UTF-16 text | | UTF-16 text files (with a byte order mark) | | | Files you add or change in the web editor | Files that contain the text `diffinery:allow-secret` (see [Opting a file out](#opting-a-file-out)) | | Files in a commit made with `POST /repos/{owner}/{repo}/diffinery/commits` or the MCP `commit_files` tool | Objects the server already has, which git does not send again | | Files written with `PUT /repos/{owner}/{repo}/contents/{path}` | Git LFS objects, which are uploaded separately from the push | | Blobs created with `POST /repos/{owner}/{repo}/git/blobs`, and inline `content` in `POST /repos/{owner}/{repo}/git/trees` | Release assets | | | Deleted files | Pushes larger than 100 MB are refused before scanning with `push exceeds 100 MB; split it into smaller pushes`. > **Important:** Push protection is a safety net, not a guarantee. It finds only the formats listed above, in text files. Never rely on it as your only defense: keep secrets in [Actions secrets](https://diffinery.com/docs/actions/secrets-and-variables) and out of your repository. ## When a push is blocked ### What you see git prints Diffinery's messages with a `remote:` prefix, followed by a rejection for each ref: ```text $ git push origin main Enumerating objects: 5, done. ... remote: Push blocked: possible secrets detected. remote: AWS access key in blob 3f2a9c1d0e remote: Private key in blob 8b41e07a5c remote: Remove the secret from your commits, or rotate it and contact an admin. To https://diffinery.com/acme/website.git ! [remote rejected] main -> main (secret detected (push protection)) error: failed to push some refs to 'https://diffinery.com/acme/website.git' ``` The format is: - The first line is always `Push blocked: possible secrets detected.` - Then one line per finding, ` <type> in blob <first 10 characters of the blob id>`, for at most 10 findings. - Then `Remove the secret from your commits, or rotate it and contact an admin.` - If you passed `-o allow-secrets=...` but are not allowed to bypass, one more line: `Only repository admins (people, not agents) can bypass push protection.` - Every ref in the push is rejected with the reason `secret detected (push protection)`. ### What else happens - The attempt is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `push.blocked_secret`, with the repository as the target and the detected types and refs as detail. The secret values themselves are never recorded. - You get an email with the subject `[acme/website] Your push was blocked: possible secrets`. If an agent pushed, the email goes to the agent's owner (for an organization's agent, to the organization's owners and admins). This is a security email and is sent whatever your notification settings are. - Nothing from the push is stored in the repository. ### How to fix it 1. Find the file. The blob id prefix in the output identifies the file version. In your local clone, run: ```sh git rev-list --objects --all | grep '^3f2a9c1d0e' ``` The line it prints ends with the path of the file. If it prints no path, the finding is in a commit or tag message: `git cat-file -t 3f2a9c1d0e` says `commit` or `tag`, and `git log -1 3f2a9c1d0e` shows the message. 2. Remove the secret from every commit you are pushing, not only the latest one. For the most recent commit: ```sh # edit the file to remove the secret, then git add path/to/file git commit --amend --no-edit ``` If the secret was added in an earlier commit, rewrite those commits with an interactive rebase (`git rebase -i origin/main`) and edit each one that contains it. For a secret in a commit message, reword the commit instead (`git commit --amend` for the latest, `reword` in the rebase for older ones); for an annotated tag, delete and recreate the tag. 3. If the value was a real credential, rotate it at its provider. Treat it as exposed: it was on your machine in a commit and may have been pushed elsewhere. 4. Push again. ## Commits and writes without Git Commits made without a Git client run the same scan before anything is written. This covers: | Where | What is scanned | |---|---| | The web editor (**Commit changes** on a new or edited file) | The file's new content | | `POST /repos/{owner}/{repo}/diffinery/commits` | Every file in `files` that isn't a delete | | The MCP `commit_files` tool | Every file in `files` | | `PUT /repos/{owner}/{repo}/contents/{path}` | The new file content | | `POST /repos/{owner}/{repo}/git/blobs` | The blob | | `POST /repos/{owner}/{repo}/git/trees` | Each tree entry given with inline `content`; entries given by `sha` refer to blobs already stored and are not scanned again | | `POST /repos/{owner}/{repo}/git/commits` and `POST /repos/{owner}/{repo}/git/tags` | The commit or tag `message` | The web editor, the commits API, `commit_files` and the contents API (`PUT` and `DELETE`) scan the commit message too. A match in it is reported as `(commit message)` where a file path would be. The patterns, the binary-file rule and the `diffinery:allow-secret` opt-out are the same as for pushes. Every text file is scanned, whatever its size. ### When a commit is refused A commit that contains a credential fails with `422` and nothing is stored. For the web editor, the commits API, the contents API and MCP, the message names the types and up to 10 paths: ```json { "message": "push protection: possible secret detected (AWS access key) in config/deploy.env. Remove it, or rotate it and ask an admin.", "documentation_url": "https://docs.github.com/rest" } ``` The web editor shows the same message above the form and keeps your edit, so you can remove the value and commit again. If you sent `allow_secrets` but aren't allowed to bypass, the message ends with `Only repository admins (people, not agents) can bypass push protection.` The git data endpoints answer `422` with the shorter `push protection: possible secret detected (AWS access key)`. Each refused write is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `api.blocked_secret`, against the person or agent who made the request, with the repository as the target and the detected types and paths (or, for the git data API, the blob id) as detail. You get an email with the subject `[acme/website] Your commit was blocked: possible secrets` (for an agent, its owner does). ### Bypassing with the commits API A repository admin who is a person can commit a flagged file anyway through `POST /repos/{owner}/{repo}/diffinery/commits` by giving a reason in `allow_secrets`, the API counterpart of `git push -o allow-secrets=`: ```sh curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/commits \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/json" \ -d '{ "branch": "main", "message": "Add migration test fixture", "allow_secrets": "revoked key kept in a migration test", "files": [{ "path": "test/fixtures/old-key.txt", "content": "AKIA..." }] }' ``` The same [requirements](#who-can-bypass) apply as for a push: the admin role on the repository, a person rather than an agent, a token with the `admin` scope, and a non-empty reason (trimmed and cut to 200 characters). A bypassed commit is recorded in the audit log as `api.secret_bypassed`, with the detected types, the paths and your reason, and the `[acme/website] Push protection was bypassed` email goes to you and to the repository's admins. The web editor, the contents API, the git data API and MCP have no bypass. If a file really must contain the value, add the `diffinery:allow-secret` marker to it, commit it with `allow_secrets` through the commits API, or push it with git and use [the bypass](#bypassing-push-protection). ## Opting a file out Test fixtures sometimes need values that look like real keys. To let such a file through, put the text `diffinery:allow-secret` anywhere in it, usually in a comment: ```ts // diffinery:allow-secret (fake keys for the parser tests) export const FIXTURE_KEY = "AKIAIOSFODNN7EXAMPL3"; ``` The marker applies to the **whole file**, not to one line. If `diffinery:allow-secret` appears anywhere in a file's content, Diffinery skips every pattern for that entire file. Writing it at the end of a line, as in `key = AKIA... # diffinery:allow-secret`, works only because it puts the marker somewhere in the file; any other credential in the same file is skipped too. The opt-out is visible in the code and goes through review like any other change, so use it only for files whose secrets are fake. It matches the exact text `diffinery:allow-secret` (case-sensitive), in any comment syntax. An opted-out file that holds something that looks like a credential is still let through, and the audit log records it: `push.secret_marked` for a push (with the credential types, up to 50 blob ids and the refs) and `api.secret_marked` for a commit made without Git (with the types and up to 10 paths). So an admin can see who used the opt-out and on what. ## Bypassing push protection A repository admin who is a person can push a flagged change anyway by giving a reason with a git push option (for commits made through the API, see [Bypassing with the commits API](#bypassing-with-the-commits-api)): ```sh git push -o allow-secrets="revoked key kept in a migration test" origin main ``` `--push-option=allow-secrets=...` is the long form of the same option. ### Who can bypass All of these must be true: | Requirement | Detail | |---|---| | You have the **admin** role on the repository | The repository owner, an owner or admin of the organization that owns it, a collaborator with the admin role, or a site admin. See [Permissions](https://diffinery.com/docs/accounts/permissions). | | You are a person | Agents can never bypass push protection, whatever their role. | | Your token allows admin | git authenticates with a token. A `write`-scoped token caps your role at maintain, so you need a token with the `admin` scope. See [Tokens](https://diffinery.com/docs/accounts/tokens). | | You give a reason | The value after `allow-secrets=` must not be empty or only spaces. It is trimmed and cut to 200 characters. | If any requirement is missing, the push is blocked as usual. When you supplied the option but are not allowed to use it, the output adds `Only repository admins (people, not agents) can bypass push protection.` ### What a bypass does - The push continues and the output shows `Push protection bypassed by diffy: <reason>`, followed by the findings (up to 10). - The bypass skips only the secret scan. Branch protection still applies to every ref. - It is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `push.secret_bypassed`, with the detected types, your reason and the refs. - An email with the subject `[acme/website] Push protection was bypassed` goes to you and to the repository's admins (the owner or the organization's owners and admins, plus admin collaborators who are people). It names who pushed, the types found, the reason and the refs. You cannot turn this email off. Bypassing does not make a real credential safe: anyone who can read the repository can read it. Rotate real credentials even after a bypass. ## Removing a secret that was pushed Force-pushing a branch to drop a commit doesn't delete the commit. Like git, Diffinery keeps objects no branch reaches, and anyone who can read the repository can still fetch the commit by its SHA (`GET .../git/commits/{sha}`, the commit page, or `git fetch origin <sha>`). To remove it: 1. **Rotate the credential** at its provider. Treat it as exposed: clones, forks, caches and webhook receivers may already have it. 2. Rewrite the history without it and force-push every branch and tag that contains it. Close or update any pull request whose head contains it; a closed pull request still keeps its commits through `refs/pull/<number>/head` until it is deleted with **Delete closed pull requests** (see [Start over with fresh history](https://diffinery.com/docs/repositories/overview#start-over-with-fresh-history)). 3. On the repository's **Settings**, under **Danger zone**, click **Purge unreachable objects** (or `POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable`). It permanently deletes every commit, tree and file that no branch, tag, pull request, merge queue entry or unfinished workflow run reaches, and empties the cache of downloadable archives. It answers `{"objects": 3, "bytes": 1234}`. Only repository admins (people, not agents) can purge, with a sign-in from the last 2 hours in the browser. It is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `repo.purge_unreachable`. A push that was in flight while the purge ran is refused with `the repository's unreachable objects were purged during this push; push again`. Purging works for repositories of up to 300,000 objects. Review comments on commits that were purged no longer show their diff. ## Imported history An [import from GitHub](https://diffinery.com/docs/migrate-from-github) copies a repository's Git history by pushing it into Diffinery in chunks, and push protection scans every one of those pushes like any other: each new text blob, with the same patterns and the same `diffinery:allow-secret` opt-out. Git LFS objects and release assets are not scanned, as for any push. Old history often holds credentials that were committed and removed later, so every import has a setting for what happens when push protection finds one: | Web form choice (under **Secrets found in old history (push protection)**) | API, CLI and MCP value | What happens | |---|---|---| | **Import with an audited bypass** (the default) | `"options": {"secrets": "bypass"}` | The import pushes with a bypass whose reason is `Imported from GitHub <owner>/<name>`. It works only when the import may bypass (see below); otherwise the import fails. | | **Stop the import** | `"options": {"secrets": "stop"}` | The import fails at the first push with a finding, so you can rewrite the history on GitHub first. | ### Who can import past a finding An import pushes as the person or agent who started it, with the token scope they started it with (a browser session counts as `admin`). So the bypass follows the same rules as a `git push -o allow-secrets=...`: - The import was started by a person, not an agent. **Agents never bypass push protection**: an import an agent starts always uses `stop`, whatever it asks for. - That person has the admin role on the new repository: it is in their own account, or in an organization they own or administer (site admins have it everywhere). - They started the import in the web UI or with an `admin`-scoped token. With a `write` token the bypass is refused. When a finding stops an import, the import fails with one of these errors, and the import page and `GET /api/v3/diffinery/imports/{id}` show it: | Error | Why | |---|---| | `Push protection found possible secrets in the history (<types>). The import was set to stop on secrets. Rotate them, then start a NEW import with the secrets option set to bypass (as a person who administers the target); a retry cannot change the options.` | The import used `stop`, or an agent started it. | | `Push protection found possible secrets in the history (<types>). Only a person who administers the target can import them with an audited bypass (agents and write-scoped tokens cannot); start a new import as such a person.` | The import used `bypass`, but whoever started it can't bypass. | The Git transfer isn't tried again after such a failure, and retrying the import doesn't help, because a retry keeps the options and runs as the same person or agent. Rotate the credentials, then start a new import (as a person who administers the target, with `bypass`), or rewrite the history on GitHub and import again. The failed import's repository stays, so delete it or give the new import another `name`. ### What an imported bypass records - Each bypassed push writes a `push.secret_bypassed` entry to the [audit log](https://diffinery.com/docs/security/audit-log), with the person who started the import as the actor, the reason `Imported from GitHub <owner>/<name>`, and in its detail the import's id and up to 50 blob ids. - The person who started the import and the repository's admins get the `[acme/website] Push protection was bypassed` email. - The import's summary lists every finding with its type, the file path when the import agent saw one, and the blob id: on the import page under **Possible secrets in history (rotate these)**, and as `summary.findings` in the API. Rotate these credentials. They are readable by anyone who can read the repository, and were already readable on GitHub. - A push that push protection blocks during an import is recorded as `push.blocked_secret`, and the person who started the import (or the agent's owner) gets the `Your push was blocked: possible secrets` email. ## Differences from GitHub push protection Diffinery's push protection follows the same idea as GitHub's: scan at push time, block before anything is stored, and allow an audited bypass. The differences: | | GitHub | Diffinery | |---|---|---| | Patterns | A large catalog of provider patterns, plus custom patterns | The nine types listed above; no custom patterns | | Enabling | A repository or organization setting | Always on for every repository; cannot be turned off | | Bypass | A link in the push output where you pick a reason, then push again | `git push -o allow-secrets=<reason>` in the same push, or `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits`; repository admins who are people only | | Excluding files | Paths in `.github/secret_scanning.yml` | A `diffinery:allow-secret` marker inside each file | | Notification | Secret scanning alerts in the repository | Audit log entries and email to the pusher (and to admins on a bypass) | | Existing history | Secret scanning can also scan existing content | Only new blobs in each push, and each file written through the web editor, the commits, contents and git data APIs and MCP, are scanned. History imported from GitHub is scanned as it is pushed in. | ## Troubleshooting | Symptom | Cause and fix | |---|---| | `secret detected (push protection)` on every ref | At least one file in the push matched. Read the `remote:` lines above it for the type and blob, then follow [How to fix it](#how-to-fix-it). | | The push is still blocked after you deleted the file in a new commit | The secret is still in an earlier commit you are pushing. Rewrite the commit that added it instead of adding a commit on top. | | `Only repository admins (people, not agents) can bypass push protection.` | You used `-o allow-secrets=...` without being allowed. Check your role, that you are not using an agent's token, and that your token has the `admin` scope. | | `-o allow-secrets=` seems to be ignored | The reason is empty. Give a non-empty reason, for example `-o allow-secrets="test fixture"`. | | A real key was not caught | It is not one of the formats above, it is in a binary file, or the file contains `diffinery:allow-secret`. Rotate the key and remove it. | | `push protection: possible secret detected (...) in (commit message)` (422) | The commit message, not a file, holds what looks like a credential. Change the message and commit again. | | `push protection: possible secret detected (...)` (422) from the API, MCP or the web editor | A file you committed or wrote matched. Remove the value, or mark a test fixture with `diffinery:allow-secret`. A repository admin who is a person can commit it anyway with `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits`; the other endpoints have no bypass. | | `... Only repository admins (people, not agents) can bypass push protection.` (422) from the commits API | You sent `allow_secrets` without being allowed. Check your role, that you are not using an agent's token, and that your token has the `admin` scope. | | An import from GitHub failed with `Push protection found possible secrets in the history (...)` | The source's history contains credentials and the import couldn't bypass. See [Imported history](#imported-history). | | `push exceeds 100 MB; split it into smaller pushes` | The pack is over the push size limit. Push fewer commits at a time, or move large files to [Git LFS](https://diffinery.com/docs/repositories/git-lfs). | --- # Audit log > What Diffinery records in its audit log, the fields of each entry, who can view it, and which entries also send security emails. Source: https://diffinery.com/docs/security/audit-log The audit log is an append-only record of security-relevant actions on a Diffinery instance: sign-ins, passkey and token changes, profile pictures, repository and environment settings (from the web UI and the API alike), pushes, merges, admin merges that bypass branch protection, push protection decisions, package changes, imports from GitHub, linked GitHub accounts, and moderation (reports, blocks, suspensions and hidden comments). Site administrators use it to answer "who did what, when, and on whose behalf". ## Viewing the audit log The audit log is available to **site administrators** only. 1. Sign in with your passkey. 2. Open **Settings** at `https://diffinery.com/settings`. 3. In the sidebar, under the **Site administration** heading (shown only to site admins), select **Audit log**. It comes after **People and invitations** and **Email**. The page is at `https://diffinery.com/settings/audit`. The page shows the 200 most recent entries, newest first, in a table with these columns: | Column | Content | |---|---| | **When** | The time of the action | | **Actor** | The login of the account that acted, followed by `for <owner>` when an agent acted on behalf of a person or organization. Shows `system` when no account is attached. | | **Action** | The action name, such as `git.push` | | **Target** | What the action applied to, such as `acme/website` or `acme/website:refs/heads/main` | | **Detail** | Extra data as JSON (hover to see it all) | There is no filtering, search or paging on the page, and organization owners and repository admins cannot open it. There is no REST API endpoint, MCP tool or CLI command for reading the audit log on this version. ## How long entries are kept Entries are kept for 2 years (730 days). Older ones are deleted every hour. An account deletion doesn't remove the deleted account's entries; they go when they reach 2 years like the rest. ## Entry fields Each entry is stored with these fields: | Field | Description | |---|---| | `id` | A unique id for the entry | | `at` | ISO 8601 timestamp of the action | | `actor_id` | Id of the account that acted (empty for some system actions) | | `actor_login` | Login of that account at the time | | `on_behalf_of` | For agents: the login of the person or organization that owns the agent | | `action` | The action name (see below) | | `target` | What the action applied to; format depends on the action | | `detail` | JSON with action-specific data, or empty | Entries are only ever added. Diffinery has no feature to edit or delete them. ## Recorded actions ### Sign-in and account security | Action | Recorded when | Target | Detail | |---|---|---|---| | `session.create` | Someone signs in with a passkey, or through the email recovery path | none | `method` (`passkey` or `access`), `device`, `ip`, `location`, and for passkeys `passkey` (name) and `passkeyId` | | `passkey.add` | A passkey is added from **Passkeys and sessions**, or through a one-time sign-in link | none | `name`, `passkeyId`, `device`, `ip`, `location`; `via: "signin-link"` when added through a link | | `passkey.remove` | A passkey is removed | none | `name` | | `session.revoke` | You sign out one of your other browsers with **Sign out** on **Passkeys and sessions** | none | none | | `account.auto_merge` | You turn **Turn on auto-merge for pull requests I open** on or off, on **Settings → Pull requests**, the repository's **Merge on green** page, `diffinery setup` or `PUT /api/v3/diffinery/auto-merge` (see [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green#your-own-pull-requests)) | your login | `merge_method` (`null` when off) | | `session.revoke_others` | Recovering an account signs out its other browsers: through email recovery at `/auth/access` from a browser that wasn't signed in to the account, or with a one-time sign-in link (see [Recover your account](https://diffinery.com/docs/accounts/passkeys-and-sign-in#recover-your-account)) | none | `via` (`access` or `signin-link`), `sessions` (how many were signed out) | | `signup` | Someone creates an account and passkey through open sign-up (`SIGNUP=open`), after confirming their email with a code | none | `method` (`passkey`), `email`, `passkeyId`, `device`, `ip`, `location` | | `invite.accept` | Someone creates an account and passkey from an invite link, or an existing user accepts an invite (from its link, **Settings → Invitations**, the **Waiting for an invite** page or the API) | for an existing user who opened the link, the path the invite led to (such as `/acme/website`) | for a new account: `method` (`passkey`), `passkeyId`, `device`, `ip`, `location`; from **Settings → Invitations** or the API: `id` (the invitation id), and `via: "api"` for the API | | `invite.decline` | Someone declines an invitation on **Settings → Invitations**, the **Waiting for an invite** page, or with `DELETE /api/v3/user/repository_invitations/{id}` | none | `id` (the invitation id), and `via: "api"` for the API | | `token.create` | A personal access token is created on **Access tokens** or with `POST /api/v3/diffinery/tokens` | your login (web) | `name` and `scope`/`scopes`, and for the web the number of repositories it is restricted to | | `token.revoke` | A token is revoked on **Access tokens** or with `DELETE /api/v3/diffinery/tokens/{id}` | the token id | `via: "api"` for the API | | `signin_link.create` | A site admin creates a one-time sign-in link for someone | that user's login | none | | `user.active`, `user.pending`, `user.disabled` | A site admin changes a user's status | that user's login | none | | `site_admin.grant`, `site_admin.revoke` | A site administrator makes someone a site administrator, or removes them, with **Make admin** or **Remove admin** in **Site administration** | that user's login | none | | `account.delete` | Someone deletes their account on **Settings → Account**, or a site administrator deletes it (see [Exporting and deleting your account](https://diffinery.com/docs/accounts/account-data)) | the deleted account's login | `via` (`settings` or `admin`), `repositories` and `packages` (how many were deleted), `agents` (the logins of the agents deleted with it) | | `account.export` | Someone asks for an export of their account on **Settings → Account** | none | `id` (the export's id) | | `account.export_download` | Someone downloads their account export | none | `id`, `size` (in bytes) | | `user.suspend` | A site administrator [suspends](https://diffinery.com/docs/admin/site-administration#suspend-an-account) a person, from the **Users** table, a report, or `PUT /api/v3/users/{username}/suspended` | that person's login | `reason`, `via` (`web` or `api`), `stopped` (runs, jobs and sandboxes stopped in their repositories) | | `user.unsuspend` | A site administrator lifts a suspension (**Unsuspend**, or `DELETE /api/v3/users/{username}/suspended`) | that person's login | `via` | | `account.link_github` | You link a GitHub account on **Settings → Linked accounts** (gist code or GitHub sign-in) or with `POST /api/v3/diffinery/user/linked-accounts/github` | `<host>/<GitHub login>`, such as `github.com/diffy` | none | | `account.unlink_github` | You unlink a GitHub account (**Unlink**, or `DELETE /api/v3/diffinery/user/linked-accounts/{id}`) | the link's id | none | | `account.link_cleared` | A site admin clears someone's GitHub link with `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | `<host>/<GitHub login>` | `account` (the id of the Diffinery account the link belonged to) | | `avatar.update`, `avatar.remove` | A profile picture is uploaded or removed, for yourself or for an organization you administer (web UI, `PUT`/`DELETE /api/v3/user/avatar` or `PUT`/`DELETE /api/v3/orgs/{org}/avatar`) | the login of the person or organization | `kind` (`user` or `org`) and `via` (`web` or `api`) | ### Agents | Action | Recorded when | Target | Detail | |---|---|---|---| | `agent.create` | An agent account is created (web or `POST /api/v3/diffinery/agents`) | the agent's login | `maxRole` and `auto_merge` (web) | | `agent.auto_merge` | **Merge its pull requests on green** is turned on or off for an agent, on **Settings → Agents**, **Agent setup** or with `PUT /api/v3/diffinery/agents/{login}/auto-merge` | the agent's login (the repository on Agent setup, with `agent`) | `merge_method` (`null` when off) | | `agent.token.create` | A token is created for an agent, from the web UI or with `POST /api/v3/diffinery/agents/{login}/tokens` | the agent's login | `scope` (web) or `scopes` (API) | | `agent.enable`, `agent.disable` | An agent is enabled or disabled from the web UI; `agent.disable` also for `DELETE /api/v3/diffinery/agents/{login}` | the agent's login | none | ### Agent tasks and model keys | Action | Recorded when | Target | Detail | |---|---|---|---| | `task.start` | Someone starts an [agent task](https://diffinery.com/docs/agents/tasks) | `owner/repo#<task number>` | `agent`, `runtime`; `provider` and `model` when it goes through OpenRouter; `plan_first` when it plans first; `attempts` when it runs more than one; `fix_checks` and `land` | | `task.message`, `task.retry` | The person who started a task sends a follow-up or retries it | `owner/repo#<task number>` | none | | `task.plan_approve` | The person who started a [plan-first](https://diffinery.com/docs/agents/tasks#plan-first) task approves its plan | `owner/repo#<task number>` | none | | `task.options` | The person who started a task changes **Fix failing checks** or **Land when approved** | `owner/repo#<task number>` | `fix_checks`, `land` | | `task.pick` | The person who started a task with [attempts](https://diffinery.com/docs/agents/tasks#best-of-n) picks one | `owner/repo#<task number>` | `attempt` | | `task.plan_split` | The person who started a plan-first task hands its plan to their coordinator to split | `owner/repo#<task number>` | `post`, `coordinator_task` | | `task.stop` | Someone stops a task (its starter, its agent or a repository admin) | `owner/repo#<task number>` | none | | `terminal.terms` | Someone accepts the notice for [terminals](https://diffinery.com/docs/agents/terminal) | `owner/repo` | `version` | | `terminal.open` | Someone opens a [terminal](https://diffinery.com/docs/agents/terminal) | `owner/repo` | `terminal`, `agent`, `ref`, `tasks` (whether it was opened to run agent tasks), `runtimes` (the Claude Code and Codex versions its buttons install) | | `terminal.task` | Someone runs a task in their [terminal](https://diffinery.com/docs/agents/terminal#give-it-a-task) | `owner/repo` | `terminal`, `task`, `runtime`, `version` (the pinned version of the CLI) | | `terminal.end` | A terminal ends | `owner/repo` | `terminal`, `reason` (`person`, `idle`, `limit`, `access`, `stopped`, `failed`, `disabled` or `suspended`) | | `project.memory` | Someone saves or restores the [project](https://diffinery.com/docs/agents/projects) memory | `owner/repo` | `version`, `length`, and `restored` (the version restored) for a restore | | `project.settings` | A repository admin changes **Settings → Project** | `owner/repo` | `max_running`, `instructions_length`, `plan_first` | | `project.coordinator` | Someone turns their [coordinator](https://diffinery.com/docs/agents/projects#coordinator) on, changes it or turns it off | `owner/repo` | `enabled`, and when on `agent`, `runtime`, `provider` and `model` (OpenRouter), `tokens_per_run`, `tokens_per_task` | | `project.coordinate` | Someone's coordinator is given a post to handle | `owner/repo` | `post`, `task`, `agent`, `runtime` | | `review.settings` | A repository admin turns [agent review](https://diffinery.com/docs/agents/review) on or off, or changes its settings | `owner/repo` | `enabled`, `agent`, `runtime`, `trigger`, `autofix`, `daily_limit` | | `review.request` | A person asks for an agent review with **Request agent review** or `POST /repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review` | `owner/repo#<pull request number>` | none | | `model_key.set`, `model_key.delete` | You save, replace or delete a model API key under **Settings → Model access** (or `PUT`/`DELETE /api/v3/diffinery/model-keys/{provider}`) | the provider (`anthropic`, `openai` or `openrouter`) | `hint` (the key's last four characters) and `opted_in_at` (when you accepted the provider's terms) for `model_key.set` | ### Organizations and invitations | Action | Recorded when | Target | Detail | |---|---|---|---| | `org.create` | An organization is created from the web UI | the organization's login | none | | `org.update` | An organization's settings are changed on its **Settings** tab, or with `PATCH /api/v3/orgs/{org}` | the organization's login | `login` and `base_role` (web UI), or `name` and `base_role` (API) | | `org.delete` | An organization is deleted (web or `DELETE /api/v3/orgs/{org}`) | the organization's login | none | | `org.secret.set`, `org.secret.delete` | An organization secret is saved or deleted with `PUT`/`DELETE /api/v3/orgs/{org}/actions/secrets/{name}` (or `diffinery secret ... --org`) | the organization's login | `name` (never the value), and for `set` its `visibility` and any selected `repositories`; `via: "api"` | | `org.secret.repositories`, `org.secret.repository.add`, `org.secret.repository.remove` | An organization secret's repository selection is replaced, or one repository is added or removed | the organization's login | `name`, and `repositories` or `repository`; `via: "api"` | | `org.variable_set`, `org.variable_delete` | An organization Actions variable is created, changed or deleted with `POST`, `PATCH` or `DELETE /api/v3/orgs/{org}/actions/variables[/{name}]`, or added by a GitHub import of an organization | the organization's login (API), or the GitHub organization (import) | `name` (upper-cased); `via: "api"` for API changes; an import adds `import` (its id) and `org` | | `org.invite` | Someone is invited to an organization: an email address or a username entered in **Add people** on the organization's **People** tab, or `POST /api/v3/orgs/{org}/invitations` | the organization's login | `email` or `user`, and `role` | | `org.invite.revoke` | A pending organization invitation is revoked with **Revoke** | the organization's login | `id` (the invitation id) | | `org.member.set`, `org.member.remove` | A site administrator adds themselves with **Add people**, a member's role is changed, or a member is removed | the organization's login | `user`, `role` | | `invite.create` | A site admin creates an invite from **Site administration** | the email address | none | | `site.invite` | A site admin invites someone with `POST /api/v3/invitations` | none | `email` | | `account.quotas.set` | A site administrator changes an account's quotas with `PATCH /api/v3/admin/accounts/{login}/quotas` | the account's login | the quotas sent, e.g. `hosted_minutes` | | `actions.suspend` | A site administrator suspends Actions for a repository or an account ([kill switches](https://diffinery.com/docs/admin/site-administration#kill-switches)), on **Settings → Kill switches** or with `PUT /api/v3/admin/repos/{owner}/{repo}/actions-suspension` or `PUT /api/v3/admin/accounts/{login}/actions-suspension` | `owner/name`, or the account's login | `kind` (`repo` or `account`), `reason`, `via` (`web` or `api`), `stopped` (runs, jobs and sandboxes stopped) | | `actions.unsuspend` | A site administrator lifts that suspension (**Lift**, or `DELETE` on the same path) | `owner/name`, or the account's login | `kind`, `via` | | `compute.pause` | A site administrator pauses hosted compute on the instance (**Pause hosted compute**, or `PUT /api/v3/admin/hosted-compute-pause`) | none | `reason`, `via`, `stopped` | | `org.block`, `org.unblock` | An organization owner [blocks](https://diffinery.com/docs/accounts/blocking-and-reporting) or unblocks a person for the organization (its **Settings** tab, or `PUT`/`DELETE /api/v3/orgs/{org}/blocks/{username}`) | the organization's login | `user`, `via` | | `compute.unpause` | A site administrator resumes it (**Resume hosted compute**, or `DELETE /api/v3/admin/hosted-compute-pause`) | none | `via` | | `email.suppression.add` | An address goes on the email suppression list: a permanent bounce or a spam complaint arrives, or Email Sending refuses to send to it for good (no actor, shown as `system`), or a site administrator adds it (**Suppress an address** on **Settings → Email**, or `PUT /api/v3/diffinery/admin/email/suppressions/{email}`). See [the suppression list](https://diffinery.com/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | the email address | `reason` (`hard-bounce`, `complaint` or `manual`), `source` (`cloudflare`, `webhook`, `send` or `admin`), `detail` (the provider's words or the administrator's note) | | `email.suppression.remove` | A site administrator removes an address from the list (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`) | the email address | `reason` and `source` of the entry removed | | `email.suppression.lift` | Someone lifts a bounce or complaint on their own address by following the verification link from **Settings → Email** | the email address | `reason` of the entry lifted | | `security_report.create` | Someone sends a private [security report](https://diffinery.com/docs/admin/site-administration#security-reports) through `/security/report`. The actor is the reporter when signed in, otherwise `system`. Nothing of the report's content is recorded. | the report's id | `signed_in` | | `security_report.triage`, `security_report.resolve`, `security_report.reopen` | A site administrator marks a security report triaged or resolved, or new again (**Save** on the report's page, or `PATCH /api/v3/diffinery/admin/security-reports/{id}`) | the report's id | `from` and `to` (the states), `note`, `via` (`web` or `api`) | | `early_access.confirm` | An address confirms its [early access](https://diffinery.com/docs/accounts/early-access) request with the emailed link (the actor is `system`) | the request's id | none | | `early_access.invite` | A site administrator invites an early access request (**Invite** on **Settings → Early access**, or `POST /api/v3/diffinery/admin/early-access/{id}/invite`) | the request's id | `invite` (the invitation id), `from` (the state it was in), `existing_invitation` when a pending invitation was kept, `via` (`web` or `api`) | | `early_access.decline`, `early_access.restore` | A site administrator declines a request, or puts a declined one back (**Decline** or **Put back**, or `PATCH /api/v3/diffinery/admin/early-access/{id}`) | the request's id | `from` or `to` (the states), `via` | | `early_access.delete` | A site administrator deletes a request (**Delete request**, or `DELETE /api/v3/diffinery/admin/early-access/{id}`), or someone removes their own with an emailed link (the actor is `system`). Neither the address nor the request's content is recorded. | the request's id | `state`, `via` (`web`, `api` or `self`) | | `status_incident.create` | A site administrator opens an incident on the [status page](https://diffinery.com/docs/self-hosting/status-page) (**Open the incident** on **Settings → Status**, or `POST /api/v3/diffinery/admin/status/incidents`) | the incident's id | `title`, `impact`, `components`, `state`, `via` (`web` or `api`); not the update's text | | `status_incident.update`, `status_incident.resolve` | A site administrator posts an update to an incident (**Post the update**, or `POST /api/v3/diffinery/admin/status/incidents/{id}/updates`); `resolve` when it resolves the incident | the incident's id | `from` and `to` (the states), `via` | ### Moderation | Action | Recorded when | Target | Detail | |---|---|---|---| | `user.block`, `user.unblock` | Someone [blocks](https://diffinery.com/docs/accounts/blocking-and-reporting) or unblocks a person (a profile's **…** menu, **Settings → Blocked users**, or `PUT`/`DELETE /api/v3/user/blocks/{username}`) | the person blocked | `via` | | `report.create` | Someone reports content to the site administrators (**Report**, or `POST /api/v3/diffinery/reports`) | what was reported: a login, `owner/name`, `owner/name#12`, or `owner/name#12 comment <id>` | `id` (the report's), `kind`, `reason`, `via` | | `report.resolve`, `report.reopen` | A site administrator resolves a report with a note, or reopens it (**Settings → Reports**, or `PATCH /api/v3/admin/reports/{id}`) | as for `report.create` | `id`, `resolution` (for `resolve`), `via` | | `comment.minimize`, `comment.unminimize` | Someone with write access [hides a comment](https://diffinery.com/docs/collaboration/issues#hide-a-comment) or shows it again | `owner/name` | `comment` (its id), `number` (the issue or pull request), `reason` (for `minimize`), `via` | Suspensions and organization blocks are listed under [Organizations and invitations](#organizations-and-invitations) and [Sign-in and account security](#sign-in-and-account-security). ### Repositories All repository entries use the repository (`acme/website`) as the target. Settings changes are recorded the same way whether they are made on the web settings pages or through the REST API (and the CLI, which uses it). Entries written for an API call add `via: "api"` to the detail. | Action | Recorded when | Detail | |---|---|---| | `repo.create` | A repository is created from the web UI | none (target `acme/website`) | | `repo.update` | **General** settings are saved, or `PATCH /api/v3/repos/{owner}/{repo}` changes the name, description, visibility or default branch | Web: `name`, `visibility`, `defaultBranch`. API: the fields that were sent (`name`, `description`, `visibility`, `default_branch`) | | `repo.archive`, `repo.unarchive` | The repository is archived or unarchived, on **General** or with `PATCH /api/v3/repos/{owner}/{repo}` and `{"archived": true}` (or `false`) | none | | `repo.delete` | The repository is deleted (web or `DELETE /api/v3/repos/{owner}/{repo}`) | none | | `repo.purge_unreachable` | Unreachable objects are purged (**Settings → Danger zone**, or `POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable`) | `objects`, `bytes` | | `repo.delete_closed_pulls` | Every closed pull request is deleted and unreachable objects are purged (**Settings → Danger zone → Delete closed pull requests**, or `POST /api/v3/repos/{owner}/{repo}/diffinery/delete-closed-pulls`) | `pull_requests`, `objects`, `bytes` | | `repo.transfer` | The repository is transferred to another owner (web or `POST /api/v3/repos/{owner}/{repo}/transfer`); the target is the new name | `from`, `to` | | `repo.collaborator.set`, `repo.collaborator.remove` | On **Collaborators**: an account gets a role at once with **Add people** (an agent, an existing collaborator, a member of the owning organization, or yourself), a collaborator's role is changed, or a collaborator is removed. Through the API: `PUT` (when the role applied at once) or `DELETE /api/v3/repos/{owner}/{repo}/collaborators/{username}` | `login`, `role` | | `repo.invite` | **Add people** or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` sends an invitation, to an email address or a username | `email` or `login`, and `role` | | `repo.topics` | The topics are replaced with `PUT /api/v3/repos/{owner}/{repo}/topics` | `names` | | `repo.protection.set`, `repo.protection.delete` | A branch protection rule is saved or deleted on **Branch protection**, or with `PUT`/`DELETE /api/v3/repos/{owner}/{repo}/branches/{branch}/protection` | `pattern`, and on the web `rules` when saved | | `repo.protection.agent_auto_merge` | **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`) is turned on or off for a rule, from the web UI or the API | `pattern`, `enabled`, `requiredChecks` | | `repo.protection.agent_approval_waiver` | **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`) is turned on or off for a rule, from the web UI or the API | `pattern`, `enabled`, `requiredChecks` | | `repo.merge_on_green` | Merge on green is turned on, changed or turned off on the repository's **Settings → Merge on green** page (the rule changes are recorded by the `repo.protection.*` entries too) | `branch`, `enabled`, and when on `checks`, `agents`, `withoutApproval`, `method` | | `repo.protection.agent_auto_merge_rule` | **Auto-merge pull requests from these agents** (`autoMergeAgents`) or their merge method changes for a rule, from the web UI or the API (see [Auto-merging agents' pull requests](https://diffinery.com/docs/repositories/branch-protection#auto-merging-agents-pull-requests)) | `pattern`, `enabled`, `agents`, `method`, `withoutApproval`, `requiredChecks` | | `repo.secret.set`, `repo.secret.delete` | An Actions secret is saved or deleted on **Secrets and variables**, or with `PUT`/`DELETE /api/v3/repos/{owner}/{repo}/actions/secrets/{name}` | `name` (never the value) | | `repo.variable.set`, `repo.variable.delete` | An Actions variable is saved or deleted on **Secrets and variables**, or created, updated, renamed or deleted through `/api/v3/repos/{owner}/{repo}/actions/variables` | `name`, and `renamed_from` when a `PATCH` renamed it | | `repo.actions.logs.delete` | A workflow run's logs are deleted with `DELETE /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/logs` | `run`, the run's ID, and `jobs`, how many jobs' logs were deleted | | `repo.webhook.create`, `repo.webhook.delete` | A webhook is added or deleted on **Webhooks**, or with `POST /api/v3/repos/{owner}/{repo}/hooks` or `DELETE /api/v3/repos/{owner}/{repo}/hooks/{id}` | Web: `url` and `events`, or `id`. API: `id` and `url`, or `id` | | `repo.actions_workflow_settings` | An admin changes the repository's workflow permissions on the **Actions** settings tab or with `PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow` (see [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions)) | the new settings: `defaultPermissions` (`read` or `write`), `canApprovePullRequests` and `agentRunSecrets`, plus `via: "api"` when the change came through the API | | `actions.cache.delete` | Actions caches are deleted with `DELETE /api/v3/repos/{owner}/{repo}/actions/caches?key=...` or `DELETE /api/v3/repos/{owner}/{repo}/actions/caches/{id}` | `key`, `ids`, `via: "api"` | | `actions.artifact.delete` | A workflow artifact is deleted with `DELETE /api/v3/repos/{owner}/{repo}/actions/artifacts/{id}` | `id`, `name`, `via: "api"` | | `runner.registration_token` | A repository admin creates a self-hosted runner registration token with `POST /api/v3/repos/{owner}/{repo}/actions/runners/registration-token` | none | | `runner.register` | A self-hosted runner registers with a registration token; the actor is the admin who created that token | `id`, `name`, `labels`, `ephemeral` | | `runner.remove` | A self-hosted runner is removed with `DELETE /api/v3/repos/{owner}/{repo}/actions/runners/{id}` | `id` | ### Environments Changes to [environments](https://diffinery.com/docs/actions/environments) are made through the REST API by repository admins who are people (agents are refused). Each entry has the repository (`acme/website`) as the target and names the environment in `environment`. | Action | Recorded when | Detail | |---|---|---| | `environment.update` | An environment is created or updated with `PUT /api/v3/repos/{owner}/{repo}/environments/{name}` | `environment`, `deployment_branch_policy` (as sent, or `null`); `reviewers` (logins), `wait_timer` and `prevent_self_review` when they were sent | | `environment.delete` | An environment is deleted | `environment` | | `environment.branch_policy.create` | A deployment branch or tag pattern is added | `environment`, `pattern`, `type` (`branch` or `tag`) | | `environment.branch_policy.delete` | A deployment branch or tag pattern is removed | `environment`, `id` | | `environment.secret.set`, `environment.secret.delete` | An environment secret is saved or deleted | `environment`, `name` (never the value) | | `environment.variable.set`, `environment.variable.delete` | An environment variable is created, updated, renamed or deleted | `environment`, `name`, and `renamed_from` when a `PATCH` renamed it | | `deployment.approve`, `deployment.reject` | A required reviewer approves or rejects a run's waiting deployments, on the run page or with `POST .../actions/runs/{run_id}/pending_deployments`. The actor is the reviewer. | `run`, `environments` (the environments decided), `comment` | ### Git and pull requests | Action | Recorded when | Target | Detail | |---|---|---|---| | `git.push` | A branch or tag moves forward or is created, by any means: `git push`, the web editor, the API, MCP, a merge or the merge queue | `acme/website:refs/heads/main` | `before`, `after` (commit ids) | | `git.force_push` | A ref moves to a commit that does not contain the old one | as above | `before`, `after` | | `git.delete_ref` | A branch or tag is deleted | as above | `before`, `after` | | `pull.merge` | A pull request is merged, directly, by the merge queue or by auto-merge | `acme/website#42` | none | | `pull_request.merge_bypassed` | A repository admin merges a pull request past branch rules by asking for a bypass: with `"bypass_rules": true` (or **Merge without waiting for requirements (bypass)** in the web UI) past unmet blockers or a linear-history rule (a merge commit), or with `"bypass_merge_queue": true` (or **Merge now (bypass the queue)**) past a required merge queue. `diffinery pr merge --admin` sends both. A `pull.merge` entry follows. | `acme/website#42` | `blockers` (what was bypassed: the unmet blockers, such as `needs 1 more approving review(s)`, plus `linear history required` or `merge queue` when those were bypassed), `base` (the base branch) | | `pull_request.auto_merge_enabled` | Someone turns on [auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge) for a pull request, or changes its method (web UI, `PUT /api/v3/repos/{owner}/{repo}/pulls/{number}/auto-merge` or the `enable_auto_merge` MCP tool) | `acme/website#42` | `base`, `method`, `viaAgentRule` (`true` when an agent or workflow token relies on `allowAgentAutoMerge`); `byRule` and `agent` when a branch rule's `autoMergeAgents` turned it on, recorded as `diffinery-auto-merge[bot]`; `byDefault: true` when the author's own setting turned it on as the pull request opened | | `pull_request.auto_merge_disabled` | Auto-merge is turned off: by someone (**Disable auto-merge**, `DELETE .../auto-merge` or `disable_auto_merge`), by a push from someone else, by converting the pull request to a draft, or by itself when it can't go through. The actor is who turned it off, the pusher, or `diffinery-auto-merge[bot]`. | `acme/website#42` | `enabledBy` (login), and `reason` when it turned itself off | | `pull_request.auto_merged` | Auto-merge merges the pull request, as the account that turned it on. A `pull.merge` entry is written too. | `acme/website#42` | `base`, `method`, `sha` | | `pull_request.auto_merge_enqueued` | Auto-merge adds the pull request to the base branch's merge queue | `acme/website#42` | `base`, `state` | | `push.blocked_secret` | [Push protection](https://diffinery.com/docs/security/push-protection) blocks a push | `acme/website` | `kinds` (credential types found), `refs` | | `push.secret_bypassed` | A repository admin pushes past push protection with `git push -o allow-secrets=<reason>`, or a GitHub import imports old history with an audited bypass (see [Imported history](https://diffinery.com/docs/security/push-protection#imported-history)) | `acme/website` | `kinds`, `reason`, `refs`; for an import also `import` (its id) and `blobs` (up to 50 blob ids), and the reason is `Imported from GitHub <owner>/<name>` | | `api.blocked_secret` | [Push protection](https://diffinery.com/docs/security/push-protection#commits-and-writes-without-git) refuses a commit made with the web editor, `POST /repos/{owner}/{repo}/diffinery/commits`, the contents API or the MCP `commit_files` tool, or a file written with the git data API. The actor is the person or agent who made the request. | `acme/website` | `kinds`, and `paths` (up to 10), or `blob` for the git data API | | `api.secret_bypassed` | A repository admin who is a person commits past push protection with `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits` | `acme/website` | `kinds`, `paths`, `reason` | | `push.secret_marked` | A push lets through a file that opts out of push protection with `diffinery:allow-secret` and holds something that looks like a credential (see [Opting a file out](https://diffinery.com/docs/security/push-protection#opting-a-file-out)) | `acme/website` | `kinds`, `blobs` (up to 50 blob ids), `refs` | | `api.secret_marked` | The same, for a commit made without Git or a blob written with the git data API | `acme/website` | `kinds`, and `paths` (up to 10), or `blob` for the git data API | ### GitHub imports [Imports from GitHub](https://diffinery.com/docs/migrate-from-github) are recorded with the person or agent who started the import as the actor. Apart from `import.push` and the push protection entries, they don't fill in `on_behalf_of` for an agent. Entries written by the import service have the GitHub source (`diffy/hello-world`, or `diffy` for an organization import) as the target and the import's id as `import` in the detail. | Action | Recorded when | Target | Detail | |---|---|---|---| | `import.create` | An import is started (web form, `POST /api/v3/diffinery/imports`, `diffinery migrate github`, MCP `start_github_import`), or a draft organization import starts after its repositories are chosen | the GitHub source | `import`, `kind` (`repo` or `bulk`), `owner` (the Diffinery account imported into), and `name` and `options`, or `repositories` (how many were chosen) | | `import.push` | The import's Git transfer pushes history into the new repository. Replaces the `git.push` entries such pushes would otherwise write. | `acme/website` | `import`, `refs` (how many refs the push created or moved) | | `import.cancel` | Someone cancels the import (**Cancel import**, or `POST /api/v3/diffinery/imports/{id}/cancel`) | the GitHub source | `import`, `by` (who cancelled it) | | `import.retry` | Whoever started it retries a failed or cancelled import | the GitHub source | `import`, `by`, `attempt` | | `import.fail` | An import fails after its retries | the GitHub source | `import`, `error` | | `import.finish` | An import finishes. An organization import with repositories that weren't imported is recorded as `import.finish` too, and its status is `failed`. | the GitHub source | `import`, and `repo`, `counts` (issues, pull requests, comments, reviews, labels, milestones) and `findings` (how many push protection findings were bypassed), or `children` (counts of the organization import's repositories) | Old history imported past push protection writes `push.secret_bypassed`, and a push blocked during an import writes `push.blocked_secret` (see [Git and pull requests](#git-and-pull-requests)). Organization variables an import adds write `org.variable_set`. Nothing an import writes into the repository (issues, comments, reviews, labels, milestones, releases, branch protection, variables) writes other entries. ### Container packages | Action | Recorded when | Target | Detail | |---|---|---|---| | `package.create` | The first push creates a package | `acme/website` (owner and package name) | `repository` it is linked to | | `package.push` | An image manifest is pushed | `acme/website:latest` (with the tag or digest) | `digest` | | `package.update` | A package's visibility or linked repository changes | `acme/website` | the new settings | | `package.delete` | A package is deleted | `acme/website` | none | | `package.delete_version` | One version of a package is deleted | `acme/website@sha256:...` | none | ## Agents and on-behalf-of When an agent acts, the entry records the agent as the actor and the agent's owner in `on_behalf_of`, so the page reads, for example, `review-bot for diffy`. This applies to pushes, merges, push protection decisions (including `api.blocked_secret`) and settings changes made with the agent's token, on the web or through the API. (Agents can never bypass branch protection on a merge, so `pull_request.merge_bypassed` always names a person.) See [Agents](https://diffinery.com/docs/agents/overview). ## Security emails Some audit entries also send an email to the people affected. These emails are in the security group, which cannot be turned off: | Action | Email | |---|---| | `session.create` | "New sign-in to your account", for a passkey sign-in from a device not seen before, and for every recovery sign-in | | `passkey.add` | "A passkey was added to your account" | | `passkey.remove` | "A passkey was removed from your account" | | `agent.create` | "A new agent was added" | | `push.blocked_secret` | "Your push was blocked: possible secrets", to the pusher (or the agent's owner); for a GitHub import, to whoever started it | | `push.secret_bypassed`, `api.secret_bypassed` | "Push protection was bypassed", to the pusher (or committer) and the repository's admins | Deleting a repository (`repo.delete`) also emails the repository owner, or the organization's owners and admins, except the person who deleted it. Token creation emails are sent when the token is created. See [Notifications](https://diffinery.com/docs/accounts/notifications). ## Troubleshooting | Symptom | Cause | |---|---| | You don't see **Audit log** in the settings sidebar | Only site administrators can view it. Ask a site admin; see [Site administration](https://diffinery.com/docs/admin/site-administration). | | A push or merge appears a few seconds late | `git.push`, `git.force_push`, `git.delete_ref`, `pull.merge`, `pull_request.merge_bypassed`, the `pull_request.auto_merge_*` and `pull_request.auto_merged` entries, `repo.protection.agent_auto_merge`, `repo.protection.agent_approval_waiver`, `repo.protection.agent_auto_merge_rule`, `repo.actions_workflow_settings` and push protection entries (including `api.blocked_secret` and `api.secret_bypassed`) are written by the event queue shortly after the action, not in the request itself. | | An older entry is missing from the page | The page shows only the 200 most recent entries. | --- # Reporting a vulnerability > How to report a security problem in Diffinery, what is in scope, safe harbour for good-faith research, the response times we aim for, and why there is no bounty. Source: https://diffinery.com/docs/security/disclosure If you think you have found a security problem in Diffinery, please tell us privately first, so we can fix it before anyone else uses it. This page says how, what we look at, how you are protected when you research in good faith, and what to expect from us. ## How to report 1. Use the private report form at [`/security/report`](https://diffinery.com/security/report), the first `Contact:` in this instance's [`/.well-known/security.txt`](https://diffinery.com/.well-known/security.txt). Only the instance's site administrators can read what you send. You can send it signed in, or without an account after a quick check that you're a person ([Cloudflare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/)); then give an email address we can reply to. Diffinery's deploy sets Turnstile up for the form. If an instance still has none, the form asks you to sign in, and `security.txt` may list another contact (an email address or another form) after the first. 2. Write a report that lets us reproduce the problem. The form has a one-line summary, the details (up to 20,000 characters of plain text) and, optionally, the affected URL or area: - what is affected (a URL, an API endpoint, a git or MCP operation, a CLI command); - the steps, requests or a short script that show it, and what happened compared to what you expected; - what an attacker could do with it, as far as you know; - whether you have told anyone else, and when you would like to publish. 3. Write in English if you can. The form takes plain text only: describe screenshots or logs, and offer to send them once we reply. The site administrators get an email with your summary only, and read the rest in Diffinery. Your reference number is shown when you send the report; quote it if you write again. You can send up to 5 reports a day without signing in from one network, and 10 a day from one account. Please don't report in a public issue, a pull request, a discussion or social media. If the report includes a secret you came across (a token, a key, someone's data), say so and don't keep a copy. ## In scope - The Diffinery application: the web UI, the REST API (`/api/v3`), git over HTTPS, the MCP server, the container registry, webhooks, Actions (workflow runs, runners, OIDC tokens, secrets), agent tasks and sandboxes, and the `diffinery` CLI. - The source code of Diffinery itself, including the scripts that deploy it. - Problems such as: reading or changing data you shouldn't, getting around sign-in, branch protection, push protection, token scopes, agent guardrails or kill switches, escaping a job or sandbox container, reaching secrets, cross-site scripting or request forgery, and server-side request forgery. ## Out of scope - Cloudflare's own platform and services, and other third-party services. Report those to their owners. - Another operator's instance of Diffinery, unless the problem is in Diffinery's code: then report it here, and only test it on an instance you run yourself or have permission to test. - Denial of service by sheer volume, spam, and load testing. - Social engineering, phishing, and physical attacks on people or offices. - Reports from automated scanners with no shown impact, missing headers that change nothing in practice, and self-XSS. - Anything that needs a compromised device, browser or account to begin with. ## Safe harbour If you research and report in good faith and follow this page, we will consider your research authorised, we won't take legal action against you or ask anyone else to, and if someone else does we will say that you acted with our permission. Good faith means you: - test only against accounts and repositories that you own or have permission to use, and stop as soon as you have shown the problem; - don't access, change, keep or share other people's data beyond the minimum needed to show the problem, and don't degrade the service for others; - don't use what you found for anything other than reporting it; - give us a reasonable time to fix it before you publish anything, and agree the date with us. If you aren't sure whether something is allowed, ask first through the [report form](https://diffinery.com/security/report). This safe harbour covers Diffinery's own claims only. It can't speak for Cloudflare, other services, or the operator of an instance you don't own. ## What to expect These are the times we aim for, not promises: a small team runs Diffinery, and a hard problem can take longer. | Step | Target | | --- | --- | | We confirm we received your report | 3 working days | | We tell you whether we can reproduce it, and how serious we think it is | 10 working days | | We fix a critical or high-severity problem | 30 days | | We fix other problems | 90 days | We keep you informed while we work on it, tell you when the fix is released, and, if you want, credit you by name in the release notes. We ask you to wait for the fix, or for 90 days from your report if we haven't agreed another date, before publishing. ## No bounty There is no bug bounty: we don't pay for reports. We are grateful for every one, and are glad to credit you. ## See also - [Security overview](https://diffinery.com/docs/security/overview), for how Diffinery protects accounts, code and secrets. - [Configuration](https://diffinery.com/docs/self-hosting/configuration#security-contact), for operators: what `security.txt` serves, and adding contacts to it. The deploy sets up Turnstile for the form, so people without an account can report; its Cloudflare API token needs **Account → Turnstile → Edit** for that (see [Turnstile](https://diffinery.com/docs/self-hosting/configuration#turnstile)). - [Site administration](https://diffinery.com/docs/admin/site-administration#security-reports), for operators: reading and triaging reports. --- # Self-hosting > Diffinery is open source (Apache-2.0) and runs on your own Cloudflare account. What an instance needs, what it costs, the steps to deploy one, and where to go next. Source: https://diffinery.com/docs/self-hosting Diffinery is open-source software under the Apache License 2.0, and the way to use it is to run your own instance on your own Cloudflare account and domain. There's no hosted service to sign up for: diffinery.com is the project's own instance. This page is the overview; [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy) is the step-by-step guide. > **Want to try it before you deploy?** [Request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance. Its administrators review requests and email invitations; see [Early access](https://diffinery.com/docs/accounts/early-access). ## Get the source The repository is [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). Clone either one; both carry the same `main` branch, with the `LICENSE` and `NOTICE` files at the top. ```sh git clone https://github.com/codician-team/diffinery.git cd diffinery ``` ## What an instance needs | Requirement | Why | | --- | --- | | A Cloudflare account on the Workers Paid plan, with R2 turned on | Diffinery uses Workers, Durable Objects with SQLite, D1, R2, Queues, Workflows, Cron Triggers, Email Sending and Containers. Containers need the paid plan. | | A domain added to that account | The instance's hostname, such as `git.example.com`, is served through a Workers route on that zone. | | A Cloudflare Zero Trust organization | Cloudflare Access guards `/auth/access`, the email sign-in the first administrator uses and that lets people recover a lost passkey. | | A clone of the repository and somewhere to run the deploy | Your own machine with Node.js 22, pnpm 10, `jq`, `curl`, `openssl` and Docker, for `scripts/setup.sh`; or the repository's Deploy workflow, on a forge with GitHub-compatible Actions. | People who use your instance don't need a Cloudflare account; only the deployment does. See [What you need](https://diffinery.com/docs/self-hosting/deploy#what-you-need) for the full list. ## The steps The quickest way is `scripts/setup.sh` from a clone on your machine: one command checks your tools, API token and zone, then deploys, and running it again updates the instance. 1. [Create a Cloudflare API token](https://diffinery.com/docs/self-hosting/deploy#step-2-create-a-cloudflare-api-token) with the permissions the deploy needs. 2. [Choose your hostname and first administrators](https://diffinery.com/docs/self-hosting/deploy#step-1-choose-your-hostname-and-first-administrators): `DIFFINERY_DOMAIN` and `BOOTSTRAP_ADMINS`. You don't edit any code. 3. Run the deploy: ```sh CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... DIFFINERY_DOMAIN=git.example.com \ BOOTSTRAP_ADMINS=you@example.com scripts/setup.sh ``` Add `--dry-run` first to check everything without changing anything. See [Deploy from your own machine](https://diffinery.com/docs/self-hosting/deploy#deploy-from-your-own-machine). To deploy from a workflow on every push instead, see [Run the Deploy workflow](https://diffinery.com/docs/self-hosting/deploy#step-4-run-the-deploy-workflow). 4. [Set up email sending](https://diffinery.com/docs/self-hosting/deploy#step-5-set-up-email-sending) and [DNS](https://diffinery.com/docs/self-hosting/deploy#step-6-dns). 5. [Sign in as the first administrator](https://diffinery.com/docs/self-hosting/deploy#step-7-sign-in-as-the-first-administrator) at `/auth/access`, add a passkey, and invite your team from **Settings → People and invitations**. The deploy creates whatever is missing in your account, applies the database migrations, uploads the Worker and builds the runner image, and every step is safe to repeat. To update later, bring your copy up to date with upstream and deploy again; see [Updating](https://diffinery.com/docs/self-hosting/deploy#updating). ## What it costs Diffinery is free. Your instance's costs are your Cloudflare bill: the Workers Paid plan has "a minimum cost of $5 USD per month per account", plus usage above its allowances ([Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026), and runner and sandbox containers are billed by the 10 ms while they run ([Containers pricing](https://developers.cloudflare.com/containers/pricing/), checked October 25, 2026). Diffinery starts a container only when a job starts and stops it when the job ends, and per-account [quotas](https://diffinery.com/docs/reference/limits#account-quotas), which you set, cap what each account can use. ## After the first deploy - **Who can join.** An instance is invite-only by default. Set `SIGNUP=open` to let anyone create an account; see [SIGNUP](https://diffinery.com/docs/self-hosting/configuration#signup) and [Invitations](https://diffinery.com/docs/accounts/invitations). - **Limits.** Change the per-account quotas and the instance's hosted job limit with the `QUOTA_*` and `HOSTED_JOBS_LIMIT` variables; see [Configuration](https://diffinery.com/docs/self-hosting/configuration#worker-variables). - **Running it.** [Site administration](https://diffinery.com/docs/admin/site-administration) covers accounts, early access requests, email and audits, and the [status page](https://diffinery.com/docs/self-hosting/status-page) shows whether each part of your instance works. - **Your team's tools.** The CLI, the Claude Code and Codex plugins, the MCP server and the editor extensions all work with your instance. People install the CLI from it and run `diffinery setup` with its URL, which signs them in and points git, gh, the Claude Code plugins and Codex at it: `curl -fsSL https://git.example.com/install | bash`, then `diffinery setup --url https://git.example.com`. AI apps connect to `https://git.example.com/mcp`. See [Claude Code](https://diffinery.com/docs/connect/claude-code#self-hosted-diffinery), [Codex](https://diffinery.com/docs/connect/codex#self-hosted-diffinery), [Clone in your editor](https://diffinery.com/docs/repositories/clone-in-your-editor) and [Clone in JetBrains IDEs](https://diffinery.com/docs/repositories/clone-in-jetbrains-ides). - **How it fits together.** [Architecture](https://diffinery.com/docs/self-hosting/architecture) maps each part of Diffinery to the Cloudflare product that runs it. ## Get help and contribute Report bugs and send pull requests on [the GitHub mirror](https://github.com/codician-team/diffinery) or [the Diffinery repository](https://diffinery.com/codician/diffinery). Report security problems privately with the [report form](https://diffinery.com/security/report); see [Vulnerability disclosure](https://diffinery.com/docs/security/disclosure). --- # Site administration > Site administrators manage people and admins, handle abuse and security reports, suspend and delete accounts, stop Actions, post status incidents, oversee imports, and check the audit and email logs. Source: https://diffinery.com/docs/admin/site-administration Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, handle private security reports, post incidents on the status page, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**. ## Site administrators Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar with these pages: | Page | Path | What it is for | | --- | --- | --- | | **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links | | **Early access** | `/settings/admin/early-access` | Requests from people without an invitation, to invite, decline or delete | | **Reports** | `/settings/admin/reports` | What people reported as abusive, to look at and resolve | | **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email | | **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance | | **Security reports** | `/settings/admin/security-reports` | Private vulnerability reports from the report form, to read, triage and resolve | | **Status** | `/settings/admin/status` | Incidents on the public status page, to open, update and resolve | | **Search engines** | `/settings/admin/search-engines` | What Diffinery told search engines through IndexNow about new, changed and removed public pages | | **Audit log** | `/settings/audit` | The most recent security-relevant events | Their **Settings → Profile** page shows **Site admin** as yes, and they have an **admin** badge in the users list. ### Who becomes a site administrator The instance setting `BOOTSTRAP_ADMINS` is a comma-separated list of email addresses, set on deploy from the `BOOTSTRAP_ADMINS` environment variable (a deploy without it keeps the list the instance has; see [BOOTSTRAP_ADMINS](https://diffinery.com/docs/self-hosting/configuration#bootstrap_admins)). An account becomes a site administrator when: - it is created with an email address on the list (from an invite, after opening the link emailed to that address, or by verifying the email address through email recovery at `/auth/access`), or - it was waiting for an invite (pending) and the person verifies an email address on the list at `/auth/access`. Bootstrap administrators don't need an invite. Adding an address to the list later doesn't promote an account that is already active; use **Make admin** instead. See [Configuration](https://diffinery.com/docs/self-hosting/configuration) for the setting. ### Granting and removing site administration In the **Users** table on **Settings → People and invitations**, the **Site admin** column has a **Make admin** button for each active person who isn't a site administrator, and a **Remove admin** button for each one who is. Both ask you to confirm, and both need a sign-in from the last 2 hours: if yours is older, Diffinery asks you to sign in again and brings you back. You see "diffy is now a site administrator." or "diffy is no longer a site administrator." - Only active accounts can be made site administrators. Enable a pending or disabled account first. - The last site administrator can never be removed: "Diffinery needs at least one site administrator: make someone else a site administrator first". You can remove yourself while there is another one; you then lose access to these pages. - The person gets a security email saying who made the change. - Each change is recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `site_admin.grant` or `site_admin.revoke`, with who made it and the person it was about. ### First sign-in for administrators On a new instance nobody has a passkey yet. A bootstrap administrator signs in through email recovery: 1. Open `https://diffinery.com/auth/access`. 2. Confirm your email address with the one-time PIN that Cloudflare Access emails you. 3. On the page that follows (**Settings → Passkeys and sessions**), click **Add a passkey**. From then on you sign in with the passkey. Email recovery needs Cloudflare Access to be set up for `/auth/access`; see [Passkeys and sign-in](https://diffinery.com/docs/accounts/passkeys-and-sign-in). ### Tokens and site administration A site administrator's tokens are capped by their scope like anyone's: to act as admin on repositories through Git or the API, use an `admin`-scoped token. See [Personal access tokens](https://diffinery.com/docs/accounts/tokens). ## People **Settings → People and invitations** (`/settings/admin`, titled **Site administration**) has three sections: **Pending access requests**, **Invite someone to this instance** and **Users**. Other people get `403 site administrators only`. ### Pending access requests People who verified their email address at `/auth/access` without an invite have a pending account and can click **Request access** with a note. Each request is listed with the person's email address, when they asked and their note. Diffinery also emails up to 20 active site administrators about each request. - **Approve** makes the account active. The person can then use Diffinery, but isn't added to any organization or repository; invite them to one, by email address or by username. - **Deny** disables the account. The person gets a "Your account was disabled" email. See [Invitations](https://diffinery.com/docs/accounts/invitations#access-requests) for the requester's side. ### Users The **Users** table lists every person (not organizations, agents or deleted accounts) with their picture and username, email address, when they joined, their status and whether they are a site administrator. Each row has **Make admin** or **Remove admin** (see [Granting and removing site administration](#granting-and-removing-site-administration)), **Sign-in link** (see [Sign-in links](#sign-in-links)) and **Delete** (see [Deleting an account](#deleting-an-account)). For each user except yourself you can pick a status in the menu and click **Save**. You see "User updated." | Status in the menu | Value | Meaning | | --- | --- | --- | | **Active** | `active` | Can sign in and use everything their roles allow | | **Pending** | `pending` | Waiting for an invite: can sign in but only sees the **Waiting for an invite** page | | **Disabled** | `disabled` | Can't use Diffinery | A suspended person has a red **suspended** badge, and their status is `disabled` until you unsuspend them (see [Suspend an account](#suspend-an-account)). Each row also has **Suspend** (with a reason), or **Unsuspend** for someone suspended. You can't change your own status ("you cannot change your own status"). Pending accounts also become active without you when someone invites their email address (including with **Add people**), or when the person accepts an invitation sent to their username on the **Waiting for an invite** page. Being invited by username doesn't make the account active by itself. ### Disable an account Set the user's status to **Disabled** and click **Save**. Immediately: - every browser session they have ends, so they are signed out everywhere, and passkey sign-in is refused with "Your account is disabled.", - every access token the person has is revoked, - agents they own stop working, because an agent can never do more than its owner, - they get a "Your account was disabled" email. Their repositories, organizations and memberships stay as they are, but they can't be added to organizations or repositories while disabled ("diffy's account is disabled"). Set the status back to **Active** to enable the account; the person gets "Your account was enabled" and signs in with their passkey again. Their old tokens stay revoked, so they need to create new ones. Every status change is recorded in the audit log as `user.active`, `user.pending` or `user.disabled`. ### Deleting an account Click **Delete** on the person's row in the **Users** table. A page shows what will go: their repositories (with how many), container images, tokens, passkeys, agents, settings and memberships. Type their username and click **Delete this account**. It needs a sign-in from the last 2 hours, like granting site administration. You see "Deleted the account diffy." and the person gets an email saying a site administrator deleted their account. The page says why, and the button is off, when the person is the only owner of an organization (it names each one), the only site administrator, or has a GitHub import running. Make someone else an owner, or delete the organization, first. Deleting an account is the same as the person deleting it themselves: everything they own goes, what they wrote in other people's repositories is shown as written by **ghost**, and their username can't be used for 90 days. See [Exporting and deleting your account](https://diffinery.com/docs/accounts/account-data#deleting-your-account). It can't be undone; to keep someone out while you decide, disable the account instead. Each deletion is recorded in the audit log as `account.delete`, with `"via":"admin"`. ### Suspend an account Suspending is for abuse: like disabling, it stops the person at once, but it also hides what they made, and unsuspending puts everything back as it was, tokens included. It works as on GitHub Enterprise. In the **Users** table, click **Suspend** next to the person, enter a **Reason** (at most 500 characters; it is emailed to them and kept in the audit log) and click **Suspend diffy**. You can also suspend from a report about the person. Immediately: - every browser session they have ends, and passkey sign-in is refused with "Your account is disabled.", - their access tokens, their agents' tokens and their runners are refused (`401`), but not revoked, - running workflow runs and agent sandboxes in their repositories stop, and no new ones start, as with [Suspend Actions for an account](#kill-switches), - their profile, repositories (on the web, through Git, the API and the container registry) and packages answer `404` to everyone else, - their issues and pull requests answer `404` and are left out of lists and search, and their comments and reviews are left out of every conversation, - they get a "Your account was suspended" email with the reason. Site administrators still see all of it, with a **suspended** badge on the profile; `GET /api/v3/users/{username}` shows them `suspended_at` and `suspension_reason`. Site administrators can't be suspended, and you can't suspend yourself. **Unsuspend** makes the account active again. Their tokens, agents and runners work again, their content is shown again and they get a "Your account is no longer suspended" email. Their browser sessions stay ended, so they sign in again with their passkey. Workflow runs that were stopped don't restart. Suspensions are audited as `user.suspend` (with the reason and what was stopped) and `user.unsuspend`. From the API, with an `admin`-scoped token that isn't limited to repositories (GitHub Enterprise's endpoints): ```sh echo '{"reason": "spam in issues, see report 4f2c"}' | diffinery api PUT /users/diffy/suspended --input - diffinery api DELETE /users/diffy/suspended ``` Both answer `204`. Anyone else gets `403 site administrators only`. ## Early access While the instance is invite-only, people without an invitation can ask for one at `/early-access`, and confirm their address with an emailed link. **Settings → Early access** (`/settings/admin/early-access`) lists the requests: **Confirmed** ones wait for you, with their count next to **Early access** in the sidebar and a reminder on **People and invitations**. **Invite** sends the usual instance invitation (see [Invites](#invites)), **Decline** emails nobody, and **Delete request** removes everything stored about a request. Each decision is in the audit log. See [Early access](https://diffinery.com/docs/accounts/early-access#for-site-administrators) for the whole page, the API and how the form is protected. ## Reports Anyone signed in can report a person, a repository, an issue, a pull request or a comment as abusive, from the **…** menu on it (or on a profile) and **Report**. They pick a reason (spam, harassment or bullying, hateful content, violence or threats, sexual content, malware or phishing, impersonation, or something else) and can add a note of up to 2,000 characters; "Something else" needs the note. The person reported isn't told. One person files at most 20 reports a day, and one open report per thing. **Settings → Reports** (`/settings/admin/reports`) lists the open reports, newest first, with what was reported, the reason, who reported it and when; **Resolved** lists the rest. Open one to see: - a link to what was reported, and the account that wrote it, - the reporter's note, - what was reported as it was then (an issue's title and text, a comment's text), so editing or deleting it afterwards hides nothing, - **Resolve with a note**: say what you did (hid the comment, suspended the account, nothing to do) and click **Resolve**. A resolved report can be reopened with **Reopen**. - **Suspend** the account that wrote it, with a reason. Filing, resolving and reopening are audited as `report.create`, `report.resolve` and `report.reopen`. From the API, people file reports with `POST /api/v3/diffinery/reports`; site administrators read the queue with `GET /api/v3/admin/reports` (`?state=open`, `resolved` or `all`) and `GET /api/v3/admin/reports/{id}`, and resolve with `PATCH /api/v3/admin/reports/{id}` and `{"state": "resolved", "resolution": "..."}` (an `admin`-scoped token). See [REST API](https://diffinery.com/docs/reference/rest-api#moderation). Other tools for abuse: [blocking](https://diffinery.com/docs/accounts/blocking-and-reporting) (anyone, for themselves or their organization), [hiding comments](https://diffinery.com/docs/collaboration/issues#hide-a-comment) (anyone with write access to the repository; site administrators everywhere) and the [kill switches](#kill-switches). ## Invites Under **Invite someone to this instance**, enter an **Email address** and click **Send invitation** (the hint says "We email them a link. Opening it lets them pick a username and create a passkey."). The invite isn't tied to an organization or repository: it only lets the person create an account. Diffinery emails the link and also shows it to you under "Invite created for friend@example.com" with a copy button, with the note that it expires in 14 days and works once. Instance invitations can't be revoked. The same works from the CLI (`diffinery invite friend@example.com`, without `--org`) and the API (`POST /api/v3/invitations` with `{"email": "friend@example.com"}`); both are limited to site administrators. Site administrators can also add people to any organization with **Add people** on its **People** tab, and revoke its pending invitations there. See [Invitations](https://diffinery.com/docs/accounts/invitations) for all kinds of invites. ## Sign-in links When someone loses their passkeys, a site administrator can give them a one-time link that adds a new passkey to their existing account. 1. In the **Users** table, click **Sign-in link** next to the person. 2. Confirm "Create a one-time link that adds a passkey to diffy? Only send it to them." 3. The page shows **Sign-in link for diffy** with the link and a copy button. Diffinery has already emailed it to the person ("Your one-time sign-in link"); you can also send it yourself through a channel you trust. The person opens the link, creates a passkey and is signed in. See [Passkeys and sign-in](https://diffinery.com/docs/accounts/passkeys-and-sign-in#sign-in-link-from-an-administrator) for their side. | Rule | Value | | --- | --- | | Lifetime | 24 hours | | Uses | Once | | Disabled accounts | Refused with "enable this account first"; enable the account, then create the link | | Link format | `https://<host>/signin#<token>`. The token is in the URL fragment, which browsers never send to the server, so it stays out of request logs and `Referer` headers. The page removes it from the address bar. Older `/signin/<token>` links still work. | | Effect | Adds a passkey and signs that browser in; the person's other passkeys stay | | Audit | `signin_link.create` when you create it, `passkey.add` when it is used | Anyone with the link can add a passkey to the account, so send it only to that person. The link also works for a first sign-in, for an account that exists but has no passkey yet. ## Kill switches Kill switches stop Actions and agent sandboxes quickly, for abuse, a runaway workflow or a cost problem. There are three, from the narrowest: | Switch | What stops | What keeps working | | --- | --- | --- | | **Suspend Actions for a repository** | Every workflow run of the repository, on hosted and self-hosted runners, and its agent sandboxes (including agent tasks' and reviews') | Everything else in the repository: Git, issues, pull requests | | **Suspend Actions for an account** | The same, for every repository a person or an organization owns, including repositories created or transferred in later | The account's other repositories' Git and collaboration | | **Pause hosted compute** | Hosted Actions jobs and agent sandboxes in every repository on the instance | Self-hosted runners, which cost the instance nothing, and workflow runs themselves | Nobody is exempt, not even site administrators' own accounts and organizations (unlike [account quotas](https://diffinery.com/docs/reference/limits#account-quotas)). Every change is in the audit log: `actions.suspend` and `actions.unsuspend` with the repository (`owner/name`) or the account's login as the target, `compute.pause` and `compute.unpause`, each with the reason and what was stopped. ### While a switch is on While a repository or its owner is suspended: - no workflow run starts: pushes, pull requests, schedules and other events start nothing, and no run is recorded; - **Run workflow** (`workflow_dispatch`), `repository_dispatch` and **Re-run** are refused with `403 Actions are suspended for this repository by a site administrator: <reason>` (or `... for every repository of <account> ...`); - a job that would start fails instead, with "The job was not started: Actions are suspended ...", and a self-hosted runner isn't given a job that was already waiting; - a new sandbox is refused with the same `403`, so agent tasks and reviews fail when they try to start theirs, with that message. - [web terminals](https://diffinery.com/docs/agents/terminal) can't be opened or attached to, and open ones end within a minute ("A site administrator suspended Actions and sandboxes here"). While hosted compute is paused: - a job on a hosted runner fails without starting: "The job was not started: Hosted runners are paused on this instance by a site administrator: <reason>. Self-hosted runners still work." Jobs of the same run on self-hosted runners run as usual; - a new sandbox is refused with `403 Agent sandboxes are paused on this instance by a site administrator: <reason>` (agent tasks and reviews fail to start theirs), and a sandbox that went to sleep doesn't wake up. - web terminals can't be opened or attached to, and open ones end within a minute. The repository's **Actions** page, its run pages and its **Settings** pages show a red banner with the reason, so the people affected know why. While hosted compute is paused, every **Site administration** page shows "Hosted compute is paused on this instance" at the top, so nobody forgets to turn it back on. ### Turning a switch on stops what is running - Suspending a repository or an account cancels each of its unfinished runs ("Cancelled: Actions are suspended ...", including jobs on self-hosted runners) and closes each of its open sandboxes. - Pausing hosted compute fails each running hosted job ("The job was stopped: Hosted runners are paused ...") and closes the open sandboxes, in every repository with hosted compute running (Diffinery finds them from the hosted minutes it is counting). Jobs that wait in the queue fail when they would start. The answer says what was stopped: `stopped` with `repositories`, `runs`, `jobs`, `sandboxes`, and `failed` for repositories that didn't answer (their work still can't start). Turning a switch off only lets new work start again. Nothing that was stopped restarts: re-run the runs you want and start new sandboxes. ### From the web **Settings → Kill switches** (`/settings/admin/kill-switches`): - **Hosted compute** shows **running** or **paused**. Enter a **Reason** and click **Pause hosted compute** (you confirm first), or click **Resume hosted compute**. - **Suspend Actions for an account**: the person's or organization's login and a **Reason**, then **Suspend**. - **Suspend Actions for a repository**: `owner/name` and a **Reason**, then **Suspend**. - **Suspended** lists every suspended account and repository with who suspended it, when and why, and a **Lift** button. A reason is required (at most 500 characters): it is shown to the people affected and kept in the audit log. ### From the API Site administrators only (`403 only site administrators can ...` for anyone else). Reading needs any token that isn't limited to repositories; changing needs an `admin`-scoped token that isn't limited to repositories, or a browser session. | Method and path | Does | | --- | --- | | `GET /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | `suspended`, `reason`, `suspended_by`, `suspended_at`, and `owner_suspended` when the owner's Actions are suspended | | `PUT /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | Suspend, with `{"reason": "..."}` | | `DELETE /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | Lift | | `GET`, `PUT`, `DELETE /api/v3/admin/accounts/{login}/actions-suspension` | The same for a person or an organization | | `GET`, `PUT`, `DELETE /api/v3/admin/hosted-compute-pause` | `paused`, `reason`, `paused_by` and `paused_at`; pause with `{"reason": "..."}`, resume with `DELETE` | ```sh echo '{"reason": "crypto mining, see ticket 123"}' | diffinery api PUT /admin/repos/acme/website/actions-suspension --input - diffinery api DELETE /admin/repos/acme/website/actions-suspension echo '{"reason": "runner image incident"}' | diffinery api PUT /admin/hosted-compute-pause --input - ``` ## GitHub imports and linked accounts Site administrators have extra reach over [imports from GitHub](https://diffinery.com/docs/migrate-from-github): - **Import into any account.** A site administrator can start an import into any user's account or any organization, not only their own (agent accounts can't own imports). - **See and cancel any import.** Open `https://diffinery.com/imports/<id>`, or call `GET /api/v3/diffinery/imports/{id}`, for any import, and stop one with **Cancel import** or `POST /api/v3/diffinery/imports/{id}/cancel` (audited as `import.cancel`, with you in `by`). The **Imports** page and `GET /api/v3/diffinery/imports` still list only the imports you started yourself. - **Retrying stays with the starter, except for agents' imports.** A person's import can be retried only by that person, even by a site administrator. An import an agent started can also be retried by any person who may import into the target, which includes site administrators. ### Clear a GitHub link An import attributes a GitHub user's issues, pull requests, comments and reviews to the Diffinery account linked to that GitHub user. If a link is wrong, for example after a Diffinery account changed hands, a site administrator can remove it. There is no page for this; use the API with an `admin`-scoped token that isn't limited to repositories (or a browser session): ```sh diffinery api DELETE /diffinery/admin/linked-accounts/<link id> ``` The link id comes from `GET /api/v3/diffinery/user/linked-accounts`, called by the account that owns the link; Diffinery has no endpoint that lists other people's links. The request answers `204`, or `404 link not found`, and is audited as `account.link_cleared` with the link (`github.com/diffy`) as the target and the Diffinery account's id in the detail. Imports that already ran keep their attribution; later imports treat that GitHub user as a mannequin until someone links the account again. People unlink their own accounts on **Settings → Linked accounts** (`account.unlink_github`). ## Account quotas Every person and organization has [quotas](https://diffinery.com/docs/reference/limits#account-quotas) on hosted minutes per month, hosted jobs running at once, agent sandboxes open at once, repositories and storage. Accounts run by a site administrator have none. The defaults come from the `QUOTA_*` variables in [Configuration](https://diffinery.com/docs/self-hosting/configuration). There is no page for quotas; use the API: ```sh diffinery api GET /admin/accounts/acme/quotas echo '{"hosted_minutes": 6000, "repositories": -1, "storage_bytes": 53687091200}' | diffinery api PATCH /admin/accounts/acme/quotas --input - ``` | Request | What it does | Who | | --- | --- | --- | | `GET /api/v3/admin/accounts/{login}/quotas` | The account's quotas and what it uses: `hosted_minutes` (`used`, `limit`), `hosted_jobs` (`running`, `limit`), `sandboxes` (`open`, `limit`), `repositories` (`count`, `limit`), `storage_bytes` (`used`, `limit`, in bytes), with `exempt` and `period_start` (the start of the month, UTC) | site administrator, any token scope | | `PATCH /api/v3/admin/accounts/{login}/quotas` | Changes only the quotas you send: a whole number (`storage_bytes` in bytes, up to 1 PiB), `-1` for unlimited, or `null` to go back to the default. Answers like `GET`. Audited as `account.quotas.set`. | site administrator, with an `admin` token not limited to repositories, or a browser session | `{login}` is a person or an organization; an agent gets `404 no person or organization with that name`, because agents use their owner's quotas. Anyone else gets `403 only site administrators can see and change quotas`. People see their own usage with `GET /api/v3/user/usage`, and organization owners and admins see their organization's with `GET /api/v3/orgs/{org}/usage`. Storage is counted per repository as it changes: each repository's Durable Object keeps a running total of its git objects, Git LFS objects and release assets and reports it to D1 (`repo_storage`), where the owner's repositories and packages are added up. A repository that hasn't reported yet, such as one made before storage was counted, is measured the first time its account's storage is checked (up to 25 per check): its git objects are added up once from its own database, and its Git LFS objects are listed from R2 once. Purging unreachable objects counts the repository again. ## Audit log **Settings → Audit log** (`/settings/audit`) shows the 200 most recent security-relevant events: sign-ins, passkeys, tokens, invites, account status changes, repository and access changes, pushes, merges and push protection. Repository and organization settings changed through the REST API or the CLI are recorded too: repository settings, archiving, collaborators, branch protection, Actions secrets and variables (repository, environment and organization), webhooks, a repository's workflow token settings, and deleted Actions artifacts and caches. Imports from GitHub (`import.create`, `import.push`, `import.finish` and the rest) and linked GitHub accounts are recorded too. Settings changes made through the API have `via: api` in their **Detail**. Each row has **When**, **Actor** (and who an agent acted for), **Action**, **Target** and **Detail**. See [Audit log](https://diffinery.com/docs/security/audit-log) for every action and how to use it. ## Email **Settings → Email** (`/settings/admin/email`) shows whether the instance can send email and lets you check every template. | Item | What it shows | | --- | --- | | **Sending** | "Cloudflare Email Sending is bound", or "not configured (no EMAIL binding): emails are logged, not sent" | | **Queue** | `diffinery-mail`, or "not bound: emails are dropped" | | **From** | The address emails are sent from: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty (see [Configuration](https://diffinery.com/docs/self-hosting/configuration)) | | **Bounces and complaints** | Where they come from: the `diffinery-mail-events` queue, and whether the signed endpoint is on (`MAIL_EVENTS_SECRET` set) | | **Last 24 hours** | How many emails were sent, failed and skipped | Buttons: - **Send me a test email** sends one test email to you right away and shows the result. - **Send every template to me** queues one sample of every template to your address, ignoring your notification settings. They show up under **Recent emails** as they are sent. Under **Templates**, pick a template from the menu to preview it in the page. **Open HTML** opens the HTML version in a new tab; **Plain text** shows the text version with its headers (`From`, `Subject`, `X-Diffinery-Reason`, `List-Unsubscribe` and the rest). The previews use sample data such as the repository `acme/website`. **Suppressed addresses** lists every address Diffinery sends nothing to, newest first, with why (`hard-bounce`, `complaint` or `manual`), where that came from and since when. **Remove** sends email to the address again; **Suppress an address** adds one by hand, with an optional note. Both are recorded in the audit log. See [Bounces, complaints and the suppression list](#bounces-complaints-and-the-suppression-list). **Recent emails** lists the last 100 emails sent to anyone, with the time, recipient address, subject, template kind, Cloudflare's message ID, the status (`sent`, `failed` or `skipped`) and the reason when it wasn't sent. The log keeps 90 days. See [Notifications](https://diffinery.com/docs/accounts/notifications#every-email-diffinery-sends) for when each email is sent. ### Email API for administrators | Method and path | Description | Needs | | --- | --- | --- | | `GET /api/v3/diffinery/admin/email` | `sending` and `queue` (whether they are configured), `from` (the sender address: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty), `last_24h` counts (`sent`, `failed`, `skipped`) and the `last` email logged | site administrator, any token scope | | `POST /api/v3/diffinery/admin/emails/samples` | Send every template to yourself now, or only some with `{"only": ["signin", "invite-org"]}`. Returns `to`, `sent`, `total` and each result. | site administrator, with an `admin` token for all repositories or a browser session | | `GET /api/v3/diffinery/admin/email/suppressions` | The suppression list, newest first: `email`, `reason`, `detail`, `source` (`cloudflare`, `webhook`, `send` or `admin`), `created_by`, `created_at` and `owner_can_lift` | site administrator, any token scope | | `PUT /api/v3/diffinery/admin/email/suppressions/{email}` | Suppress an address by hand, with an optional `{"note": "..."}`. Audited as `email.suppression.add`. | site administrator, with an `admin` token or a browser session | | `DELETE /api/v3/diffinery/admin/email/suppressions/{email}` | Remove an address from the list, whatever put it there; `204`, or `404` when it isn't there. Audited as `email.suppression.remove`. | site administrator, with an `admin` token or a browser session | | `GET /api/v3/diffinery/emails?all=1` | Everyone's recent emails (without `all=1`, only yours). Also takes `kind`, `since` and `per_page` (up to 500). | site administrator for `all=1` | The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `model-key-added`, `review-limit`, `push-bypassed`, `push-blocked`, `commit-blocked`, `account-disabled`, `account-enabled`, `account-suspended`, `account-unsuspended`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `email-verify`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted`, `repo-transferred` and `test`. ### Bounces, complaints and the suppression list Diffinery sends no email at all to an address on the suppression list: not notifications, not security alerts, not sign-in links or account recovery. Such an email shows in **Recent emails** as `skipped` with "address is suppressed: ..." as the reason. An address gets on the list three ways: | Reason | How | | --- | --- | | `hard-bounce` | Email to it bounced permanently (the mailbox or domain doesn't exist, or the receiving server refuses it for good), or Email Sending refused to send to it for good (`E_RECIPIENT_SUPPRESSED`, source `send`). Soft bounces, delays and temporary send errors (rate and daily limits, delivery errors) change nothing. | | `complaint` | Its owner marked an email from Diffinery as spam. | | `manual` | A site administrator added it, under **Suppress an address** or with the API. | A newer event replaces an older one for the same address. Every addition is in the audit log as `email.suppression.add`. **Lifting a suppression.** A site administrator can remove any address (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`). People can lift a bounce or complaint on their own address themselves: **Settings → Email** shows that the address is suppressed and why, and **Send a verification email** sends one email to it, past the list. Following its link while signed in to the same account turns email back on (`email.suppression.lift` in the audit log). The link works for 24 hours, and at most 3 can be asked for in an hour. A `manual` suppression can only be removed by an administrator. See [Notifications](https://diffinery.com/docs/accounts/notifications#when-diffinery-stops-emailing-you). Cloudflare Email Sending keeps a suppression list of its own, for bounces and complaints it sees. When it refuses to send to an address on that list (`E_RECIPIENT_SUPPRESSED`, shown as `failed` in **Recent emails**), Diffinery adds the address to its own list as a `hard-bounce` with source `send`, so later emails are skipped. If you remove such an address from Diffinery's list, remove it in the Cloudflare dashboard too (**Email Service → Email Sending → Suppressions**), or the next email to it puts it back. #### Where bounces and complaints come from Cloudflare Email Sending publishes delivery events (`message.delivered`, `message.bounced`, `message.complained` and others) through [event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/) to a queue. Diffinery consumes the `diffinery-mail-events` queue, and **the deploy sets it all up**: `scripts/bootstrap.sh` creates the queue and, when it is missing, an event subscription named `diffinery-mail-events <domain>` with source **Email Sending**, your sending domain (`MAIL_DOMAIN`, or `DIFFINERY_DOMAIN`), and the **bounced** and **complained** events. A subscription you made yourself for that domain and queue is kept; any of those two events it lacks are added. Other events are ignored. The deploy's API token needs **Account → Queues → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/queues/subresources/subscriptions/methods/create/) calls it "Queues Write"), and **Zone → Zone → Read** on the zone of your sending domain to look up its zone ID; the domain must be onboarded to Email Sending. Without them the deploy goes on and prints a warning such as `Could not subscribe diffinery-mail-events to Email Sending's bounce and complaint events for example.com. The token needs Account → Queues → Edit ...`, followed by Cloudflare's error. Then either add the permission to the token and deploy again, or subscribe the queue by hand once: 1. In the Cloudflare dashboard, open **Storage & databases → Queues** and select `diffinery-mail-events`. 2. On the **Subscriptions** tab, click **Subscribe to events**. 3. Name the subscription (for example `diffinery-bounces`), pick **Email Sending** as the source and your sending domain (the domain of `MAIL_FROM`), and select at least the **bounced** and **complained** events. Others are ignored. 4. Click **Subscribe**. With Wrangler, the same is `wrangler queues subscription create diffinery-mail-events --source email.sending --zone-id <zone id> --domain <sending domain> --events message.bounced,message.complained`. Only Cloudflare can publish to the queue, so its messages need no signature. Until the subscription exists, bounces and complaints reach the list only when Email Sending refuses a send (see above); administrators can still add and remove addresses by hand. **The signed endpoint.** Anything else that can relay bounces and complaints, such as another provider's webhook through a small relay, can POST them to `https://<your host>/-/email/events`. It is off (`404`) until you set the `MAIL_EVENTS_SECRET` Worker secret, and it refuses (`401`) any request without a good signature: - Header `X-Diffinery-Signature: t=<unix seconds>,v1=<signature>`, where the signature is the hex HMAC-SHA256, keyed with `MAIL_EVENTS_SECRET`, of `<t>.<the raw request body>`. Requests more than 5 minutes old (or ahead) are refused, so a captured request can't be replayed later. - The body is JSON: one event, an array of events, or `{"events": [...]}`, at most 1,000 events and 1 MB. Each event has Cloudflare's shape: a `type` ending in `message.bounced` or `message.complained`, and a `payload` with `recipient` and, for bounces, `bounce.type` (`hard` or `soft`) and `bounce.reason`. - The answer is `{"suppressed": <n>, "ignored": <n>}`. Events of any other type, and soft bounces, are ignored. ```sh secret=... # the value of MAIL_EVENTS_SECRET body='{"type":"message.bounced","payload":{"recipient":"someone@example.com","bounce":{"type":"hard","reason":"550 5.1.1 User unknown"}}}' t=$(date +%s) sig=$(printf '%s' "$t.$body" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.* //') curl -sS https://diffinery.example/-/email/events -H 'content-type: application/json' \ -H "X-Diffinery-Signature: t=$t,v1=$sig" --data "$body" ``` Set the secret with `wrangler secret put MAIL_EVENTS_SECRET -c wrangler.deploy.jsonc` (see [Configuration](https://diffinery.com/docs/self-hosting/configuration#worker-secrets)). ## Security reports `/.well-known/security.txt` names the instance's private report form, `/security/report`, as its first contact (see [Configuration](https://diffinery.com/docs/self-hosting/configuration#security-contact)). Anyone can send a report there: signed in, or without an account after Cloudflare Turnstile, which the deploy sets up when its API token has **Account → Turnstile → Edit** (see [Turnstile](https://diffinery.com/docs/self-hosting/configuration#turnstile); without it, only people who are signed in can report). A report has a one-line summary (up to 200 characters), details (plain text, up to 20,000 characters), optionally the affected URL or area, and a reply-to address: required without an account, optional when signed in, when the account's own address is used. Limits: 5 reports a day from one IP address for people who aren't signed in, 50 a day from all of them together, 10 a day per account, and the sign-in rate limit of 20 requests a minute per IP address. Only site administrators can see reports. To anyone else the pages and the API answer `404`, as if they didn't exist. Each new report emails every active site administrator (`security_report`, an email that always sends). The email has the summary and a link to the report, never the details: read those in Diffinery. **Settings → Security reports** (`/settings/admin/security-reports`) lists reports newest first, with when they arrived, the summary, who sent it (a username, or "not signed in") and the state: **New**, **Triaged** or **Resolved**. The **Show** menu filters by state. Click a summary to open the report: its state, when it arrived, the reporter, the reply-to address, the affected URL or area, its reference (the id the reporter was shown) and the details as plain text. Under **Triage**, pick a state, write a note for the other administrators and click **Save**; you see "Report updated." Each change is in the audit log (`security_report.triage`, `security_report.resolve`, or `security_report.reopen` when set back to **New**), with the note. Reply to the reporter from your own mailbox: Diffinery doesn't email them. | Method and path | Description | Needs | | --- | --- | --- | | `GET /api/v3/diffinery/admin/security-reports` | Reports, newest first: `id`, `state`, `summary`, `details`, `area`, `reply_to`, `reporter` (a login, or `null`), `note`, `updated_by`, `updated_at`, `created_at` and `html_url`. Takes `state` (`new`, `triaged` or `resolved`) and `per_page` (default 100, up to 500). | site administrator, a `read` token not limited to repositories, or a browser session | | `GET /api/v3/diffinery/admin/security-reports/{id}` | One report, as in the list | site administrator, a `read` token not limited to repositories, or a browser session | | `PATCH /api/v3/diffinery/admin/security-reports/{id}` | `{"state": "triaged", "note": "..."}`: sets the state (`triaged`, `resolved`, or `new` to reopen) and the note (up to 2,000 characters; leaving it out clears it). Answers with the report. Audited like **Save**, with `via: api`. | site administrator, with an `admin` token not limited to repositories, or a browser session | ```sh diffinery api GET '/diffinery/admin/security-reports?state=new' echo '{"state": "resolved", "note": "Fixed in 2026.10.15; reporter credited."}' | diffinery api PATCH /diffinery/admin/security-reports/<id> --input - ``` ## Status Every instance has a public [status page](https://diffinery.com/docs/self-hosting/status-page) at `/status`, with JSON at `GET /api/v3/diffinery/status`: each component's status, incidents in progress and those resolved in the last 14 days. Anyone can read it without signing in. **Settings → Status** (`/settings/admin/status`) shows what the page says now, a form to open an incident (a title, the impact, the affected components, a state and the first update), each open incident with a form to post an update or resolve it, and the recent incidents. While an incident isn't resolved, its impact sets the status of each component it names; pausing hosted compute (see [Kill switches](#kill-switches)) puts **Actions** and **Agents and sandboxes** under maintenance without one. Each change is in the audit log (`status_incident.create`, `status_incident.update` or `status_incident.resolve`). Incidents and their updates are public, so keep internal details out of them. | Method and path | Description | Needs | | --- | --- | --- | | `GET /api/v3/diffinery/admin/status/incidents` | Incidents, newest first, with their updates | site administrator, a `read` token not limited to repositories, or a browser session | | `POST /api/v3/diffinery/admin/status/incidents` | `{"title": "...", "impact": "major", "components": ["git"], "state": "investigating", "body": "..."}`: opens an incident | site administrator, with an `admin` token not limited to repositories, or a browser session | | `POST /api/v3/diffinery/admin/status/incidents/{id}/updates` | `{"state": "resolved", "body": "..."}`: posts an update, which resolves the incident when its state is `resolved` | site administrator, with an `admin` token not limited to repositories, or a browser session | To anyone else these endpoints answer `404`. The status page runs in the instance's own Worker, so it is down whenever the whole Worker is: watch `/healthz` with an external uptime monitor too (see [Monitoring from outside](https://diffinery.com/docs/self-hosting/status-page#monitoring-from-outside)). ## Search engines Diffinery tells search engines about public pages that are added, change or go away through [IndexNow](https://www.indexnow.org), so Bing, Yandex, Naver, Seznam, Yep and the other engines that share IndexNow submissions recrawl them within minutes instead of whenever they next visit. Nobody needs to do anything: it runs by itself after every deploy. What it covers: - **After each deploy**, the pages the deploy added, changed or removed: the home page, the product, comparison and company pages (`/features`, `/compare/github`, ...), every docs page, each of those pages' Markdown version (`/index.md`, `/compare/github.md`, `/docs/quickstart.md`, ...), `/llms.txt`, `/llms-full.txt`, and `/early-access` while the instance is invite-only. These are the pages in `/sitemap.xml` plus their Markdown versions. A deploy that changes no page's content submits nothing. - **Every hour**, public repositories pushed since they were last submitted (their page, `/<owner>/<repo>`, as the sitemap lists it). - **Never** a private repository, a repository whose owner is suspended, or any page behind sign-in (settings, notifications, the API, sign-in pages). A repository is only submitted in the same check that found it public, so one made private in the meantime isn't sent, and it is forgotten. Removed site and docs pages are submitted once more, so search engines learn they are gone. How it works: the Worker's five-minute cron notices a new deploy, hashes each page's content and compares the hashes with the ones it last submitted, then sends only the differences in one request (up to 10,000 URLs; more wait for the next run) to `api.indexnow.org`. Engines share what any of them receives, so when `api.indexnow.org` answers `429` or `5xx` or can't be reached, the same request goes to Bing's own endpoint (`www.bing.com/indexnow`), then Yandex's (`yandex.com/indexnow`): IndexNow limits requests per IP address, and a Worker sends from addresses it shares with other Workers. A run on an older version during a deploy's rollout is ignored. Submitting happens after the deploy, in the background, so it can't fail or slow one. If every endpoint answers `429 Too Many Requests`, the pages stay queued for an hour; after another refusal (`400`, `403`, `422`) for 6 hours; if it can't be reached or fails (`5xx`, a timeout), for 15 minutes. IndexNow's `200 OK` and `202 Accepted` (key validation pending) count as submitted. The key that proves submissions come from this site is served at the site root, `/<key>.txt`. It is 32 hex characters derived from the instance's `MASTER_KEY` and `PUBLIC_URL`, so each instance has its own and nothing needs configuring. [Rotating MASTER_KEY](https://diffinery.com/docs/self-hosting/configuration#rotating-master_key) gives the instance a new key, which the next submission uses. **Settings → Search engines** (`/settings/admin/search-engines`) shows whether IndexNow is on and its key file, how many pages and public repositories it tracks and how many are waiting, and the newest submissions: when, how many URLs, which endpoint answered, its answer and how long it took, any error, and the pages sent. The newest 200 submissions are kept. The same is available over the API: ```bash diffinery api GET "/diffinery/admin/indexnow?per_page=10" ``` Anyone can see whether it is on and how the last submission went, without the pages, in `GET /api/v3/meta` (`indexnow`). Each submission is also logged in the Worker's logs as `indexnow: <n> URLs, <status> in <ms> ms`. IndexNow is off when `INDEXNOW` is `off`, when `PUBLIC_URL` isn't a public `https` origin (`localhost`, an IP address or `http`, as in local development), or when `MASTER_KEY` isn't set. See [Configuration](https://diffinery.com/docs/self-hosting/configuration#worker-variables). ## Workflow scheduler Scheduled workflows are started by Diffinery's own timer, every 5 minutes, which also catches up checks that came late (see [Scheduled workflows](https://diffinery.com/docs/actions/events#schedule)). When a scheduled run doesn't start, read the scheduler's state to tell a timer that stopped from a run that failed to start: ```bash diffinery api GET /diffinery/admin/scheduler ``` ```json { "tick_minutes": 5, "last_tick_at": "2026-10-13T17:30:01.204Z", "covered_until": "2026-10-13T17:30:00.000Z", "next_tick_at": "2026-10-13T17:35:00.000Z" } ``` | Field | Meaning | | --- | --- | | `tick_minutes` | Minutes between checks (`SCHEDULE_TICK_MINUTES`, default 5) | | `last_tick_at` | When the last check finished, or `null` before the first | | `covered_until` | The last minute whose schedules have been started | | `next_tick_at` | When the next check is due, or `null` if the timer isn't set (the cron trigger sets it again within 5 minutes) | A `last_tick_at` far behind the time means checks are failing; look in the Worker's logs for `schedule tick failed`. Site administrators only, with any token scope. How it works is in [Architecture](https://diffinery.com/docs/self-hosting/architecture#workflow-schedules). ## Server errors When Diffinery answers a request with a 5xx status, it keeps a record of the error. The answer's JSON includes a `request_id`, which also appears in the `x-request-id` header, and an error page shows it as **Request ID**. Look the request ID up under **Settings → Server errors**, or over the API: ```bash diffinery api GET /diffinery/admin/errors/<request id> diffinery api GET "/diffinery/admin/errors?per_page=20" ``` Each record has the request's method, `route` (the matched route pattern), `path`, `status`, `message` and `stack`. Only the newest 500 errors are kept. Records never include request bodies, query strings or headers. Tokens that appear in a message are masked, and a path that carries a secret, such as a sign-in link, is stored as its route pattern instead. On Cloudflare, the request ID is the request's Ray ID, so the same request can also be found in Cloudflare's logs. ## Administration from the CLI The CLI covers invites and, through `diffinery api`, any API endpoint: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login # Invite someone to the instance (site administrators only) diffinery invite friend@example.com # Invite someone to an organization as an admin diffinery invite friend@example.com --org acme --role admin # Email delivery status diffinery api GET /diffinery/admin/email # Send two sample emails to yourself (needs an admin-scoped token) echo '{"only": ["signin", "test"]}' | diffinery api POST /diffinery/admin/emails/samples --input - # Everyone's recent emails, with their status diffinery api GET /diffinery/emails -f all=1 -f per_page=100 ``` `diffinery api METHOD PATH` sends an authenticated request; `PATH` is relative to `/api/v3`. `-f key=value` adds a field (a query parameter for `GET`), and `--input FILE` (or `-` for standard input) sends a JSON body. The CLI also manages tokens, agents and organization secrets; see [CLI reference](https://diffinery.com/docs/reference/cli). Approving access requests, changing account status and creating sign-in links are only available on the **People and invitations** page. ## Troubleshooting | Message | Cause and fix | | --- | --- | | `403 site administrators only` | Your account isn't a site administrator. | | `403 site admin only` from `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | Your account isn't a site administrator. | | `404 link not found` | No GitHub link has that id. Ask the account's owner for the id from `GET /api/v3/diffinery/user/linked-accounts`. | | "you cannot change your own status" | Ask another site administrator. | | `422 ... site administrators can't be suspended` | Site administrators can't be suspended. | | `422 give a reason for the suspension` | Suspending needs a reason. | | `403 Actions are suspended ... by a site administrator` | A kill switch is on for the repository or its owner. Lift it under **Settings → Kill switches**. | | "Hosted runners are paused on this instance" | Hosted compute is paused. Resume it under **Settings → Kill switches**, or run the job on a self-hosted runner. | | `422 give a reason` | Kill switches need a reason. | | "enable this account first" | Sign-in links can't be created for disabled accounts. Set the status to `active`, then create the link. | | "enter a valid email address" | The invite form needs an address like `friend@example.com`. | | `403 only site admins can invite people without an organization` | Instance invites from the CLI or API need a site administrator; others must pass `--org`. | | **Sending** says "not configured" | The Worker has no `send_email` binding, so emails are logged as skipped. See [Configuration](https://diffinery.com/docs/self-hosting/configuration). | | Emails show as `failed` | The **Recent emails** detail has Cloudflare's error. Check the sending domain's DNS records and the queue. | | Emails show as `skipped` with "address is suppressed" | The address bounced, reported spam or was suppressed by hand. See [Bounces, complaints and the suppression list](#bounces-complaints-and-the-suppression-list). | | `E_RECIPIENT_SUPPRESSED` | The address is on Cloudflare's own suppression list. Remove it in the Cloudflare dashboard. | | `401 missing or bad signature` from `/-/email/events` | The `X-Diffinery-Signature` header is missing, signed with another secret, or more than 5 minutes old. | --- # Deploy your own Diffinery > Deploy Diffinery to your own Cloudflare account and domain with scripts/setup.sh or the Deploy workflow, sign in as the first admin, and develop locally. Source: https://diffinery.com/docs/self-hosting/deploy Diffinery runs entirely on Cloudflare, so you can run your own instance on your own Cloudflare account and domain. This page walks through a first deployment, the first administrator sign-in, email and DNS setup, updates, local development and the errors the scripts print. Examples use `git.example.com` as your instance's hostname; replace it with yours. There are two ways to deploy, and both run the same scripts: | Way | When to use it | | --- | --- | | `scripts/setup.sh` from a clone on your machine | The quickest start. One command checks your tools, API token and zone, then deploys or updates. See [Deploy from your own machine](#deploy-from-your-own-machine). | | The Deploy workflow (`.github/workflows/deploy.yml`) | Deploys on every push to `main` once your copy of the repository lives on a forge with GitHub-compatible Actions. See steps 3 and 4. | The short version, after [creating an API token](#step-2-create-a-cloudflare-api-token): ```sh git clone https://diffinery.com/codician/diffinery.git && cd diffinery CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... DIFFINERY_DOMAIN=git.example.com \ BOOTSTRAP_ADMINS=you@example.com scripts/setup.sh ``` For what each Cloudflare product does in Diffinery, see [Architecture](https://diffinery.com/docs/self-hosting/architecture). For every setting, see [Configuration](https://diffinery.com/docs/self-hosting/configuration). ## What you need | Requirement | Why | | --- | --- | | A Cloudflare account on the Workers Paid plan | Diffinery uses Containers (hosted runners, sandboxes and the Git transfers of GitHub imports), Durable Objects with SQLite, Workflows, Queues, D1, R2, Cron Triggers and Email Sending. Containers need the paid plan, and the Worker asks for 300,000 ms of CPU time per request. | | R2 enabled on the account | Large git objects, LFS objects, logs, caches, artifacts, container images, release assets, cached source archives and profile pictures are stored in R2. | | A domain added to the account as a zone | Diffinery serves `https://<hostname>/*` through a Workers route on that zone. | | A Cloudflare Zero Trust organization | Cloudflare Access guards `/auth/access`, the email-based sign-in that the first administrator uses and that lets people recover a lost passkey. Its one-time PIN login works without extra setup. | | A clone of the Diffinery repository | `scripts/setup.sh` deploys from it (see [Deploy from your own machine](#deploy-from-your-own-machine)). To deploy from a workflow instead, keep your copy on a forge with GitHub-compatible Actions: the Deploy workflow (`.github/workflows/deploy.yml`) runs on `ubuntu-latest` runners, on GitHub or on a Diffinery instance. | Tools the workflow installs or expects on its runner, and that `scripts/setup.sh` checks for on your machine: Node.js 22, pnpm 10 (the repository pins `pnpm@10.28.0`), `jq`, `curl`, `openssl` and Docker (wrangler builds the runner container image from `containers/runner/Dockerfile` during `wrangler deploy`). Nobody who uses your instance needs a Cloudflare account. Only the deployment uses one. ## How a deployment works One run of the Deploy workflow, or of `scripts/setup.sh`, does everything, and every step is safe to repeat: 1. Install dependencies, build the runner agent, the web UI's client bundles and the documentation (the pages under `apps/diffinery/docs`, the CLI reference and the CLI file served at `/-/cli/diffinery.mjs`), typecheck and run the unit tests. 2. `scripts/bootstrap.sh` creates whatever is missing in your account (D1 database, R2 bucket, queues, the Turnstile widget for the security report form, the Cloudflare Access application for `/auth/access`), sets the R2 bucket's lifecycle rules, checks Email Sending, writes `apps/diffinery/wrangler.deploy.jsonc` with the real IDs, your hostname's route, `PUBLIC_URL`, email sender and `BOOTSTRAP_ADMINS`, and applies D1 migrations. 3. `wrangler deploy -c wrangler.deploy.jsonc` uploads the Worker, the Durable Object classes, the two Workflows (workflow runs and GitHub imports) and the cron trigger, and builds and pushes the runner image for Containers. 4. `scripts/wait-container-rollout.sh` waits until the container rollouts of the new runner image reach their last step. Even then, an instance the rollout hasn't updated yet can start a container on the old image. Diffinery turns away a runner that reports any image other than `RUNNER_IMAGE` and starts the job again, up to four times. The live test waits until jobs report the deployed image (`runner_image` on `GET /api/v3/meta`) and then checks that every job ran on it. Running containers keep the old image until they finish. It gives up with a warning, not a failure, after `ROLLOUT_WAIT_MINUTES` (10 by default). 5. `scripts/ensure-secrets.sh` creates the Worker's two secrets, `MASTER_KEY` and `OIDC_PRIVATE_KEY`, the first time only, and sets `TURNSTILE_SECRET` when it's missing. 6. On the very first deploy, bootstrap runs again to finish the Access setup, and the Worker is redeployed. 7. Smoke tests check `/healthz`, the sign-in pages, static assets and D1 sessions. ## Step 1: Choose your hostname and first administrators You don't edit any code to deploy your own instance. `apps/diffinery/wrangler.jsonc` names `diffinery.com`, the project's public instance, in its route, `PUBLIC_URL` and `MAIL_FROM`; on every deploy, `scripts/bootstrap.sh` passes your hostname to `scripts/render-config.mjs`, which writes your values into the deployed config instead (see [scripts/render-config.mjs](#scriptsrender-configmjs)). Decide these settings, which both ways of deploying read from the environment: | Setting | Example | Meaning | | --- | --- | --- | | `DIFFINERY_DOMAIN` | `git.example.com` | Your instance's hostname, without `https://` or a path. Diffinery is always served over HTTPS at the root of that hostname. The deployed Worker gets the route `<DIFFINERY_DOMAIN>/*`, `PUBLIC_URL` `https://<DIFFINERY_DOMAIN>` and `MAIL_FROM` `notifications@<DIFFINERY_DOMAIN>`. | | `BOOTSTRAP_ADMINS` | `you@example.com,ops@example.com` | Comma-separated emails of the people who become site administrators when they first sign in (see [Step 7](#step-7-sign-in-as-the-first-administrator)). | | `DIFFINERY_ZONE` | `example.co.uk` | Only when the Cloudflare zone that holds your hostname is not its last two labels. For `git.example.com` the default zone is `example.com`, which is right; for `git.example.co.uk` the default would be `co.uk`. | | `MAIL_DOMAIN` | `example.com` | Only to send email from another domain than your hostname, here `notifications@example.com`. | | `ACCESS_TEAM_DOMAIN` | `example.cloudflareaccess.com` | Only when the API token can't read your Zero Trust organization. | **`BOOTSTRAP_ADMINS` on later deploys.** The repository's `wrangler.jsonc` leaves `BOOTSTRAP_ADMINS` empty. A deploy takes the list from the `BOOTSTRAP_ADMINS` environment variable; when that is empty, from `wrangler.jsonc` if your copy sets it there; and otherwise it keeps the list the deployed Worker already has. So you set the list once, for example on your first `scripts/setup.sh` run, and later deploys, including the Deploy workflow, keep it. On a brand-new instance with no list, the deploy warns that nobody will become a site administrator. Admin status is stored with each account when it is created, so the list only matters for accounts that don't exist yet. See [BOOTSTRAP_ADMINS](https://diffinery.com/docs/self-hosting/configuration#bootstrap_admins). **`.github/workflows/deploy.yml`** needs no changes either. It reads your hostname from repository variables you add in step 3: - `DIFFINERY_DOMAIN`: your hostname, for example `git.example.com`. `scripts/bootstrap.sh` uses it for the Worker's route, `PUBLIC_URL`, the email sender, the Access application and the Email Sending check, and the workflow uses it as the `production` environment's URL and for its checks after the deploy. Without it, everything assumes `diffinery.com`, and the deployed Worker gets the `diffinery.com/*` route and `PUBLIC_URL` `https://diffinery.com` whatever `wrangler.jsonc` says. - `DIFFINERY_ZONE`, if the Cloudflare zone that holds your hostname is not its last two labels. For `git.example.com` the default zone is `example.com`, which is right; for `git.example.co.uk` the default would be `co.uk`, so set `DIFFINERY_ZONE` to `example.co.uk`. - `MAIL_DOMAIN`, if email should come from another domain than your hostname, for example `example.com` to send from `notifications@example.com`. The Deploy job runs on every push to `main` when your copy lives on a Diffinery instance, and whenever you start it by hand. On GitHub it doesn't run on pushes; start it by hand there (`workflow_dispatch`). The workflow doesn't pass `BOOTSTRAP_ADMINS`. Set the list with a first deploy from your machine (`scripts/setup.sh`), or put it under `vars` in your copy's `wrangler.jsonc`; after that, workflow deploys keep it. **Other workflows.** `ci.yml` (typecheck, unit tests and the Worker bundle) and `e2e.yml` (the end-to-end tests) work as they are. `live-test.yml` checks the upstream project's public instance, `diffinery.com`, and runs on its own after a deploy only there; delete it, or change its `DIFFINERY` host, for your own instance. `d1-replication.yml` is optional; see [Architecture](https://diffinery.com/docs/self-hosting/architecture#d1-and-read-replication). Any other workflow in `.github/workflows` is the upstream project's own maintenance, and you can delete it from your copy. > **Note:** The scripts use fixed resource names: a D1 database `diffinery`, an R2 bucket `diffinery-data`, queues `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail`, `diffinery-mail-dlq` and `diffinery-mail-events`, and a Worker `diffinery`. One Cloudflare account holds one instance unless you rename them in `scripts/bootstrap.sh` and `wrangler.jsonc`. ## Step 2: Create a Cloudflare API token In the Cloudflare dashboard, create an API token (**My Profile → API Tokens → Create Token → Create Custom Token**) with these permissions: | Kind | Permission | Level | Used for | | --- | --- | --- | --- | | Account | Workers Scripts | Edit | Deploying the Worker, Durable Objects and secrets | | Account | D1 | Edit | Creating the database and applying migrations | | Account | R2 (Workers R2 Storage) | Edit | Creating the bucket and setting its lifecycle rules | | Account | Queues | Edit | Creating the queues and consumers, and subscribing `diffinery-mail-events` to Email Sending's bounce and complaint events | | Account | Workflows | Edit | The `diffinery-run` and `diffinery-import` Workflows | | Account | Containers | Edit | Building and deploying the runner image for runners, sandboxes and imports | | Account | Access: Apps and Policies | Edit | The Access application for `/auth/access` | | Account | Turnstile | Edit | The Turnstile widget that lets people without an account send [security reports](https://diffinery.com/docs/security/disclosure) (the API reference calls it "Turnstile Sites Write"). Without it the deploy warns and goes on, and only people who are signed in can send reports. | | Zone (the zone that holds your hostname, `DIFFINERY_ZONE`) | Workers Routes | Edit | The `git.example.com/*` route | | Zone (your zone) | Zone Read | Read | `scripts/setup.sh` checks that the zone is in the account, and bootstrap finds the zone ID of your sending domain for the bounce and complaint subscription. Optional for the Deploy workflow. | | Zone (your zone), optional | DNS Read, Email Sending Read | Read | Letting `scripts/email-sending.sh` check your Email Sending setup through the API. Without them it falls back to public DNS lookups. | If the token can't read your Zero Trust organization, bootstrap can't discover your Access team domain on its own; set the `ACCESS_TEAM_DOMAIN` variable (next step). Copy your **Account ID** from the dashboard (**Workers & Pages** overview, or any zone's overview page). ## Step 3: Add repository secrets and variables In your copy's repository settings, add: | Name | Kind | Value | | --- | --- | --- | | `CLOUDFLARE_API_TOKEN` | Secret | The API token from step 2. | | `CLOUDFLARE_ACCOUNT_ID` | Secret | Your Cloudflare account ID. | | `DIFFINERY_DOMAIN` | Variable | Your hostname, for example `git.example.com`. Without it, the deploy targets `diffinery.com`. | | `DIFFINERY_ZONE` | Variable, optional | The Cloudflare zone that holds the hostname, when it isn't the hostname's last two labels. | | `MAIL_DOMAIN` | Variable, optional | The domain email is sent from, when it isn't `DIFFINERY_DOMAIN`. | | `ACCESS_TEAM_DOMAIN` | Variable, optional | Your Zero Trust team domain, for example `example.cloudflareaccess.com`. Needed only when the token can't read it. | The Deploy job runs in an environment named `production`, so you can also store the secrets as environment secrets there and add required reviewers to that environment. ## Step 4: Run the Deploy workflow Push to `main`, or start **Deploy** by hand (`workflow_dispatch`). The job takes up to 45 minutes; most of it is the first build of the runner image. Here is what each script does, so you can read its output. ### scripts/bootstrap.sh Bootstrap needs `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, and reads these optional variables: | Variable | Default | Used for | | --- | --- | --- | | `DIFFINERY_DOMAIN` | `diffinery.com` | Your hostname: the Worker's route, `PUBLIC_URL` (`https://<DIFFINERY_DOMAIN>`), the Access application and the email sender. | | `DIFFINERY_ZONE` | The hostname's last two labels | The Cloudflare zone the route is added to. Passed to render-config as `--zone`. | | `MAIL_DOMAIN` | `DIFFINERY_DOMAIN` | The domain email is sent from (`notifications@<MAIL_DOMAIN>`) and that the Email Sending check looks at. | | `ACCESS_TEAM_DOMAIN` | Discovered | Your Zero Trust team domain, when the token can't read it. | | `BOOTSTRAP_ADMINS` | Kept from the deployed Worker | Comma-separated emails of the first site administrators. Passed to render-config as `--admins`. | Each section prints a collapsible log group: 1. **D1 database.** Looks for a database named `diffinery` and creates it if missing. 2. **R2 bucket.** Creates `diffinery-data` if missing, then sets its lifecycle rules, which delete short-lived data (such as expired artifacts, old job logs and unfinished uploads) by age and never touch git objects, LFS objects or release assets. The rules are set on every deploy and replace any lifecycle rules you added in the dashboard. If they can't be set, or a rule would still delete git objects, bootstrap stops the deploy. See [R2](https://diffinery.com/docs/self-hosting/architecture#r2) for the rules. 3. **Queues.** Creates `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail`, `diffinery-mail-dlq` and `diffinery-mail-events` if missing. 4. **Email Sending event subscription.** Subscribes `diffinery-mail-events` to Email Sending's `message.bounced` and `message.complained` events for `MAIL_DOMAIN` (default `DIFFINERY_DOMAIN`), through the Queues event subscriptions API, so bounces and complaints reach the [suppression list](https://diffinery.com/docs/admin/site-administration#where-bounces-and-complaints-come-from). It creates the subscription only when there is none for that domain and queue, and adds either event to one that lacks it. A failure here is only a warning that names the permission the token needs (Account → Queues → Edit, and Zone Read to find the zone); the deploy continues. 5. **Email Sending.** Runs `scripts/email-sending.sh` for `MAIL_DOMAIN` (default `DIFFINERY_DOMAIN`). A failure here is only a warning: the deploy continues and email is skipped until sending works. 6. **Turnstile.** Runs `scripts/turnstile.sh`, which looks for a Turnstile widget named `diffinery <DIFFINERY_DOMAIN>` in your account and creates it when there is none (managed mode, for your hostname only). Its site key becomes the Worker's `TURNSTILE_SITE_KEY` variable, so people without an account can send [security reports](https://diffinery.com/docs/security/disclosure); its secret key becomes the `TURNSTILE_SECRET` secret (see [scripts/ensure-secrets.sh](#scriptsensure-secretssh)). The token needs **Account → Turnstile → Edit** ("Turnstile Sites Write" in the API reference). Without it this is only a warning that names the permission: the deploy continues, and only people who are signed in can send reports. The keys don't open sign-up: that still takes `SIGNUP=open` (see [SIGNUP](https://diffinery.com/docs/self-hosting/configuration#signup)). If `wrangler.jsonc` sets `TURNSTILE_SITE_KEY` itself, the step leaves Turnstile to you. Worker secrets can't be read back, so the step tells which widget `TURNSTILE_SECRET` goes with by the `TURNSTILE_SITE_KEY` deployed with it. When they differ, for example because someone deleted the widget and the deploy created a new one, it replaces `TURNSTILE_SECRET` with the new widget's secret before the deploy. If it can't, it leaves `TURNSTILE_SITE_KEY` unset for this deploy, so anonymous reports stay refused instead of failing the check, and the next deploy tries again. 7. **Cloudflare Access.** Finds your team domain (from `ACCESS_TEAM_DOMAIN`, else from your Zero Trust organization through the API). Then it creates, or updates, a self-hosted Access application named **Diffinery account recovery** that covers only `<domain>/auth/access`, with a 15-minute Access session, an HTTP-only cookie and a policy that lets anyone who proves their email through. Diffinery, not Access, decides what that person may do. It also deletes an application named **Diffinery machine endpoints** left by older versions. If it still has no team domain, it requests `https://<domain>/auth/access` and reads the team domain from the Access redirect. 8. **Bootstrap administrators.** Runs `scripts/bootstrap-admins.sh`, which picks the `BOOTSTRAP_ADMINS` list: the environment variable when it isn't empty, else the value in `wrangler.jsonc` when that isn't empty, else the list the deployed `diffinery` Worker has, read from its settings through the API. It prints how many addresses it found and where, never the addresses. With no list anywhere (a new instance), it warns that nobody will become a site administrator and goes on. If the Worker's settings can't be read, it stops the deploy rather than replace the Worker's list with an empty one. 9. **Render config and migrate D1.** Runs `scripts/render-config.mjs` with the Turnstile site key in `TURNSTILE_SITE_KEY` (when the step above found one), `--public-url https://<DIFFINERY_DOMAIN>`, `--zone <DIFFINERY_ZONE>` (only when set), `--mail-from notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>` and `--admins <list>`, and then `wrangler d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc`. At the end it prints `team=<team domain> aud=set` and, inside a workflow, writes `d1_id`, `access_team` and `access_aud_set` to the step outputs. ### scripts/render-config.mjs ```sh node scripts/render-config.mjs --d1 <database id> [--team <x.cloudflareaccess.com>] [--aud <tag>] \ [--public-url https://git.example.com] [--zone example.com] [--mail-from notifications@example.com] \ [--admins you@example.com,ops@example.com] ``` It reads `apps/diffinery/wrangler.jsonc`, drops the `env` section (the `dev` environment) and `$schema`, and changes it as follows: | Flag | Required | Effect on the deployed config | | --- | --- | --- | | `--d1 <database id>` | Yes | Sets the D1 `database_id`. Without it the script stops with `--d1 <database id> is required`. | | `--team <team domain>` | No | Sets `ACCESS_TEAM_DOMAIN`. | | `--aud <tag>` | No | Sets `ACCESS_AUD`. | | `--public-url <origin>` | No | Sets `PUBLIC_URL` to the URL's origin, replaces `routes` with one route `<hostname>/*` in the zone from `--zone`, and sets `MAIL_FROM` to `notifications@<hostname>`. The URL must use `https://` and have no path, otherwise the script stops with `--public-url must be an https origin, like https://git.example.com`. | | `--zone <zone>` | No | The zone for the route. Defaults to the hostname's last two labels (`git.example.com` gives `example.com`). Only used together with `--public-url`. | | `--mail-from <address>` | No | Sets `MAIL_FROM`, overriding the `notifications@<hostname>` that `--public-url` sets. | | `--admins <emails>` | No | Sets `BOOTSTRAP_ADMINS` to the comma-separated emails, trimmed. An empty value sets it empty; without the flag, the value in `wrangler.jsonc` stays. An entry that isn't an email address stops the script with `--admins takes comma-separated email addresses; not an email address: ...`. | It also reads two environment variables: `RUNNER_IMAGE` (set by `scripts/runner-image.sh`) points every container at a prebuilt runner image and sets the `RUNNER_IMAGE` variable, and `TURNSTILE_SITE_KEY` (set by bootstrap from `scripts/turnstile.sh`) sets the `TURNSTILE_SITE_KEY` variable, unless `wrangler.jsonc` already sets one. Without `--public-url`, the route, `PUBLIC_URL` and `MAIL_FROM` stay as they are in `wrangler.jsonc`. Bootstrap always passes `--public-url` and `--mail-from`, so a normal deploy takes them from `DIFFINERY_DOMAIN` and `MAIL_DOMAIN`. For `DIFFINERY_DOMAIN=git.example.com` and `BOOTSTRAP_ADMINS=you@example.com`, the deployed config contains: ```json { "routes": [{ "pattern": "git.example.com/*", "zone_name": "example.com" }], "vars": { "PUBLIC_URL": "https://git.example.com", "MAIL_FROM": "notifications@git.example.com", "BOOTSTRAP_ADMINS": "you@example.com" } } ``` The script writes `apps/diffinery/wrangler.deploy.jsonc`, which every later `wrangler` command uses. That file is generated on each deploy and ignored by git; don't edit it. ### wrangler deploy `pnpm exec wrangler deploy -c wrangler.deploy.jsonc` (run in `apps/diffinery`) deploys the Worker with all its bindings, and builds the runner image from `containers/runner/Dockerfile` (Ubuntu 24.04 with Node.js 22, git, git-lfs and the runner agent) for all three container classes: `RunnerContainer` (hosted jobs), `SandboxContainer` (agent sandboxes) and `ImportContainer` (the Git transfers of GitHub imports). It also applies Durable Object migrations the account doesn't have yet: tag `v1` creates `RepoDO`, `RunnerContainer` and `SandboxContainer`, tag `v2` creates `ImportContainer`, tag `v3` creates `ProjectDO`, tag `v4` creates `SchedulerDO`, tag `v5` creates the smaller runner classes, and tag `v6` creates `ComposeDO`. ### scripts/ensure-secrets.sh Creates the Worker's own secrets once and never rotates them: - `MASTER_KEY`: 32 random bytes, base64 (`openssl rand -base64 32`). It is the root key for encrypting Actions secrets, signing unsubscribe links, and signing the tokens and links that workflow jobs use for caches and artifacts. - `OIDC_PRIVATE_KEY`: a new ECDSA P-256 private key as a JWK, with a key ID like `diffinery-2026-10-13`. It signs workflow OIDC tokens. It also sets `TURNSTILE_SECRET` when the deployed config has a `TURNSTILE_SITE_KEY` and the Worker has no `TURNSTILE_SECRET` yet (the first deploy with Turnstile): `scripts/turnstile.sh secret <site key>` reads that widget's secret key from the Cloudflare API. Without **Account → Turnstile → Edit** it warns and the deploy goes on. Values are generated on the runner, or read from the Cloudflare API, and piped straight into `wrangler secret put`; they are never printed. The script lists existing secrets first and stops with `unexpected output from wrangler secret list` if it can't read them, rather than risk overwriting `MASTER_KEY`, which would make every stored Actions secret unreadable. It never sets or removes `MASTER_KEY_PREV`: you set that yourself while [rotating MASTER_KEY](https://diffinery.com/docs/self-hosting/configuration#rotating-master_key). ### Finishing Access on the first deploy On a brand-new account, bootstrap may not find the team domain before the Worker exists. When its `access_team` output is empty, the workflow runs `scripts/bootstrap.sh` once more (which can now read the team domain from the live `/auth/access` redirect) and redeploys with `wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none`. ### Checks after the deploy The workflow then checks the live site: - `/healthz` must answer `200` within 5 minutes (30 tries, 10 seconds apart). A new hostname can take a few minutes to get its certificate. - `/login` must be the passkey sign-in page (it looks for "Sign in with a passkey") and its Kumo JavaScript and CSS bundles must load. - `/auth/access` should redirect (`302`) to `https://<team>.cloudflareaccess.com/...`. If not, the job only warns. - The favicon, web manifest, `robots.txt` and `sitemap.xml` must load, and the home page must carry its canonical, Open Graph and structured data tags. - Responses must carry an `x-d1-bookmark` header, which shows that D1 queries run in sessions. ## Step 5: Set up email sending Diffinery sends invitations, sign-in links, security alerts and notifications through Cloudflare Email Sending from `MAIL_FROM`, which the deploy sets to `notifications@<MAIL_DOMAIN>` (by default `notifications@<DIFFINERY_DOMAIN>`, for example `notifications@git.example.com`). Onboard the sending domain once in the Cloudflare dashboard: **Compute → Email Service → Email Sending → Onboard Domain**. Cloudflare adds the DNS records it needs: a bounce MX record and an SPF record on `cf-bounce.<domain>`, a DKIM record and a DMARC record. `scripts/email-sending.sh` runs on every deploy (inside bootstrap) and changes nothing. It: 1. Finds the zone that holds the domain (the domain itself or a parent). 2. With Email Sending access on the token, checks that the domain is onboarded and compares every DNS record Email Sending asks for with what is published. Each record is printed as `ok` or `missing`. An existing DMARC policy of your own counts as `ok`. 3. Without that access, it checks the public DNS instead: the `cf-bounce.<domain>` MX record (pointing at `mx.cloudflare.net`), the `cf-bounce.<domain>` SPF record and `_dmarc.<domain>`. It always exits successfully, so a missing record never blocks a deploy; it prints a warning instead. Set `MAIL_DOMAIN` if your sending domain differs from `DIFFINERY_DOMAIN`: it changes both the sender address and the domain this check looks at. Once deployed, a site administrator can check delivery in **Settings → Email** (`/settings/admin/email`) or with: ```sh curl -sS https://git.example.com/api/v3/diffinery/admin/email -H "Authorization: Bearer dfy_..." ``` `sending: true` means the Worker has the Email Sending binding; `last_24h` counts sent, failed and skipped emails. Click **Send me a test email** under **Settings → Notifications** to send yourself one. ## Step 6: DNS Diffinery is served through a Workers route (`git.example.com/*` in the zone `DIFFINERY_ZONE`, by default the hostname's last two labels), not a Custom Domain. A route only runs for hostnames whose DNS record is proxied (orange cloud). If the hostname already has a record, turn on its proxy. If it has none, create a proxied record for it; Cloudflare's documentation suggests an `AAAA` record pointing at `100::` for a hostname that only a Worker serves. If the smoke test fails with `/healthz did not return 200`, this record is the first thing to check. ## Step 7: Sign in as the first administrator Diffinery has no passwords and no open sign-up. The first administrator signs in through the email check at `/auth/access`: 1. Open `https://git.example.com/auth/access`. 2. Cloudflare Access asks for your email and sends you a one-time code. Enter it. 3. Access sends you back to Diffinery with a signed assertion. Diffinery checks it against `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD`, then finds or creates the account for that email. Because your email is in `BOOTSTRAP_ADMINS`, the new account is active and a site administrator. Your username is taken from the part of the email before `@`. 4. You land on **Settings → Passkeys and sessions** with the message "You're signed in. Add a passkey now so you can sign in directly next time." Add a passkey. From then on, sign in at `/login` with your passkey, and invite everyone else from **Settings → People and invitations** or with `diffinery invite`. Someone whose email is not in `BOOTSTRAP_ADMINS` and who has no invite gets an account that waits for an invite ("Waiting for an invite"). They can send an access request, which a site administrator approves under **Pending access requests** in **Settings → People and invitations**. `BOOTSTRAP_ADMINS` only applies when an account is created, or when an account that is still waiting for an invite signs in through `/auth/access`. Changing it later does not promote or demote existing accounts. ## Step 8: Cloudflare Access for /auth/access Access guards only `/auth/access`. Everything else (the web UI, git, the REST API, MCP, runners, the registry) reaches the Worker directly, so Access never gets in the way of tokens or passkeys. - **Login methods.** Access's one-time PIN by email works with no setup. You can add more identity providers under **Zero Trust → Settings → Authentication** (for example GitHub); Diffinery uses the email address Access reports. - **Who gets in.** The application's policy allows everyone who proves an email address. Diffinery then decides: a known account gets a session, a `BOOTSTRAP_ADMINS` email becomes an admin, anyone else waits for an invite, and disabled accounts are refused. - **Settings Diffinery needs.** `ACCESS_TEAM_DOMAIN` (like `example.cloudflareaccess.com`) and `ACCESS_AUD` (the application's audience tag). Bootstrap sets both in the deployed config. If either is empty, `/auth/access` answers "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link." and the sign-in page doesn't offer email recovery. - **Finding the team domain.** In the Zero Trust dashboard, **Settings → Custom pages** shows it. Set it as the `ACCESS_TEAM_DOMAIN` repository variable if bootstrap can't find it. ## Deploy from your own machine `scripts/setup.sh` deploys from a clone of the repository, for the first time and for every update. It needs the tools listed in [What you need](#what-you-need) and the API token from [step 2](#step-2-create-a-cloudflare-api-token). Run it in a terminal and it asks for what isn't in the environment (the token without echoing it), or set everything up front: ```sh export CLOUDFLARE_API_TOKEN=... export CLOUDFLARE_ACCOUNT_ID=... export DIFFINERY_DOMAIN=git.example.com export BOOTSTRAP_ADMINS=you@example.com # empty keeps the deployed Worker's list # export DIFFINERY_ZONE=example.com # if the zone isn't the hostname's last two labels # export MAIL_DOMAIN=example.com # to send email from another domain # export ACCESS_TEAM_DOMAIN=example.cloudflareaccess.com # if the token can't read it scripts/setup.sh --dry-run # checks everything and changes nothing scripts/setup.sh # deploys ``` `pnpm setup:cloudflare` runs the same script. | Option | Effect | | --- | --- | | `--dry-run` | Checks the tools, the token and the zone (read-only calls, skipped without a token), installs and builds, renders `apps/diffinery/wrangler.deploy.jsonc` with a placeholder D1 database ID and a placeholder runner image, and runs `wrangler deploy --dry-run` on it, which bundles the Worker and validates the config. It prints the route, `PUBLIC_URL`, `MAIL_FROM` and how many `BOOTSTRAP_ADMINS` addresses the deploy would set. It creates, changes and deploys nothing. | | `--yes`, `-y` | Never asks: every setting comes from the environment, and a missing required one stops the script. Without a terminal it never asks either. | | `--help`, `-h` | Prints the usage. | Before it changes anything, the script: 1. Checks for Node.js 22 or later, pnpm, `jq`, `curl`, `openssl` and a running Docker daemon. 2. Checks that `DIFFINERY_DOMAIN` is a bare hostname, that `DIFFINERY_ZONE` holds it and that `BOOTSTRAP_ADMINS` lists email addresses. 3. Verifies the API token (`GET /user/tokens/verify`, or the account's `/accounts/<id>/tokens/verify` for an account-owned token) and checks that the zone is in the account. Then it runs the Deploy workflow's steps, in order: `pnpm install --frozen-lockfile`, `pnpm -r --if-present build`, `scripts/runner-image.sh`, `scripts/bootstrap.sh`, `wrangler deploy -c wrangler.deploy.jsonc`, `scripts/wait-container-rollout.sh` and `scripts/ensure-secrets.sh`. When bootstrap found no Access team domain, it runs bootstrap again and redeploys with `--containers-rollout none`, as in [Finishing Access on the first deploy](#finishing-access-on-the-first-deploy). Last, it checks that `https://<DIFFINERY_DOMAIN>/healthz` answers `200` (for up to 5 minutes), that `/login` is the passkey sign-in page and that `/auth/access` redirects to Cloudflare Access, and tells you where to sign in. Every step creates only what is missing, so run it again to update; secrets are never printed. ## Updating To update, bring your copy up to date with upstream and run `scripts/setup.sh` again, or run Deploy (or push to `main` if your workflow deploys on push). Each run: - Creates nothing that already exists and keeps your D1 data, R2 data, secrets, Access application and, unless you set `BOOTSTRAP_ADMINS`, the deployed `BOOTSTRAP_ADMINS` list. - Applies new D1 migrations before the new Worker goes live. For example, `0010_imports.sql` adds the tables for GitHub imports, linked GitHub accounts and organization variables. - Rebuilds the documentation from the Markdown files in your copy, so `/docs` matches the code you deploy. - Rolls out a new runner image. The first sandbox started on a machine after a deploy pulls the new image first, so it can take a few minutes to start; Diffinery waits up to 3 minutes for it. Running hosted jobs and imports keep their containers until they finish, and the next ones start on the new image. A sandbox up for more than an hour is replaced. Keep `MASTER_KEY` safe and don't simply replace it: it decrypts every stored Actions secret (repository, organization and environment secrets), unsubscribe links in emails already sent are signed with it, and so are the cache and artifact tokens of jobs that are running. To change it, keep the old key as `MASTER_KEY_PREV` as described in [Rotating MASTER_KEY](https://diffinery.com/docs/self-hosting/configuration#rotating-master_key). If you delete `OIDC_PRIVATE_KEY`, the next deploy generates a new key with a new key ID; systems that trust your OIDC tokens fetch the new public key from `/.well-known/jwks`. ## Local development You can run Diffinery on your machine with `wrangler dev`, which simulates D1, R2, Queues, Durable Objects and Workflows locally. ```sh pnpm install pnpm -r build pnpm --filter @diffinery/app db:migrate:local pnpm dev ``` `pnpm dev` runs `wrangler dev -e dev` in `apps/diffinery` and serves `http://localhost:8787`. The `dev` environment differs from production: | | Production | `dev` environment | | --- | --- | --- | | `PUBLIC_URL` | `https://git.example.com` | `http://localhost:8787` | | Sign-in | Passkeys, tokens, `/auth/access` | Any request without credentials acts as `dev@diffinery.local` (`DEV_AUTH`), who is a site administrator. Tokens still work. | | D1, R2, queues, Workflows | `diffinery`, `diffinery-data`, `diffinery-events`, `diffinery-mail`, `diffinery-run`, `diffinery-import` | `diffinery-dev`, `diffinery-data-dev`, `diffinery-events-dev`, `diffinery-mail-dev`, `diffinery-run-dev`, `diffinery-import-dev`, all local | | Containers | Hosted runners, sandboxes and import transfers | None. `runs-on: ubuntu-latest` jobs and sandboxes can't start; use a self-hosted runner. A GitHub import waits for you to run its Git transfer by hand (`IMPORT_GIT_AGENT=external`; see [The dev environment](https://diffinery.com/docs/self-hosting/configuration#the-dev-environment)). | | Cron | Every 5 minutes | None | | Email | Sent | Captured by Miniflare, not delivered. `GET /api/v3/diffinery/emails` shows each full message. | | `MASTER_KEY` | Set by `ensure-secrets.sh` | Not set: storing Actions secrets fails, and jobs can't use caches or artifacts, until you add one. | To store secrets locally, put a key in `apps/diffinery/.dev.vars` (ignored by git): ```sh echo "MASTER_KEY=$(openssl rand -base64 32)" > apps/diffinery/.dev.vars ``` To run workflow jobs locally, use `runs-on: [self-hosted, linux]` in the workflow and start a runner with the CLI against your local instance. It needs a token (create one without credentials, as the dev user) and the runner agent built by `pnpm -r build`: ```sh export DIFFINERY_URL=http://localhost:8787 export DIFFINERY_TOKEN=$(curl -sS -X POST http://localhost:8787/api/v3/diffinery/tokens \ -H "content-type: application/json" --data '{"name":"local","scopes":["admin"]}' | jq -r .token) diffinery runner -R dev/website --labels linux --agent containers/runner/dist/agent.mjs ``` ### Tests ```sh pnpm -r build && pnpm -r typecheck && pnpm -r test pnpm --filter @diffinery/app exec playwright install --with-deps chromium pnpm --filter @diffinery/app e2e ``` The end-to-end tests start a real `wrangler dev` with fresh local state, push and clone with the real `git`, run workflow jobs with the real runner agent as a self-hosted runner, and drive passkey sign-in in Chromium with a virtual authenticator. They need `git` installed. Set `E2E_RUNNER_IMAGE` to a built runner image to run jobs inside Docker instead, and `E2E_KEEP=1` to keep the temporary directory for debugging. ## Troubleshooting | Message | Where | What to do | | --- | --- | --- | | `set CLOUDFLARE_API_TOKEN` or `set CLOUDFLARE_ACCOUNT_ID` | bootstrap, email check, setup | Add the secrets (step 3), or export them for a deploy from your machine. | | `missing: ...`, `Node.js 22 or later is needed` or `docker is installed but its daemon isn't running` | setup | Install the tools listed in [What you need](#what-you-need), or start Docker, and run it again. | | `Cloudflare doesn't accept the API token: ...` | setup | The token is wrong, expired or not active. Create one as in step 2; the text after the colon is Cloudflare's error. | | `the zone example.com isn't in account ..., or the token can't see it.` | setup | Add the domain to the account, give the token Zone Read on it, or set `DIFFINERY_ZONE` when the zone that holds your hostname is another one. | | `BOOTSTRAP_ADMINS is empty: ..., so nobody becomes a site administrator on first sign-in.` | bootstrap (warning) | Deploy again with `BOOTSTRAP_ADMINS=you@example.com`. Until then, nobody can sign in as an administrator. | | `Could not read the diffinery Worker's settings to keep its BOOTSTRAP_ADMINS, so the deploy stops ...` | bootstrap | Add Workers Scripts Edit to the token, or set `BOOTSTRAP_ADMINS` for this deploy. Nothing was changed. | | `--admins takes comma-separated email addresses; not an email address: ...` | render-config | `BOOTSTRAP_ADMINS` holds something that isn't an email address, such as a username. Only email addresses make administrators. | | `could not create D1 database (does the token have D1: Edit?)` | bootstrap | Add D1 Edit to the token. | | `could not create R2 bucket (R2 enabled? token has R2: Edit?)` | bootstrap | Enable R2 on the account (dashboard, R2) and add R2 Edit to the token. | | `could not set the R2 lifecycle rules (token has R2: Edit?): ...` | bootstrap | Add R2 Edit to the token. The text after the colon is Cloudflare's error. The deploy stops here, because the bucket's rules weren't confirmed. | | `an R2 lifecycle rule still deletes objects under repos/ (git objects, LFS); refusing to continue` | bootstrap | The rules were set, but reading them back still shows a rule that would delete git or LFS objects. Check the lifecycle rules of the `diffinery-data` bucket in the Cloudflare dashboard, remove the rule that covers `repos/` or the whole bucket, and rerun the deploy. | | `could not create queue diffinery-events (token has Queues: Edit?)` | bootstrap | Add Queues Edit to the token. | | `Could not read Access applications (token needs Access: Apps and Policies Edit). Passkeys work; email recovery stays off.` | bootstrap | Add Access: Apps and Policies Edit. Until then nobody can use `/auth/access`, so the first administrator can't sign in. | | `Could not create the Access application; email recovery stays off.` | bootstrap | Check that the account has a Zero Trust organization and that the token has Access: Apps and Policies Edit. | | `Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it).` | bootstrap | Set the variable and deploy again. | | `Email Sending check failed; email may not be delivered.` | bootstrap | Read the email check's output above it. | | `Could not subscribe diffinery-mail-events to Email Sending's bounce and complaint events for git.example.com. The token needs Account → Queues → Edit ...` (or `Could not read the queue's event subscriptions ...`) | bootstrap (warning) | Add Queues Edit to the token, check that the domain is onboarded to Email Sending, and deploy again; the text at the end is Cloudflare's error. Or subscribe the queue by hand once, see [Site administration](https://diffinery.com/docs/admin/site-administration#where-bounces-and-complaints-come-from). Until then, bounces and complaints don't reach the suppression list. | | `Could not find the Cloudflare zone for git.example.com (the token needs Zone → Zone → Read on it), so diffinery-mail-events isn't subscribed ...` | bootstrap (warning) | Add Zone Read for the zone of your sending domain, or subscribe the queue by hand. | | `Could not read the account's Turnstile widgets ... The token needs Account → Turnstile → Edit ...` (or `Could not create the Turnstile widget "diffinery git.example.com" ...`) | bootstrap (warning) | Add Turnstile Edit to the token and deploy again; the text at the end is Cloudflare's error. Until then, only people who are signed in can send security reports. | | `TURNSTILE_SECRET goes with another Turnstile widget and couldn't be replaced, ...` | bootstrap (warning) | The widget was created again and its secret key couldn't be read or stored (the warning before it says why). This deploy leaves `TURNSTILE_SITE_KEY` unset; fix the cause and deploy again. | | `TURNSTILE_SECRET is not set, so only people who are signed in can send security reports.` | ensure-secrets (warning) | Its secret key couldn't be read or stored; the warning before it says why. Add Turnstile Edit to the token and deploy again. | | `Could not find the Cloudflare zone for git.example.com (the token needs Zone Read on it).` | email check | Add Zone Read for the zone, or ignore it if you don't send email yet. | | `git.example.com is not onboarded to Email Sending, so no email will be delivered.` | email check | Onboard the domain (step 5). | | `Some Email Sending DNS records for git.example.com are missing, so mail may not be delivered.` | email check | Finish onboarding in the dashboard; the lines marked `missing` name the records. | | `unexpected output from wrangler secret list` | ensure-secrets | Wrangler couldn't list the Worker's secrets (usually a token permission or a failed deploy). Fix that and rerun; nothing was overwritten. | | `--public-url must be an https origin, like https://git.example.com` | render-config | `DIFFINERY_DOMAIN` (or the `--public-url` you passed) isn't a bare hostname. Set it to a hostname such as `git.example.com`, without a scheme or a path. | | `--d1 <database id> is required` | render-config | You ran the script by hand without `--d1`. Pass the D1 database ID that bootstrap prints. | | `https://.../healthz did not return 200 ... Check that the ... DNS record is proxied (orange cloud).` | smoke test | Check the DNS record (step 6) and the route in `wrangler.deploy.jsonc`: its pattern must be your hostname and its `zone_name` the zone that holds it (set `DIFFINERY_ZONE` if not). A new certificate can take a few minutes; rerun the workflow. | | `/login is not the Diffinery sign-in page` | smoke test | Another service answers on the hostname. Check `DIFFINERY_DOMAIN`, `DIFFINERY_ZONE` and the route in `wrangler.deploy.jsonc`. | | `/auth/access is not behind Cloudflare Access yet, so email recovery and first admin sign-in will not work` | smoke test (warning) | Check the Access step's output; the application must cover `<hostname>/auth/access`. | | `/security/report has no Turnstile, so only people who are signed in can send security reports` | smoke test (warning) | The Turnstile step warned earlier in the deploy (see its message), or the operator's own `TURNSTILE_SITE_KEY` is set without `TURNSTILE_SECRET`. | | `D1 queries don't run in a session (no x-d1-bookmark header)` | smoke test | The request to `/sitemap.xml` failed or the deployed Worker isn't the one you just built. Check that `wrangler deploy` succeeded and look at the Worker's logs. | | "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link." | `/auth/access` | `ACCESS_TEAM_DOMAIN` or `ACCESS_AUD` is empty in the deployed config. Fix the Access step and redeploy. | | "Cloudflare Access sign-in was rejected: audience mismatch" (or `issuer mismatch`) | `/auth/access` | The deployed `ACCESS_AUD` or `ACCESS_TEAM_DOMAIN` doesn't match the Access application. Rerun Deploy so bootstrap writes the current values. | | "Waiting for an invite" after the email check | Web UI | Your email isn't in `BOOTSTRAP_ADMINS` and nobody invited you. Deploy again with your email in `BOOTSTRAP_ADMINS` (for example `BOOTSTRAP_ADMINS=you@example.com scripts/setup.sh`), and sign in through `/auth/access` again. | | `docs: N problem(s):` followed by a list | Build | A Markdown page under `apps/diffinery/docs` has missing or overlong frontmatter, uses an H1, or isn't listed in `docs/nav.json` (or the other way round). Fix the listed pages; each line names the file. | | `could not store the secret: ... (is MASTER_KEY configured?)` | Repository settings | `MASTER_KEY` is missing. Run `scripts/ensure-secrets.sh`, or add it to `.dev.vars` locally. | | `The cache service is not available to this job; skipping.` or `The artifact service is not available to this job.` | Workflow job logs | `MASTER_KEY` is missing, so jobs get no cache and artifact token. Run `scripts/ensure-secrets.sh`, or add it to `.dev.vars` locally. | | `OIDC is not configured on this Diffinery instance` | Workflow jobs with `id-token: write` | `OIDC_PRIVATE_KEY` is missing. Run `scripts/ensure-secrets.sh`. | | `no import container is configured (IMPORTER binding)` | A GitHub import's error | The deployed config has no `IMPORTER` Durable Object binding or `ImportContainer` container. Restore them from `wrangler.jsonc` and deploy again. | | `the Git transfer did not finish within 6 hours` | A GitHub import's error | Copying the repository's Git data took longer than the 6-hour limit. Lower `IMPORT_MAX_REPO_KB` so that repositories this large are refused before they start, and move them another way (for example a mirror push in several steps). See [Configuration](https://diffinery.com/docs/self-hosting/configuration#github-imports). | --- # Configuration > Every setting of a self-hosted Diffinery: Worker variables and secrets, bindings, the dev environment, the deploy workflows' secrets, and rotating MASTER_KEY with MASTER_KEY_PREV. Source: https://diffinery.com/docs/self-hosting/configuration A Diffinery instance is configured in four places: Worker variables in `apps/diffinery/wrangler.jsonc`, Worker secrets set with `wrangler secret put`, the Cloudflare bindings in the same `wrangler.jsonc`, and the secrets and variables of the repository whose workflows deploy it. This page lists every setting, its default and what it changes. To deploy for the first time, follow [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). ## Where configuration lives | File or place | What it holds | Edit it? | | --- | --- | --- | | `apps/diffinery/wrangler.jsonc` | Worker name, route, variables, bindings, container settings, cron, and the `dev` environment. | Yes. This is the source of truth. | | `apps/diffinery/wrangler.deploy.jsonc` | `wrangler.jsonc` without the `dev` environment, plus the real D1 database ID, `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD`, `TURNSTILE_SITE_KEY`, and the route, `PUBLIC_URL` and `MAIL_FROM` for your hostname. Written by `scripts/render-config.mjs` on every deploy and ignored by git. | No. It is regenerated on each deploy. | | Worker secrets | `MASTER_KEY` and `OIDC_PRIVATE_KEY`, created by `scripts/ensure-secrets.sh`; `TURNSTILE_SECRET`, set by the deploy from its Turnstile widget; and optionally `GITHUB_OAUTH_CLIENT_SECRET`, which you set yourself. | Only to restore them, or to add the optional one. Never change `MASTER_KEY`. | | Repository secrets and variables | Cloudflare credentials and options for the Deploy workflow, and a token for the live test. | Yes, in your forge's repository settings. | | `apps/diffinery/.dev.vars` | Local secrets for `wrangler dev`, such as `MASTER_KEY`. Ignored by git. | Yes, for local development only. | ## Worker variables Set under `vars` in `wrangler.jsonc`. All values are strings. | Variable | Production value in the repository | Required | What it does | | --- | --- | --- | --- | | `PUBLIC_URL` | `https://diffinery.com` | Yes | The canonical origin of the instance. See [PUBLIC_URL](#public_url). On deploy, `scripts/bootstrap.sh` replaces it with `https://$DIFFINERY_DOMAIN` (default `https://diffinery.com`), whatever `wrangler.jsonc` says. | | `MAIL_FROM` | `notifications@diffinery.com` | No | Sender address of every email. The display name is "Diffinery", or `<actor> (Diffinery)` for emails about someone's action. On deploy, `scripts/bootstrap.sh` replaces it with `notifications@$MAIL_DOMAIN` (default `notifications@$DIFFINERY_DOMAIN`). When empty, Diffinery sends from `notifications@<hostname of PUBLIC_URL>`. The domain must be onboarded to Email Sending. | | `BOOTSTRAP_ADMINS` | Empty (set on deploy) | For the first sign-in | Emails (case-insensitive) that become site administrators. On deploy, `scripts/bootstrap.sh` sets it from the `BOOTSTRAP_ADMINS` environment variable, else keeps the deployed Worker's list. See [BOOTSTRAP_ADMINS](#bootstrap_admins). | | `ACCESS_TEAM_DOMAIN` | Empty (filled in on deploy) | For email sign-in | Your Cloudflare Zero Trust team domain, like `example.cloudflareaccess.com`. Used to fetch the Access signing keys and to check the token issuer. | | `ACCESS_AUD` | Empty (filled in on deploy) | For email sign-in | The audience tag of the Access application that guards `/auth/access`. | | `ARTIFACT_QUOTA_BYTES` | Not set | No | Workflow artifact storage per repository, in bytes: artifacts that haven't expired plus uploads in progress. Default `10737418240` (10 GB). Anything that isn't a positive whole number counts as not set. Add it under `vars` in `wrangler.jsonc`; the deploy keeps it. When a repository reaches it, uploads fail with `413 the repository's artifact storage quota (N bytes) is used up; delete artifacts or wait for them to expire`. See [Caches and artifacts](https://diffinery.com/docs/reference/limits#caches-and-artifacts). | | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). | | `SIGNUP` | Not set | No | `open` lets anyone create an account at `/signup`. See [SIGNUP](#signup). Any other value, or none, keeps the instance invite-only. | | `TURNSTILE_SITE_KEY` | Set by the deploy | With `SIGNUP=open`; for anonymous security reports and [early access requests](https://diffinery.com/docs/accounts/early-access) | The site key of the Cloudflare Turnstile widget on the sign-up page, the [security report form](#security-contact) and the early access form. The deploy creates the widget and sets this; see [Turnstile](#turnstile). Setting it doesn't open sign-up. | | `IMPORT_MAX_REPO_KB` | Not set | No | The largest GitHub repository a one-repository import accepts, in KB as GitHub reports a repository's `size`. Default `10485760` (10 GB). An empty value, `0`, a negative number or text that isn't a number counts as not set. A larger source is refused before the import starts with `422 the repository is about N GB, over the M GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. See [GitHub imports](#github-imports). | | `QUOTA_HOSTED_MINUTES`, `QUOTA_HOSTED_JOBS`, `QUOTA_SANDBOXES`, `QUOTA_REPOSITORIES`, `QUOTA_STORAGE_BYTES` | Not set | No | The default per-account quotas: hosted minutes per month (default `3000`), hosted Actions jobs at once (`20`), agent sandboxes open at once (`5`), repositories (`500`) and storage in bytes (`10737418240`, 10 GB). `-1` is unlimited; text that isn't a whole number counts as not set. Site administrators change them for one account with `PATCH /api/v3/admin/accounts/{login}/quotas`, and accounts run by a site administrator have none. See [Limits](https://diffinery.com/docs/reference/limits#account-quotas). | | `HOSTED_JOBS_LIMIT` | Not set | No | Hosted Actions jobs the whole instance runs at once (default `80`); one account runs at most half of them, accounts run by a site administrator included. Keep it under the runner container classes' `max_instances`. Past it, hosted jobs wait as queued until one finishes. See [Limits](https://diffinery.com/docs/reference/limits#actions). | | `IMPORT_GHE_HOST` | Not set | No | The host name of a GitHub Enterprise Server (like `ghe.example.com`) that imports may download release assets from. Without it, imports download release assets only from github.com and its asset hosts, and assets of an Enterprise import are skipped with a warning. | | `GITHUB_OAUTH_CLIENT_ID` | Not set | No | The client ID of a GitHub OAuth app, for linking GitHub accounts with a GitHub sign-in instead of a gist. Needs `GITHUB_OAUTH_CLIENT_SECRET` too. See [GitHub imports](#github-imports). | | `IMPORT_GIT_AGENT` | Not set in production; `external` in the `dev` environment | Never in production | With `external`, an import's Git transfer doesn't start a container: it waits for an import agent started by hand (see [The dev environment](#the-dev-environment)). | | `SCHEDULE_TICK_MINUTES` | Not set | No | Minutes between the workflow scheduler's checks, a whole number from 1 to 60. Default `5`, GitHub's shortest schedule interval. Anything else counts as not set. The end-to-end tests use `1`. | | `WAIT_TIMER_MINUTE_MS` | Not set | Never in production | Local development and the end-to-end tests only: how long one minute of a deployment environment's wait timer lasts, in milliseconds, from 1 to `60000` (the default). Anything else counts as not set. It counts only when `PUBLIC_URL` is on this machine (`localhost`, `127.x` or `[::1]`) and `ACCESS_AUD` is empty, as for `DEV_AUTH`; a deployed Worker ignores it. The end-to-end tests use `10000` so a wait timer passes in seconds. | | `AGENT_POLL_MS` | Not set | Never in production | The end-to-end tests only: how often a running agent task is checked, in milliseconds, from `200` to `3000` (the default). Like `WAIT_TIMER_MINUTE_MS`, it counts only on a Worker on this machine; a deployed Worker ignores it. | | `TERMINAL_POLL_MS` | Not set | Never in production | The end-to-end tests only: how often open web terminals are checked for their limits and their agent's access, in milliseconds, from `2000` to `60000` (the default). It counts only on a Worker on this machine; a deployed Worker ignores it. | | `EGRESS` | Not set | No | `web` limits hosted Actions jobs and agent sandboxes (including tasks, reviews and terminals) to HTTP and HTTPS (ports 80 and 443) and DNS. Any other value, or none, leaves them open to the whole internet. Accounts run by a site administrator stay open. See [EGRESS](#egress). | | `RUNNER_IMAGE` | Set by the deploy | No | The runner image's name and tag (for example `diffinery-runner:96dbbe31790f`), as jobs report it in `runner.image`. `scripts/render-config.mjs` sets it when the deploy uses a prebuilt runner image, and `GET /api/v3/meta` returns it as `runner_image` (`null` when not set). Don't set it by hand. | | `SECURITY_CONTACT` | Not set | No | More places people can report a vulnerability in your instance, listed in `/.well-known/security.txt` after the instance's own report form (`/security/report`): an email address or an `https://` URL, several separated by commas. See [Security contact](#security-contact). | | `MCP_REGISTRY_AUTH` | Not set | No | The proof record served at `/.well-known/mcp-registry-auth`, such as `v=MCPv1; k=ed25519; p=<public key>`, so you can publish your instance's MCP server to the official MCP registry under your domain. Not set, that path is a 404. See [List your instance in the MCP registry](https://diffinery.com/docs/agents/mcp#list-your-instance-in-the-mcp-registry). | | `INDEXNOW` | Not set | No | `off` stops telling search engines about new, changed and removed public pages through IndexNow. Not set, or anything else, it is on whenever `PUBLIC_URL` is a public `https` origin and `MASTER_KEY` is set. Turn it off for an instance search engines can't reach. See [Search engines](https://diffinery.com/docs/admin/site-administration#search-engines). | | `TERMINALS` | Not set | No | `off` switches [terminals](https://diffinery.com/docs/agents/terminal) off: nobody can open one, and open ones end at their next check, within a minute. Anything else, or not set, leaves them on. | When either `ACCESS_TEAM_DOMAIN` or `ACCESS_AUD` is empty, email sign-in at `/auth/access` is off: the page answers "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.", and the sign-in page doesn't offer **Recover your account by email**. Passkeys, invitations and admin sign-in links keep working, but the first administrator can't sign in, because email sign-in is how an account in `BOOTSTRAP_ADMINS` is created. ### PUBLIC_URL `PUBLIC_URL` must be the exact origin people and tools use, with `https://` and no path. You don't set it by hand for a deploy: `scripts/render-config.mjs` sets it from `--public-url`, which bootstrap fills in as `https://<DIFFINERY_DOMAIN>`, and refuses a URL that isn't `https://` or has a path (`--public-url must be an https origin, like https://git.example.com`). The same flag points the Worker's route at that hostname, so the route and `PUBLIC_URL` always agree. Diffinery uses it for: - **Passkeys.** The WebAuthn relying party ID is the hostname of `PUBLIC_URL`, and the expected origin is its origin. Passkeys only work on that hostname. - **Every generated link:** emails, invitation and sign-in links, `html_url` and `clone_url` in API responses, webhook payloads, check run details links, the sitemap and canonical tags. - **Workflows:** `github.server_url` is `PUBLIC_URL`, `github.api_url` is `PUBLIC_URL/api/v3`, and the runner agent and sandboxes call back to it. - **OIDC:** the issuer (`iss`) of workflow ID tokens, and their default audience. - **The container registry host name**, the hostname of `PUBLIC_URL` (images are `<hostname>/<owner>/<name>`). - **The cross-site check:** a cookie-authenticated write with an `Origin` other than this origin is refused. > **Important:** Changing `PUBLIC_URL` on a running instance (by deploying with another `DIFFINERY_DOMAIN`) breaks every existing passkey (they are bound to the old hostname), changes the OIDC issuer that cloud providers trust, and changes every image name. Choose the hostname before your first sign-in. ### BOOTSTRAP_ADMINS A comma-separated list of email addresses; spaces around entries are ignored and matching is case-insensitive. Logins in the list are ignored, because anyone could pick a free login, while an email address has to be proven (by email sign-in or by opening the link in an invitation email). You don't keep the list in the repository. Each deploy sets it (`scripts/bootstrap-admins.sh`, run by bootstrap), from the first of these that isn't empty: | Source | Example | | --- | --- | | The `BOOTSTRAP_ADMINS` environment variable | `BOOTSTRAP_ADMINS=you@example.com scripts/setup.sh` | | `BOOTSTRAP_ADMINS` under `vars` in `wrangler.jsonc` | A copy that keeps its list in the file. The repository leaves it empty. | | The list the deployed `diffinery` Worker has | Read from the Worker's settings (`GET /accounts/<id>/workers/scripts/diffinery/settings`), so a deploy that doesn't set it, such as the Deploy workflow, keeps it. | With none of them (a brand-new instance), the deploy warns that nobody will become a site administrator. If the Worker's settings can't be read, the deploy stops instead of replacing its list with an empty one. The deploy logs say how many addresses it set, never the addresses. An account becomes a site administrator from this list only when it is created (through email sign-in at `/auth/access`, by joining from an invitation or through open sign-up, or through `DEV_AUTH` locally), or when an account that is still waiting for an invite signs in through `/auth/access`. Accounts that already exist are not promoted or demoted when you change the list later, and there is no page for granting or removing site administration. See [Site administration](https://diffinery.com/docs/admin/site-administration). ### SIGNUP With `SIGNUP=open`, the sign-in page links to **Create an account** (`/signup`). Someone enters their email address, gets a 6-digit code by email, enters it, picks a username and creates a passkey; their account is active at once (see [Open sign-up](https://diffinery.com/docs/accounts/passkeys-and-sign-in#open-sign-up)). Unset, or any other value, the instance is invite-only as before: `/signup` answers `404` and the sign-in page says so. Open sign-up needs Cloudflare Turnstile, so scripts can't use it to send email. The deploy sets Turnstile up (see [Turnstile](#turnstile)), so to open sign-up add `"SIGNUP": "open"` under `vars` in `wrangler.jsonc`; the deploy keeps it. The Turnstile keys alone never open sign-up: only `SIGNUP=open` does. Each code request is checked with Turnstile's `siteverify` API, with the visitor's IP address. Without both keys, `/signup` answers `503` with "Sign-up needs Turnstile configured", except when `PUBLIC_URL` is on the local machine (`localhost`, `127.x.x.x` or `[::1]`): local development and the end-to-end tests skip the check. Only the sign-up page's and the security report form's content security policy allows scripts and frames from `https://challenges.cloudflare.com`. The limits on codes (5 a day per address, 20 a day per IP address, 10 minutes and 5 tries each) are in [Limits](https://diffinery.com/docs/reference/limits#sign-in-and-sessions). Sign-up emails always send: they need the `EMAIL` binding and the `MAIL` queue like every other email. ### EGRESS **Experimental:** `web` hasn't been tried on a live instance yet. The setting covers the whole instance, so try it on a test instance first, or check it with an account no site administrator runs, as in [Check it](#check-it) below. diffinery.com doesn't set it. With `EGRESS=web`, hosted job and sandbox containers start without direct internet access. Cloudflare then lets only ports 80 and 443, and DNS to Cloudflare's resolvers, leave the container, and Diffinery forwards HTTP and HTTPS requests to any host through the Worker. Unset, or any other value, hosted compute reaches the whole internet as before. The setting is read when a container starts: jobs and sandboxes already running keep the access they started with. Under `web`: - **Works:** `git clone https://...`, `curl`, `apt-get`, `npm`, `pnpm` and `pip` installs, `docker pull`, and calls to the Diffinery and GitHub APIs. - **Refused:** every other port. That includes SSH (`git@...` remotes, `ssh`, `scp`), SMTP (25, 465 and 587), IRC, databases and HTTP on other ports. - **HTTPS is intercepted.** Requests leave through the instance's own Worker, which sees them decrypted, as the operator already can for the container itself. Cloudflare re-signs them with a CA it puts in the container at `/etc/cloudflare/certs/cloudflare-containers-ca.crt`. The runner agent adds that CA to the system store and sets `NODE_EXTRA_CA_CERTS` and `REQUESTS_CA_BUNDLE`, so tools that use the system store, Node.js and Python's `requests` trust it. Tools with their own fixed CA list or certificate pinning, client certificates (mutual TLS), and HTTPS from inside Docker containers a job starts (`docker run`, `services:`, `RUN` steps of a build) fail their certificate checks. The account that owns the repository decides, as for [quotas](https://diffinery.com/docs/reference/limits#account-quotas). Accounts run by a site administrator (their own account, and organizations with a site administrator as an owner) stay open even with `EGRESS=web`, so the instance's own Deploy and live test workflows keep their access. Self-hosted runners and GitHub imports are not affected. Under `wrangler dev` sandboxes run in a local process, not containers, so the setting changes nothing there. To turn it on, add `"EGRESS": "web"` under `vars` in `wrangler.jsonc`; the deploy keeps it. To turn it off, remove it (or set any other value) and deploy again; containers started after that are open. #### How a blocked connection looks Diffinery doesn't add a line to the job or terminal output when it limits a connection. The tool that made it reports an ordinary network error: - **A port other than 80 or 443** fails to connect, as behind a firewall: for example `ssh: connect to host github.com port 22: Connection refused` (or `Connection timed out`), or a mail or database client that can't reach its server. - **HTTPS from a tool that doesn't trust the Cloudflare CA** fails its certificate check: for example `SSL certificate problem: unable to get local issuer certificate` (curl), `x509: certificate signed by unknown authority` (Go), `PKIX path building failed` (Java), or `self-signed certificate in certificate chain` (Node.js with its own CA list). Inside a Docker container a job starts, add the CA from `/etc/cloudflare/certs/cloudflare-containers-ca.crt` to the image, or run that step on the host. If the CA never appears in the container, the runner agent writes `web-only egress, but /etc/cloudflare/certs/cloudflare-containers-ca.crt did not appear: HTTPS will fail` to the container's own log (not the job log), and every HTTPS request from the job fails its certificate check. #### Check it With `EGRESS=web` deployed, run a workflow in a repository owned by an account that no site administrator runs (a site administrator's own repositories stay open): ```yaml on: workflow_dispatch jobs: egress: runs-on: ubuntu-latest steps: - run: curl -fsSI https://example.com # works - run: git ls-remote https://github.com/git/git HEAD # works - run: npm view left-pad version # works - shell: bash run: "! timeout 10 bash -c '</dev/tcp/github.com/22'" # SSH port: must not connect ``` All four steps pass. The last one fails if port 22 is reachable, which means the account is open (a site administrator runs it) or the setting isn't deployed. ### Security contact `/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116)) tells security researchers where to report a problem. Diffinery always serves it, with: | Field | Value | | --- | --- | | `Contact` | First `<PUBLIC_URL>/security/report`, the instance's private report form, then one line per entry of `SECURITY_CONTACT`. An email address becomes a `mailto:` URI; `https://`, `mailto:` and `tel:` URIs are kept as they are; anything else is left out. | | `Expires` | Midnight UTC 360 days after the request, so the file never goes stale. | | `Policy` | `<PUBLIC_URL>/docs/security/disclosure`, the [disclosure policy](https://diffinery.com/docs/security/disclosure). | | `Preferred-Languages` | `en` | | `Canonical` | `<PUBLIC_URL>/.well-known/security.txt` | The report form needs no setup: reports go to the instance's site administrators, who each get an email with the report's summary and read it under **Settings → Security reports** (see [Site administration](https://diffinery.com/docs/admin/site-administration#security-reports)). People who are signed in can always send one. People who aren't pass Cloudflare Turnstile first, with the same `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET` as [open sign-up](#signup), which the deploy sets up (see [Turnstile](#turnstile); you don't need `SIGNUP=open` for this). Without both keys, for example when the deploy's API token lacks **Account → Turnstile → Edit**, the form asks visitors to sign in, except on the local machine, where the check is skipped as for sign-up. `SECURITY_CONTACT` is optional. Use an address someone reads. The repository leaves it unset on purpose, so an instance never advertises an address its operator didn't choose. To add one, add `"SECURITY_CONTACT": "security@example.com"` under `vars` in `wrangler.jsonc`; the deploy keeps it. ### Turnstile The security report form, and open sign-up when it's on, check with [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) that a visitor without an account is a person. Every deploy sets it up (`scripts/turnstile.sh`, run by `scripts/bootstrap.sh`): 1. It looks for a Turnstile widget named `diffinery <DIFFINERY_DOMAIN>` in the account, and creates one when there is none: managed mode, for your hostname only. 2. The widget's site key becomes the `TURNSTILE_SITE_KEY` variable of the deployed Worker. 3. Its secret key becomes the `TURNSTILE_SECRET` secret: `scripts/ensure-secrets.sh` sets it when it's missing, and bootstrap replaces it before the deploy when it goes with another widget (the deployed `TURNSTILE_SITE_KEY` is not this widget's, for example because the widget was deleted and created again). The key goes from the Cloudflare API straight to `wrangler secret put` and is never printed. The deploy's API token needs **Account → Turnstile → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/create/) calls it "Turnstile Sites Write"). Without it the deploy warns, naming the permission, and goes on; people who aren't signed in then can't send security reports until you add it and deploy again. To use a widget of your own instead, add `"TURNSTILE_SITE_KEY": "<site key>"` under `vars` in `wrangler.jsonc` and store its secret key with `printf '%s' "$TURNSTILE_SECRET" | pnpm exec wrangler secret put TURNSTILE_SECRET -c wrangler.deploy.jsonc`. The deploy then creates no widget and never replaces your secret; it only fills in a missing `TURNSTILE_SECRET` when your widget is in the same Cloudflare account. If you rotate the secret key of the deploy's widget in the dashboard, run `pnpm exec wrangler secret delete TURNSTILE_SECRET -c wrangler.deploy.jsonc` and deploy again, so the deploy stores the new one. ### DEV_AUTH When `DEV_AUTH` is set, `ACCESS_AUD` is empty and both `PUBLIC_URL` and the request's own address are on the local machine (`localhost`, `127.x.x.x` or `[::1]`), every request without credentials (no token, no session) acts as the account with the email in `DEV_AUTH`, with full access. That account is created on first use and is a site administrator if the email is also in `BOOTSTRAP_ADMINS`. While `DEV_AUTH` is set, the email log also keeps each whole message, and `GET /api/v3/diffinery/emails` returns it, so tests can read emails without delivering them. > **Important:** Never set `DEV_AUTH` on a deployed instance. As a safeguard it does nothing there: a request that reaches the Worker under any other host name, such as `workers.dev` or your domain, or a `PUBLIC_URL` that isn't on the local machine, turns it off, and so does Cloudflare Access. ## Worker secrets Set with `wrangler secret put` (the deploy does this for you with `scripts/ensure-secrets.sh`), never in `wrangler.jsonc`. | Secret | Format | Created by | What it does | If it is missing | | --- | --- | --- | --- | --- | | `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import and each webhook's signing secret are encrypted the same way. Model provider keys are encrypted the same way. It also signs the unsubscribe links in emails, the links in invitation and email verification emails, the codes and OAuth state for linking GitHub accounts, keys the stored hashes of sign-up codes, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. Webhooks can't be created or delivered. Open sign-up can't send codes. | | `MASTER_KEY_PREV` | The `MASTER_KEY` you replaced | You, only while [rotating `MASTER_KEY`](#rotating-master_key) | Everything sealed or signed with the old key keeps working: secrets encrypted with it still decrypt, and links and tokens signed with it still verify. Nothing new uses it. | The normal state. Right after a rotation without it, secrets stored under the old key can't be read and links signed with it stop working, as if the old key had been lost. | | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. | | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. | | `MAIL_EVENTS_SECRET` | A random string, at least 32 characters | You, optionally | Turns on `POST /-/email/events`, the signed intake for bounces and complaints from anything other than Cloudflare's own event subscriptions. See [Bounces, complaints and the suppression list](https://diffinery.com/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | The endpoint answers 404. Bounces and complaints still arrive through the `diffinery-mail-events` queue. | | `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | The deploy, from its widget (see [Turnstile](#turnstile)) | With `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code (with `SIGNUP=open`), or sends a security report without signing in, passed the Turnstile widget. See [SIGNUP](#signup) and [Security contact](#security-contact). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured", and the security report form takes reports only from people who are signed in (local development skips the check). The next deploy sets it when the token has **Account → Turnstile → Edit**. | None of these secrets is rotated automatically, and `ensure-secrets.sh` never replaces one that exists. Keep a copy of `MASTER_KEY` somewhere safe: replacing it without keeping the old one as `MASTER_KEY_PREV` makes every stored secret undecryptable and invalidates unsubscribe links already sent, along with the runtime tokens of running jobs and cache and artifact links already handed out. To rotate it, follow [Rotating MASTER_KEY](#rotating-master_key). To rotate the OIDC key, delete `OIDC_PRIVATE_KEY` and deploy: a new key with a new `kid` (`diffinery-<date>`) is generated, and relying parties pick it up from `/.well-known/jwks`. To set a secret by hand (for example to restore a backup): ```sh cd apps/diffinery printf '%s' "$MASTER_KEY" | pnpm exec wrangler secret put MASTER_KEY -c wrangler.deploy.jsonc ``` ### Rotating MASTER_KEY Rotate `MASTER_KEY` when it may have leaked, or on your own schedule. Diffinery accepts two keys while a rotation is under way: `MASTER_KEY`, which encrypts and signs everything new, and `MASTER_KEY_PREV`, the key it replaced, which is only used to decrypt and verify what was made before. 1. Make a new key and keep it with your copy of the current one: ```sh NEW_KEY="$(openssl rand -base64 32)" ``` 2. Set the current key as `MASTER_KEY_PREV` and the new one as `MASTER_KEY` in one step, so no version of the Worker runs with the new key alone. `OLD_KEY` is the current value from your safe copy (Cloudflare doesn't show a secret's value): ```sh cd apps/diffinery jq -n --arg old "$OLD_KEY" --arg new "$NEW_KEY" '{MASTER_KEY_PREV: $old, MASTER_KEY: $new}' | pnpm exec wrangler secret bulk -c wrangler.deploy.jsonc ``` To use `wrangler secret put` instead, put `MASTER_KEY_PREV` first and `MASTER_KEY` second: each command deploys a new version of the Worker, and a version with the new `MASTER_KEY` but no `MASTER_KEY_PREV` can't read the secrets stored so far. 3. Save the new key where you kept the old one. Nothing else needs to change: no deploy, migration or restart. From then on: - New and replaced secrets (repository, organization and environment secrets, model provider keys, webhook secrets, import tokens) are encrypted with the new key. Each stored value names the key it was encrypted with (`v2.<key id>.…`, where the key id is the first 8 hex characters of the key's SHA-256: `printf %s "$NEW_KEY" | base64 -d | sha256sum | cut -c1-8`). Values stored before key ids existed (`v1.…`) are tried with both keys. - Stored values move to the new key by themselves as they are used: repository, organization and environment secrets the next time a workflow job uses them, model provider keys the next time an agent task does, webhook secrets the next time the webhook delivers, and the GitHub token of an import when the import is retried. A value nothing uses stays encrypted with the old key until someone saves it again. - Unsubscribe links, invitation and verification emails, sign-up codes, GitHub linking codes and OAuth state, runtime tokens, cache and artifact URLs, and image proxy URLs in pages already rendered signed with the old key keep working; new ones are signed with the new key. Remove `MASTER_KEY_PREV` (`pnpm exec wrangler secret delete MASTER_KEY_PREV -c wrangler.deploy.jsonc`) only when nothing you still need depends on the old key: - Every repository, organization and environment secret and every model provider key you still need has been used or saved again since the rotation. Secrets your workflows use every day move over by themselves; one that only a rare workflow uses (a release, a yearly job) may not have, and its value can't be read back, so whoever manages it re-enters it. One left behind can't be decrypted once `MASTER_KEY_PREV` is gone, and runs that use it fail. - Links already sent have expired, or you accept that they stop working: runtime tokens and cache and artifact URLs last about as long as their job, sign-up codes 10 minutes, image proxy URLs until pages and browsers cache them no more (at most 7 days), verification links 24 hours, invitation emails until the invitation expires, and unsubscribe links up to a year. Someone with an old unsubscribe link can still change their email settings after signing in. If the old key leaked, treat the secrets as exposed: replace their values at the source (new API tokens, new passwords) and save the new values, which are sealed with the new key. Then remove `MASTER_KEY_PREV` right away: until then, anyone holding the old key can still forge what it signs. ## Bindings Defined in `wrangler.jsonc`. The deploy creates the underlying resources if they don't exist. | Binding | Kind | Resource (production) | What Diffinery uses it for | | --- | --- | --- | --- | | `DB` | D1 database | `diffinery` (migrations in `migrations/`) | Accounts (including each account's appearance setting and profile picture version), organizations, tokens, repository list, topics and access, invitations, sessions and passkeys, notifications, audit log, schedules, organization secrets and variables, the registry index, email settings and log, GitHub imports and linked GitHub accounts. | | `BUCKET` | R2 bucket | `diffinery-data` | Large git objects, LFS objects, archived job logs, caches, artifacts, container image layers and manifests, the event archive, release assets, cached source archives (zipball and tarball downloads) and profile pictures. | | `EVENTS` | Queue producer, with a consumer on the same Worker | `diffinery-events`, dead-letter queue `diffinery-events-dlq` | Repository events for webhooks, notifications and the audit log. Consumer: batches of up to 50, waiting up to 2 seconds, 8 retries. | | `MAIL` | Queue producer, with a consumer on the same Worker | `diffinery-mail`, dead-letter queue `diffinery-mail-dlq` | Email jobs. Consumer: batches of up to 20, waiting up to 2 seconds, 8 retries. Optional: without it emails are dropped with a warning in the logs. | | `EMAIL` | `send_email` (Email Sending) | Your onboarded domain | Sending email. Optional: without it every email is logged as `skipped`. | | (none) | Queue consumer | `diffinery-mail-events` | Bounces and complaints from Email Sending's event subscriptions; the deploy subscribes this queue to your sending domain's events (see [Site administration](https://diffinery.com/docs/admin/site-administration#where-bounces-and-complaints-come-from)). Consumer: batches of up to 50, waiting up to 5 seconds, 8 retries. | | `RL_AUTH`, `RL_ANON`, `RL_ACCOUNT`, `RL_CREATE`, `RL_MODEL` | Rate limiting (`ratelimits`) | Namespaces `1001` to `1005`; 20, 300, 2,000, 60 and 600 requests per 60 seconds | [Request rate limits](https://diffinery.com/docs/reference/rest-api#rate-limits): sign-in and security reports per IP address, anonymous requests per IP address, signed-in requests per account, new content per account, and agents' model requests per paying person (`MODEL_RATE_LIMIT` in `src/lib/rate-limit.ts`). Optional: a missing binding (the `dev` environment declares none) or one that fails lets requests through. If you change a limit, change `RATE_LIMITS` (or `MODEL_RATE_LIMIT`) in `src/lib/rate-limit.ts` to match, since the 429 messages quote it. Namespace IDs are shared across your Cloudflare account, so pick others if another Worker already uses these. | | `REPO` | Durable Object namespace | Class `RepoDO` (SQLite) | One object per repository. | | `SCHEDULER` | Durable Object namespace | Class `SchedulerDO` (SQLite) | One object that starts scheduled workflow runs on its own alarm. | | `COMPOSE` | Durable Object namespace | Class `ComposeDO` (SQLite) | Joins the blocks of large artifact and cache uploads, one short-lived object per step, so the copying doesn't slow the repository's object. Without it, `RepoDO` does the joining. | | `RUNNER` | Durable Object namespace with a container | Class `RunnerContainer` (SQLite) | One container per hosted workflow job. | | `SANDBOX` | Durable Object namespace with a container | Class `SandboxContainer` (SQLite) | One container per agent sandbox. | | `RUN_WORKFLOW` | Workflow | `diffinery-run`, class `RunWorkflow` | One durable instance per workflow run. | | `IMPORTER` | Durable Object namespace with a container | Class `ImportContainer` (SQLite) | One container per Git transfer of a GitHub import. | | `IMPORT_WORKFLOW` | Workflow | `diffinery-import`, class `ImportWorkflow` | One durable instance per attempt of a GitHub import. | The Durable Object classes are declared in six migrations (`new_sqlite_classes`): tag `v1` for `RepoDO`, `RunnerContainer` and `SandboxContainer`, tag `v2` for `ImportContainer`, tag `v3` for `ProjectDO`, tag `v4` for `SchedulerDO`, tag `v5` for `RunnerContainerMedium`, `RunnerContainerSmall` and `RunnerContainerTiny`, and tag `v6` for `ComposeDO`. `wrangler deploy` applies a migration tag the account doesn't have yet. ## Other Worker settings | Setting | Value | Notes | | --- | --- | --- | | `name` | `diffinery` | The Worker's name in your account. | | `main` | `src/index.ts` | | | `compatibility_date` | `2026-10-01` | | | `compatibility_flags` | `nodejs_compat` | | | `workers_dev` | `false` | The instance is only served on your route. | | `routes` | `[{ "pattern": "diffinery.com/*", "zone_name": "diffinery.com" }]` | A zone route, not a Custom Domain. The hostname's DNS record must be proxied. On deploy, `scripts/render-config.mjs` replaces it with one route, `<DIFFINERY_DOMAIN>/*` in the zone `DIFFINERY_ZONE` (by default the hostname's last two labels), so you don't edit it by hand. | | `observability` | Enabled, `head_sampling_rate: 1` | Every request is logged to Workers Logs. | | `version_metadata` | Binding `CF_VERSION_METADATA` | The deployed version's id and time. [IndexNow](https://diffinery.com/docs/admin/site-administration#search-engines) checks the site's pages once for each new version and ignores older versions still running during a rollout. Optional: without it, it checks whenever the pages' content differs from what it last submitted. | | `upload_source_maps` | `true` | Readable stack traces in the logs. | | `limits.cpu_ms` | `300000` | Up to 5 minutes of CPU time per request (large pushes, diffs and merges). | | `containers` | `RunnerContainer`: `standard-4`, `max_instances: 100`; `RunnerContainerMedium`, `RunnerContainerSmall`, `RunnerContainerTiny`: `standard-3`, `standard-1`, `basic`, `max_instances: 100` each (smaller hosted runners; remove them and their `RUNNER_*` bindings to run every job on `standard-4`); `SandboxContainer`: `standard-1`, `max_instances: 50`; `ImportContainer`: `standard-4`, `max_instances: 20`; `rollout_active_grace_period`: 23400 for runners and 25200 for imports (longer than a job or transfer can run, so a deploy never replaces a running one), 3600 for sandboxes; `rollout_step_percentage: 100`, so containers started after a deploy get the new image | All three build `containers/runner/Dockerfile` with `containers/runner` as context. Raise `max_instances` for more concurrent jobs, sandboxes or import transfers. | | `triggers.crons` | `["*/5 * * * *"]` | Token expiry reminders, email log pruning, registry cleanup, cancelling draft imports older than 7 days, and setting the workflow scheduler's alarm if it isn't set. Scheduled workflows run from that alarm, not from the cron trigger. | ## The dev environment `wrangler.jsonc` has an `env.dev` section that `pnpm dev` (`wrangler dev -e dev`) and the end-to-end tests use. It is removed from the deployed config. | Setting | `dev` value | | --- | --- | | `routes`, `workers_dev` | No routes; `workers_dev: true` | | `PUBLIC_URL` | `http://localhost:8787` | | `BOOTSTRAP_ADMINS`, `DEV_AUTH` | `dev@diffinery.local` | | `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` | Empty | | `DB` | `diffinery-dev` | | `BUCKET` | `diffinery-data-dev` | | Queues | `diffinery-events-dev` and `diffinery-mail-dev`, batches waiting up to 1 second, no dead-letter queues | | `EMAIL` | Captured locally by Miniflare, not delivered | | `RUN_WORKFLOW` | `diffinery-run-dev` | | `IMPORT_WORKFLOW` | `diffinery-import-dev` | | `IMPORT_GIT_AGENT` | `external` | | `containers` | None: hosted runners, sandboxes and import containers are unavailable locally; use a self-hosted runner, and run an import's Git transfer by hand | | `triggers.crons` | None | To run an import locally, start it as usual, wait until its `git.phase` is `waiting`, claim the transfer, and run the runner agent's import mode against your instance (build it first with `pnpm -r build`): ```sh TOKEN=$(curl -sS -X POST http://localhost:8787/api/v3/diffinery/imports/<id>/claim | jq -r .token) DIFFINERY_API_URL=http://localhost:8787 DIFFINERY_IMPORT_ID=<id> DIFFINERY_IMPORT_TOKEN=$TOKEN \ DIFFINERY_WORK_ROOT=/tmp/diffinery-import node containers/runner/dist/agent.mjs ``` Only the account that started the import can claim it. In local development (`DEV_AUTH` in effect, as described in [DEV_AUTH](#dev_auth)), imports also accept plain `http://` and loopback GitHub API URLs, for test doubles. `MASTER_KEY` is not set in `dev`. Add it to `apps/diffinery/.dev.vars` (`MASTER_KEY=<base64 of 32 random bytes>`) or pass `--var MASTER_KEY:<value>` to `wrangler dev` to store Actions secrets locally. The end-to-end tests pass a random one with `--var`, together with `PUBLIC_URL`. ## GitHub imports [Imports from GitHub](https://diffinery.com/docs/migrate-from-github) need no setup beyond a normal deploy: the `IMPORTER` container, the `IMPORT_WORKFLOW` Workflow and the D1 tables of migration `0010_imports.sql` are created by the deploy. What you can tune: | Setting | Where | Effect | | --- | --- | --- | | `IMPORT_MAX_REPO_KB` | `vars` in `wrangler.jsonc` | The size ceiling for one-repository imports (default 10 GB). The Git transfer makes a full mirror clone (`git clone --mirror`) inside the import container (`standard-4`), so a higher ceiling needs room for that clone. | | `max_instances` of `ImportContainer` | `containers` in `wrangler.jsonc` | How many Git transfers run at once across the instance (default 20). | | `IMPORT_GHE_HOST` | `vars` in `wrangler.jsonc` | Lets imports download release assets from your GitHub Enterprise Server host. | | `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET` | `vars` (or a secret) and a Worker secret | Adds **Link with GitHub** to **Settings → Linked accounts**. | To offer linking through GitHub sign-in, create an OAuth app on github.com (**Settings → Developer settings → OAuth Apps**) with the authorization callback URL `https://git.example.com/settings/linked/github/callback` (your `PUBLIC_URL` plus `/settings/linked/github/callback`). Then add the client ID under `vars` in `wrangler.jsonc` and store the secret: ```sh cd apps/diffinery printf '%s' "$CLIENT_SECRET" | pnpm exec wrangler secret put GITHUB_OAUTH_CLIENT_SECRET -c wrangler.deploy.jsonc ``` The OAuth flow asks GitHub for the `read:user` scope only, uses github.com (not an Enterprise host), and binds its state to the signed-in account. Without the two settings, people link accounts with the gist code, which needs no configuration. Imports reach GitHub only on HTTPS hosts with public names; a GitHub API URL on a private address, `localhost`, `.local` or `.internal` is refused with `422 github_api_url must be a public host`. ## Deploy script settings Environment variables the scripts in `scripts/` read. `scripts/setup.sh` reads all of them too, asks for `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `DIFFINERY_DOMAIN` and `BOOTSTRAP_ADMINS` in a terminal when they aren't set, and requires `DIFFINERY_DOMAIN` (see [Deploy from your own machine](https://diffinery.com/docs/self-hosting/deploy#deploy-from-your-own-machine)): | Variable | Used by | Default | Meaning | | --- | --- | --- | --- | | `CLOUDFLARE_API_TOKEN` | `bootstrap.sh`, `email-sending.sh`, `ensure-secrets.sh` (through wrangler), `wrangler deploy` | Required | The API token. | | `CLOUDFLARE_ACCOUNT_ID` | `bootstrap.sh`, wrangler | Required | The account to deploy to. | | `DIFFINERY_DOMAIN` | `bootstrap.sh`, `email-sending.sh` | `diffinery.com` | Your instance's hostname, without a scheme or path: the Worker's route becomes `<domain>/*`, `PUBLIC_URL` becomes `https://<domain>`, email is sent from `notifications@<domain>` (unless `MAIL_DOMAIN` is set), the Access application covers `<domain>/auth/access`, and email checks look at this domain. | | `DIFFINERY_ZONE` | `bootstrap.sh` | The hostname's last two labels | The Cloudflare zone that holds the hostname, for the Worker's route. Set it when the last two labels aren't the zone, for example `example.co.uk` for `git.example.co.uk`. The API token needs Workers Routes Edit on this zone. | | `ACCESS_TEAM_DOMAIN` | `bootstrap.sh` | Discovered | Your Zero Trust team domain, when the token can't read it. | | `MAIL_DOMAIN` | `bootstrap.sh`, `email-sending.sh` | `DIFFINERY_DOMAIN` | The domain email is sent from: `MAIL_FROM` becomes `notifications@<MAIL_DOMAIN>`, and the Email Sending check looks at this domain. | | `BOOTSTRAP_ADMINS` | `bootstrap.sh` (through `bootstrap-admins.sh`), `setup.sh` | The deployed Worker's list | Comma-separated emails that become site administrators on first sign-in. See [BOOTSTRAP_ADMINS](#bootstrap_admins). | | `ROLLOUT_WAIT_MINUTES` | `wait-container-rollout.sh` | `10` | How long a deploy waits for the container rollout to reach its last step. | | `GITHUB_OUTPUT` | `bootstrap.sh` | Set by Actions | Where bootstrap writes `d1_id`, `access_team` and `access_aud_set`. | `scripts/render-config.mjs` takes flags instead: | Flag | Effect | | --- | --- | | `--d1 <database id>` | Required. The D1 `database_id`. | | `--team <team domain>` | Sets `ACCESS_TEAM_DOMAIN`. | | `--aud <audience tag>` | Sets `ACCESS_AUD`. | | `--public-url <origin>` | Sets `PUBLIC_URL` to the origin, `routes` to `[{ "pattern": "<hostname>/*", "zone_name": "<zone>" }]` and `MAIL_FROM` to `notifications@<hostname>`. Must be an `https://` URL without a path. | | `--zone <zone>` | The route's zone, with `--public-url`. Default: the hostname's last two labels. | | `--mail-from <address>` | Sets `MAIL_FROM`, overriding the one `--public-url` sets. | | `--admins <emails>` | Sets `BOOTSTRAP_ADMINS` (comma-separated email addresses). Without it, the value in `wrangler.jsonc` stays. | Bootstrap calls it with `--public-url https://$DIFFINERY_DOMAIN`, `--zone $DIFFINERY_ZONE` when that is set, `--mail-from notifications@${MAIL_DOMAIN:-$DIFFINERY_DOMAIN}` and `--admins` with the list `scripts/bootstrap-admins.sh` picked. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy#scriptsrender-configmjs). ## Repository secrets and variables for the workflows The workflows in `.github/workflows/` read these from the repository (or its `production` environment): | Name | Kind | Used by | Value | | --- | --- | --- | --- | | `CLOUDFLARE_API_TOKEN` | Secret | Deploy | Cloudflare API token with the permissions listed in [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy). | | `CLOUDFLARE_ACCOUNT_ID` | Secret | Deploy | Your Cloudflare account ID. | | `DIFFINERY_DOMAIN` | Variable | Deploy | Optional. Your instance's hostname, default `diffinery.com`. Deploy passes it to bootstrap (see [Deploy script settings](#deploy-script-settings)), uses it as the `production` environment's URL and runs its checks against `https://<DIFFINERY_DOMAIN>`. | | `DIFFINERY_ZONE` | Variable | Deploy | Optional. The Cloudflare zone for the route, when it isn't the hostname's last two labels. | | `MAIL_DOMAIN` | Variable | Deploy | Optional. The domain email is sent from, when it isn't `DIFFINERY_DOMAIN`. | | `ACCESS_TEAM_DOMAIN` | Variable | Deploy | Optional. Your Zero Trust team domain, for example `example.cloudflareaccess.com`, when the API token can't read it. | | `DIFFINERY_LIVE_TOKEN` | Secret | Live test | A Diffinery token with the `admin` scope and access to all repositories, created under **Settings → Access tokens** on the instance. The live test creates and deletes throwaway repositories with it, and uploads a test profile picture to the token's account (putting the previous one back afterwards). When the secret is not set, those steps are skipped with a warning; the check that `/docs`, `/llms.txt` and the CLI download are served without signing in still runs. | Deploy reads `DIFFINERY_DOMAIN`, `DIFFINERY_ZONE` and `MAIL_DOMAIN` from the repository's variables and passes them to bootstrap, so you point it at your own hostname without editing the workflow (see [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy#step-1-choose-your-hostname-and-first-administrators)). Deploy runs on pushes to `main` on any Diffinery instance, and when started by hand on GitHub. The Deploy job runs in an environment named `production` (created by its first run, with no branch policy). The Live test checks the upstream project's public instance, `https://diffinery.com`, and runs on its own after a deploy only there; delete it, or change its `DIFFINERY` host, for your own instance. **When agents land changes on `main`.** A merge that Diffinery lands for an agent through auto-merge is the agent's push, so the Deploy and Live test runs after it are [runs of agent code](https://diffinery.com/docs/actions/workflow-permissions#runs-whose-code-came-from-an-agent). Those get no repository or organization secrets, and Deploy stops with `CLOUDFLARE_ACCOUNT_ID is required`. Either keep the secrets in the `production` environment, limited to protected branches, with `main` protected (add `environment: production` to the Live test job as well), or have an admin turn on **Give runs of agent code the repository's and organization's secrets** under **Settings → Actions**. The environment is the tighter choice: the setting gives the secrets to every agent run, branches included. See [Which runs receive secrets](https://diffinery.com/docs/actions/secrets-and-variables#which-runs-receive-secrets). `scripts/diffinery-wait-runs.sh` waits for the Actions runs on a commit. It reads `DIFFINERY` (the instance origin) and `TOKEN` (a Diffinery token) from the environment: ```sh DIFFINERY=https://diffinery.com TOKEN=dfy_... scripts/diffinery-wait-runs.sh acme/website <sha> 60 ``` ## Settings that are not configuration Some values are fixed in code and can't be changed without editing it, for example the job timeout ceiling, matrix size, the 10 GB cache budget per repository and session lifetime. They are listed in [Limits](https://diffinery.com/docs/reference/limits). --- # Architecture > How Diffinery maps onto Cloudflare products, how a git push and a workflow run flow through them, and where each kind of data is stored. Source: https://diffinery.com/docs/self-hosting/architecture Diffinery is one Cloudflare Worker plus the Cloudflare products it binds to. There are no servers or databases to run yourself. This page explains which product does what, how a git push and a workflow run move through the system, how the code is organized, and where each kind of data lives. Read it before you deploy or operate your own instance, or when you need to reason about consistency and failure modes. ## Overview | Piece of Diffinery | Cloudflare product | Binding | | --- | --- | --- | | Web UI and public docs, REST API, git smart HTTP, Git LFS, MCP server, container registry, runner API, Actions cache and artifact services, release asset uploads and downloads, source archives, profile pictures | Workers, with the Hono router | The Worker itself (`src/index.ts`) | | One repository: refs, git objects, issues, pull requests, reviews, reactions, checks and commit statuses, releases, deployments and environments, workflow runs, secrets, webhooks, merge queue | Durable Object with SQLite storage (`RepoDO`), one per repository | `REPO` | | Accounts (with profile picture version and appearance setting), organizations, tokens, repository list, topics and access, invitations, notifications, audit log, schedules, package index, email settings and log, organization variables, GitHub imports and linked GitHub accounts | D1, read through the Sessions API | `DB` | | Large git objects, LFS objects, archived job logs, caches, artifacts, container image layers and manifests, the event archive, release assets, cached source archives, profile pictures, account exports | R2 | `BUCKET` | | Webhooks (and retries of failed deliveries), notifications, audit fan-out | Queues (`diffinery-events`, dead-letter queue `diffinery-events-dlq`) | `EVENTS` | | Email jobs | Queues (`diffinery-mail`, dead-letter queue `diffinery-mail-dlq`) | `MAIL` | | Bounces and complaints | Queue `diffinery-mail-events`, fed by Email Sending's event subscriptions | (consumer only) | | Workflow run orchestration (durable across restarts) | Workflows (`diffinery-run`, class `RunWorkflow`) | `RUN_WORKFLOW` | | Hosted job runners | Containers (`RunnerContainer`, one container per job) | `RUNNER` | | Agent sandboxes | Containers (`SandboxContainer`, one container per sandbox) | `SANDBOX` | | GitHub import orchestration (durable, with paged and retried steps) | Workflows (`diffinery-import`, class `ImportWorkflow`) | `IMPORT_WORKFLOW` | | Account exports and the clean-up after an account is deleted (durable, with retried steps) | Workflows (`diffinery-account`, class `AccountWorkflow`) | `ACCOUNT_WORKFLOW` | | The Git transfer of a GitHub import | Containers (`ImportContainer`, one container per transfer) | `IMPORTER` | | Scheduled workflows | Durable Object alarm, every 5 minutes (`SchedulerDO`, one instance) | `SCHEDULER` | | Joining large artifact and cache uploads | Durable Objects, one per step (`ComposeDO`) | `COMPOSE` | | Token expiry reminders, email log pruning, registry cleanup, expiry of draft imports and of account exports, a check that the scheduler's alarm is set, IndexNow submissions | Cron Triggers, every 5 minutes | `scheduled` handler | | Live run logs and pull request updates in the browser | Durable Object WebSockets with hibernation | Through `REPO` | | Outgoing email | Email Sending | `EMAIL` | | Email-based account recovery and first admin sign-in | Cloudflare Access, on `/auth/access` only | `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` | ```text browser, git, CLI, MCP client, docker, runner agent | v +---------------------------+ +--------------------+ | Worker (Hono) |-------->| D1 (Sessions API) | | auth, routing, D1 reads | +--------------------+ +---------------------------+ | | | \ v v v \----------------> R2 +------------+ +--------+ +------------------+ | RepoDO | | Queues | | Containers | | (one per | | events | | RunnerContainer | | repo, | | mail | | SandboxContainer | | SQLite) | +--------+ | ImportContainer | +------------+ | +------------------+ | ^ v ^ | | webhooks, email, | v | audit log | Workflows (RunWorkflow, | ImportWorkflow) --------+ ``` ## The Worker `apps/diffinery/src/index.ts` exports the Worker's `fetch`, `queue` and `scheduled` handlers, and the classes Cloudflare instantiates: `RepoDO`, `SchedulerDO`, `ComposeDO`, `RunnerContainer`, `SandboxContainer`, `RunWorkflow`, `ImportWorkflow` and `ImportContainer`. Every request goes through one middleware before it reaches a route: 1. **Cross-site check.** A request that changes state (anything but `GET`, `HEAD` and `OPTIONS`) and carries no `Authorization` credential is refused with `403 cross-site request refused` if the browser marks it as coming from another site (`Sec-Fetch-Site`, or a foreign `Origin`). Token clients are exempt. The web UI's forms also use a double-submit CSRF token. 2. **D1 session.** All D1 queries of the request run in one D1 session (see [D1 and read replication](#d1-and-read-replication)). 3. **Identity.** `GET` and `HEAD` requests for profile pictures (`/api/avatars/...`) skip this step and run as anonymous, because they are public and appear many times on every page. Requests to the Actions cache and artifact services (`/twirp/...` and `/_runtime/...`) skip it too: they carry their own signed token or URL (see [Actions cache and artifact services](#actions-cache-and-artifact-services)). For everything else, a `dfy_` token in the `Authorization` header (Bearer, `token` or Basic) becomes an account principal with the token's scopes. The push token of a GitHub import is a `dfy_` token too, but it is accepted only on Git smart HTTP, Git LFS and that import's own agent endpoints (`403 this token can only be used by its GitHub import` elsewhere). Job tokens (`djt_`), sandbox tokens (`dst_`) and self-hosted runner tokens (`drn_`, accepted only for claiming jobs) become a principal for one repository. Otherwise the session cookie from a passkey sign-in is checked. Otherwise the request is anonymous. 4. After the route runs, the response gets security headers and the `x-d1-bookmark` header. Routes are mounted in this order, so earlier ones win: profile pictures (`/api/avatars/...`), OIDC discovery (`/.well-known/*`), git smart HTTP and LFS (`/<owner>/<repo>.git/...`), the runner API (`/runner/jobs/...`), the email REST API, the container registry (`/v2/`), the packages REST API, the Actions cache and artifact services together with the REST API's artifact and cache routes, the REST API (`/api/v3`), release asset uploads (`/api/uploads/...`), release asset downloads (`/<owner>/<repo>/releases/download/...`) and source archives (`/<owner>/<repo>/archive/...`), the MCP server (`/mcp`), the live-update WebSocket and artifact downloads, a catch-all for `/api/*`, and finally the server-rendered web UI, which also serves the public docs at `/docs`. The `/api/*` catch-all answers any API path that no route matched with GitHub's JSON 404, `{"message": "Not Found", "documentation_url": "https://docs.github.com/rest"}`, so API clients never get the web UI's HTML error page. Some work happens in the Worker itself: profile pictures are checked, cropped and resized to small WebP files with WebAssembly image codecs, and code in the web UI and the docs is syntax highlighted with Shiki on the server. The Worker holds no state of its own. It authenticates, checks the caller's role on the repository against D1, and then calls the repository's Durable Object over RPC. Before it calls the object, the Worker also decides whether the request is a read or a write. `GET` and `HEAD` are reads, and so are the two `POST` requests that only fetch data: git's `git-upload-pack` (clone and fetch) and a Git LFS batch request for `download`. MCP tools marked read-only count as reads too. Every other request is a write: - An anonymous caller (no token, no session) gets `401 authentication required` for any write, even on a public repository. - A personal or agent token whose scope is only `read` gets `403 this token needs the write scope`. - Browser sessions act with full scope, and job and sandbox tokens are limited by their own role instead. - A job token is also checked against the job's effective `permissions:`, which the Durable Object records when the job starts. A request outside them gets `403 Resource not accessible by integration`, and some endpoints refuse job tokens altogether. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions). Account-level changes that don't belong to a repository check the token's scope too: creating a repository needs `write`, while creating an organization, sending invitations, managing organization secrets, creating and revoking tokens, and creating and deleting agents need `admin`. A token restricted to some repositories is refused for creating repositories and organizations, invitations, organization secrets, agents, and creating and revoking tokens (`403 this token is limited to repositories and can't change account settings`). ## RepoDO: one Durable Object per repository Each repository has one Durable Object, addressed by the repository's ID. It is the single writer for everything inside the repository, which gives strongly consistent ref updates, merges and run state without locks: two pushes to the same branch are applied one after the other, and a compare-and-swap on the ref rejects the stale one. The object keeps its data in its own SQLite database (tables in [Data model](#data-model)). Inside it: - **Git object store.** Objects are stored deflated. Small objects live in SQLite; objects larger than 512 KiB after compression go to R2 (SQLite rows are capped at 2 MB). Recently read objects stay in a 24 MiB in-memory cache. - **Git protocol.** Protocol v2 `upload-pack` for clone and fetch (including shallow clones), and `receive-pack` for push, with push protection, branch protection and agent rules checked before refs move. - **Collaboration.** Issues, pull requests, comments, reviews, reactions, issue events, labels, check runs and commit statuses, webhooks configuration and mergeability. - **Releases and deployments.** Releases and their asset metadata (the files are in R2), deployments and their statuses, and environments with their branch policies, secrets (encrypted with a key derived from `MASTER_KEY`) and variables. The object also remembers who last moved each branch and tag, so tags pushed by agents never match an environment's tag rules and runs on code an agent pushed act as an agent. - **Git data and archives.** The git data API (blobs, trees, commits, tags and refs) and zip and tar.gz source archives, which are built in the object and cached in R2. - **Actions.** Parsing workflows from the pushed commit, creating runs and jobs, the runner API's server side, logs while a job runs, secrets (encrypted with a key derived from `MASTER_KEY`) and variables, and sandboxes. - **Workflow tokens.** The repository's workflow settings (default token permissions, whether workflows may create and approve pull requests), each job's granted role and permissions, and the check that keeps a job token from creating, changing or deleting workflow files. - **Caches and artifacts.** Their records, the per-repository cache budget and artifact quota, eviction of caches and expiry of artifacts. The contents are in R2. - **Merge queue.** One queue per protected branch. The object's single alarm wakes it when a merge group's check timeout runs out. - **Live updates.** WebSocket connections, see [Live updates](#live-updates). Deleting a repository wipes the object's storage and deletes all of the repository's R2 objects: large git objects, LFS files, release assets, caches, artifacts, archived job logs, unfinished uploads and cached source archives. A repository with more objects than one call can delete is finished by the object's alarm. ## D1 and read replication D1 holds the global directory: data that is not inside one repository, or that the Worker must read before it knows which repository to call (who you are, what you can see). Its schema is in `apps/diffinery/migrations/`. Diffinery reads D1 through the Sessions API, so reads can be served by a replica near the visitor while a client still sees its own writes: - Each request runs in one session. Where it starts depends on the caller: a bookmark from the `x-d1-bookmark` request header if valid; for browsers, the bookmark in the `__Host-dfy_d1` cookie; otherwise the primary for token clients (`first-primary`) and the nearest copy for anonymous browsers (`first-unconstrained`). - Each response carries the session's latest bookmark in `x-d1-bookmark`. Browsers get it as a cookie (for one day) once they have written something or are signed in. - Durable Objects, queue consumers and the cron handler use the D1 binding directly, which always goes to the primary. Read replication is a setting of the D1 database, off until you turn it on. The repository's **D1 read replication** workflow (`.github/workflows/d1-replication.yml`, started by hand) first checks on a throwaway database that every read made with a bookmark sees its write, and then, if you ask it to, sets `read_replication.mode` to `auto` on the `diffinery` database. It needs the Deploy workflow's two secrets, D1 and Workers Scripts Edit on the API token, and a `workers.dev` subdomain on the account; change its hard-coded `https://diffinery.com` checks to your origin first. Without read replication, every read goes to the primary and bookmarks are still issued. The [REST API](https://diffinery.com/docs/reference/rest-api) page explains how API clients use the bookmark header. ## R2 One bucket (`diffinery-data`) stores everything too large for SQLite or D1: - git objects over 512 KiB compressed, - Git LFS objects (verified against their SHA-256 on upload), - job logs, archived from the Durable Object when the job completes, - Actions caches and artifacts, and their uploads while they are in progress, - container image blobs and manifests, with uploads written as multipart uploads, - an append-only archive of every event that goes through the events queue, - release assets (up to 100 MB each), - source archives (zipball and tarball downloads), cached after they are first built, - profile pictures of people and organizations, stored as small WebP files in a few sizes. Durable and short-lived data have their own top-level prefixes, because R2 lifecycle rules select objects by prefix: | Prefix | What | Removed by | | --- | --- | --- | | `repos/` | Git objects, LFS objects and release assets | Nothing by age. Kept until the repository or the object is deleted. | | `registry/` | Container image blobs and manifests | Nothing by age. The cron trigger removes blobs no image uses. | | `avatars/` | Profile pictures | Nothing by age. Replaced when someone uploads a new picture, and deleted with the account. | | `actions-cache/` | Actions caches | Nothing by age. Diffinery evicts caches itself (least recently used, and unused for 7 days). | | `artifacts/` | Workflow artifacts | Diffinery at the end of each artifact's retention, and a lifecycle rule after 90 days. | | `logs/` | Archived job logs | A lifecycle rule after 90 days. | | `actions-staging/` | Cache and artifact uploads in progress (blocks and chunks) | Diffinery when the upload finishes, and a lifecycle rule after 1 day. | | `archives/` | Cached source archives | A lifecycle rule after 30 days. They are built again on the next download. | | `exports/` | Account exports and their parts while they are built | Diffinery 7 days after an export is ready (or when the person asks for a new one, or deletes the account), and a lifecycle rule after 8 days. | | `events/` | The event archive | A lifecycle rule after 400 days. | A lifecycle rule also aborts multipart uploads left unfinished for a day, under every prefix. `scripts/bootstrap.sh` sets these rules on every deploy and replaces the bucket's lifecycle configuration when it does, so rules added by hand in the Cloudflare dashboard don't survive the next deploy. If it can't set them, the deploy stops with `could not set the R2 lifecycle rules (token has R2: Edit?)`. It then reads the rules back and stops with `an R2 lifecycle rule still deletes objects under repos/ (git objects, LFS); refusing to continue` if any rule would delete objects under `repos/` or in the whole bucket. Instances that stored artifacts, caches and job logs before these prefixes existed kept them under `repos/<repository ID>/`. Diffinery still reads them from there, and the lifecycle rules never touch them. ## Queues **`diffinery-events`** carries every repository event: `push`, `pull_request`, `issues`, `issue_comment`, `pull_request_review`, `workflow_run`, `workflow_dispatch`, `release`, `status`, `deployment`, `deployment_status`, `repository_dispatch` and `audit`. The Durable Object sends events as things happen, so the request that caused them doesn't wait. The consumer takes batches of up to 50 and, for each batch, appends the events to the R2 archive. For each event it writes audit log entries (pushes, force pushes, ref deletions, merges and explicit audit events), delivers webhooks, and creates notifications and email jobs. Webhooks and notifications run as two separate stages: a stage that fails is put back on the queue alone (a `stage_retry` message) after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 1 hour, then given up and logged, so retrying notifications never sends webhooks again and the other way round. Once a webhook may have been called, errors no longer fail the webhooks stage; that delivery's own retries take over. Any other failure retries the whole event with backoff up to 8 times, then moves it to `diffinery-events-dlq`. The same queue carries webhook retries. When a delivery fails with a network error or timeout, `408`, `429` or a `5xx` status, the consumer puts a retry message for that one webhook back on the queue with a delay: 1 minute, then 5 minutes, 30 minutes, 1 hour and 3 hours, so a delivery is tried at most 6 times, the last one about 4 hours and 36 minutes after the first. A retry sends the same body with the same `X-GitHub-Delivery` ID, to the webhook's current URL and signed with its current secret; it is dropped if the webhook was deleted in the meantime. Other statuses, including `3xx` and other `4xx` answers, are not retried. Webhook and stage retry messages are not added to the R2 event archive. **`diffinery-mail`** carries email jobs. It is separate so that sign-in links and invitation links never land in the event archive. The consumer applies the recipient's notification settings, renders the HTML and text versions, and sends through Email Sending. Temporary failures are retried with backoff; after 6 attempts the email is logged as failed. Before sending, it checks the suppression list: nothing goes to an address that bounced for good or reported spam. When Email Sending refuses a recipient for good (`E_RECIPIENT_SUPPRESSED`: it is on Cloudflare's own suppression list), the address goes on Diffinery's list as a hard bounce, so later emails are skipped without a call. **`diffinery-mail-events`** receives Email Sending's delivery events; the deploy subscribes it to the sending domain's bounced and complained events. The consumer adds the recipient of a permanent bounce or a spam complaint to the suppression list and ignores the rest. ## Workflows Each workflow run is one instance of the `diffinery-run` Workflow, with the run's ID as the instance ID. `RunWorkflow` makes a run durable: its steps, sleeps and waits survive restarts and deploys. 1. **Plan.** Ask the repository's Durable Object for the job graph: each job with the jobs it `needs` (calls to reusable workflows expanded). If the run's concurrency group is busy, sleep a minute and plan again, for up to 6 hours. 2. **Start jobs as their needs finish.** A job starts as soon as every job it needs has finished, without waiting for unrelated jobs (each start retried up to 3 times). Matrix jobs are expanded here, and up to `max-parallel` start at once. 3. **Wait.** For each started job, wait for an event named `job-<id>` that the Durable Object sends when the job completes, with a timeout of 6 hours. On timeout the job is marked `timed_out` and its container stopped. 4. **Finish.** Once every job has finished, compute the run's conclusion, which also triggers `workflow_run` followers. Runs that were planned by an older version, which grouped jobs in levels, finish level by level. Each attempt of a GitHub import is one instance of the `diffinery-import` Workflow, with `<import id>-<attempt>` as the instance ID. `ImportWorkflow` runs every step with `step.do`, each retried up to 5 times with exponential backoff from 15 seconds and limited to 15 minutes per call, and turns GitHub rate limits into Workflow sleeps. Steps that page through GitHub are called again and again with a cursor until they report that they are done, so no single step runs long. See [Request flow: a GitHub import](#request-flow-a-github-import). Each account export is one instance of the `diffinery-account` Workflow, `export-<export ID>`, and so is the clean-up after an account is deleted, `delete-<account ID>`. `AccountWorkflow` runs every step with `step.do`, retried up to 5 times with exponential backoff from 10 seconds. An export writes the account, then each repository as a Git bundle (written by the repository's Durable Object), then the issues and pull requests the person wrote, a few repositories per step, as separate R2 objects, and finally joins them into one tar archive. The clean-up wipes each deleted repository's Durable Object and R2 objects, deletes the account's profile pictures and exports, and then asks every repository's Durable Object, 25 per step, to show the deleted account's issues, pull requests, comments, reviews and reactions as written by `ghost`. See [Deleting an account](https://diffinery.com/docs/accounts/account-data#deleting-your-account). ## Containers All three container classes use the same image, built from `containers/runner/Dockerfile` (Ubuntu 24.04 with Node.js 22, git, git-lfs, Python and build tools, plus the runner agent). - **`RunnerContainer`** runs one hosted job (`runs-on: ubuntu-latest` and other `ubuntu-*` or `linux` labels). The Durable Object starts a container named after the job with three environment variables: `DIFFINERY_API_URL`, `DIFFINERY_JOB_ID` and `DIFFINERY_JOB_TOKEN`. The agent fetches its job spec from `/runner/jobs/<id>`, runs the steps, streams logs, uses the cache and artifact services and the OIDC endpoint, reports completion and exits. If the container stops before the job reports completion, the job fails with "The runner stopped unexpectedly". Instance type `standard-4` (4 vCPU, 12 GiB), at most 100 at once; `RunnerContainerMedium` (`standard-3`), `RunnerContainerSmall` (`standard-1`) and `RunnerContainerTiny` (`basic`) are the same runner on smaller instance types, at most 100 each, for jobs that ask for a size or whose earlier runs fit one ([Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes)); a job's container name ends in `.medium`, `.small` or `.tiny` on those. A deployment without their bindings (`RUNNER_MEDIUM`, `RUNNER_SMALL`, `RUNNER_TINY`) runs every job on `RunnerContainer`. Each runs with internet access on (only HTTP and HTTPS, through the Worker, under [`EGRESS=web`](https://diffinery.com/docs/self-hosting/configuration#egress)). Docker is started inside the container the first time a job uses it. - **`SandboxContainer`** runs one agent sandbox: the repository checked out at a ref, with an HTTP API on port 8080 for `exec`, file reads and file writes. The Worker forwards REST and MCP sandbox calls to it with a per-sandbox secret. Instance type `standard-1`, at most 50 at once, stopped after 30 minutes without requests. Internet access is on, limited to HTTP and HTTPS under `EGRESS=web` like `RunnerContainer`, and a limited sandbox stays limited when it starts again after its idle stop. - **`ImportContainer`** runs the Git transfer of one GitHub import: the runner agent in import mode, started with `DIFFINERY_API_URL`, `DIFFINERY_IMPORT_ID` and `DIFFINERY_IMPORT_TOKEN`. One container per try, named `<import id>-<attempt>-<try>`. If it stops before the agent reports a result (a crash, running out of memory, a platform restart), the transfer counts as failed and the import's Workflow can try it again. Instance type `standard-4` (a mirror clone of a large repository needs disk and memory), at most 20 at once, internet access on, stopped after 7 hours without activity. Self-hosted runners (`runs-on: [self-hosted, ...]`) run the same agent outside Cloudflare. They register with a repository admin's one-hour registration token, poll `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` with their own runner token (kept in the repository's Durable Object, hashed), and then talk to the same runner API. See [Runners](https://diffinery.com/docs/actions/runners). ## Actions cache and artifact services `actions/cache`, `actions/upload-artifact`, `actions/download-artifact` and other tools built on GitHub's Actions toolkit talk to the same services they use on GitHub, which the Worker implements in `src/http/actions-runtime.ts`: - The cache and artifact services at `ACTIONS_RESULTS_URL`, and the older cache API at `ACTIONS_CACHE_URL`. - Signed upload and download URLs under `/_runtime/blob/...`, which accept the parts of the Azure Blob Storage protocol the toolkit uses (blocks, block lists and ranged reads) and store the data in R2. The job spec gives the agent an `ACTIONS_RUNTIME_TOKEN` and these URLs, and the agent passes them to JavaScript and Docker actions and to its built-in cache and artifact steps, not to `run:` steps. The token is an HMAC-signed JWT for one job, with a key derived from `MASTER_KEY`. It is accepted only while its job is in progress and until 30 minutes after the job's timeout, and it can only read caches of the job's own branch, its base branch and the default branch. Without `MASTER_KEY`, jobs get no token: the built-in cache step is skipped with a warning and the built-in artifact steps fail. The Durable Object decides what each request may read or write and keeps the records (`rt_caches` and `rt_artifacts`). It applies the 10 GB cache budget per repository and the artifact quota (`ARTIFACT_QUOTA_BYTES`), evicts caches and expires artifacts. Older runner agents use their own cache and artifact endpoints under `/runner/jobs/<id>/...`. Their artifacts are recorded in `rt_artifacts` (marked `legacy`, a `.tar.gz` at the older key) so the REST API, the quota and the 90-day expiry cover them. Their caches stay in the `caches` table, with their own 10 GB budget and the same 7-day idle eviction. Limits are listed in [Caches and artifacts](https://diffinery.com/docs/reference/limits#caches-and-artifacts). ## Workflow schedules `SchedulerDO` starts scheduled workflow runs. Its one instance ticks on its own Durable Object alarm every 5 minutes (on the hour, :05, :10 and so on; `SCHEDULE_TICK_MINUTES` changes the interval, 1 to 60). Each tick sets the next alarm first, reads every workflow schedule from D1 (kept in sync from the default branch on each push) for repositories that aren't archived, and starts a run for each cron expression that matched any minute since the last tick that finished, at most 16 repositories at a time. A tick that comes late, or fails, is caught up by the next one, up to an hour back. Alarms survive deploys; a cron trigger can take minutes to apply after a deploy, which is why schedules don't depend on it. The alarm is set when a repository first registers a schedule, and the cron trigger checks it on every run. Site administrators can read the scheduler's state at `GET /api/v3/diffinery/admin/scheduler`: `tick_minutes`, `last_tick_at` (when the last tick finished), `covered_until` (the last minute it covered) and `next_tick_at`. ## Cron trigger The `scheduled` handler runs every 5 minutes (`*/5 * * * *`) and: 1. Cleans up the container registry: abandoned uploads and blobs no image uses. 2. Sets the workflow scheduler's alarm if it isn't set. 3. Sends token expiry reminders and, once a day, prunes the email log. 4. Cancels draft organization imports (started in the web form, repositories never chosen) that are more than 7 days old, with the error `draft expired`, and erases their GitHub token. 5. Tells search engines through IndexNow about public pages a deploy added, changed or removed, and, hourly, about public repositories pushed since (see [Search engines](https://diffinery.com/docs/admin/site-administration#search-engines)). It keeps its state in D1 (`indexnow_urls`, `indexnow_submissions`, `indexnow_state`). ## Live updates Pages that show changing state (run logs, job status, pull requests) open a WebSocket to `/<owner>/<repo>/live?topics=...`. The Worker checks that the caller can read the repository, then hands the connection to the repository's Durable Object. The object accepts it with the WebSocket Hibernation API, tagged with up to 10 topics: `repo`, `issues`, `runs`, `run:<id>`, `job:<id>` and `pr:<number>`. Whenever something changes, the object broadcasts a JSON message `{topic, data}` to the sockets tagged with that topic. With hibernation, idle connections don't keep the object in memory. Clients send `ping` and get `pong` to keep the connection open, and reconnect with backoff. Messages on the `repo` topic only say that something changed (`{kind: "event", type}`, never the event's payload, and never audit entries), and the page fetches what changed with the viewer's own access. Access is checked when a socket connects, so each socket lasts 10 minutes: after that the object closes it with code 4001 and the page reconnects at once, passing the check again. Someone who lost access, or a signed-out reader of a repository made private, stops getting updates within 10 minutes. ## Email Sending and Access - **Email Sending** (`send_email` binding `EMAIL`) delivers every email from `MAIL_FROM`. When the binding is missing, emails are logged as skipped instead of sent. - **Cloudflare Access** protects only `/auth/access`. Everyday sign-in is passkeys with sessions in D1; git, the API, MCP and runners use Diffinery tokens. `/auth/access` verifies the Access JWT (RS256, against the team's public keys, `aud` and `iss`) and turns the verified email into a Diffinery session. ## Rate limits Every request passes the rate limiting bindings before it is handled (`src/lib/rate-limit.ts`). Sign-in, invitation and security report requests count per IP address in `RL_AUTH`, other anonymous requests per IP address in `RL_ANON`, and requests with a session or token per account in `RL_ACCOUNT`. Requests that create repositories, organizations, forks, issues, pull requests, releases, comments or reviews also count in `RL_CREATE`, as do MCP tool calls that change something. Job, sandbox, runner and import credentials, health checks and avatars aren't counted. Cloudflare keeps each count in the location that serves the request, so the limits stop bursts rather than meter use exactly. Past a limit the request answers `429` with `retry-after: 60`. See [Bindings](https://diffinery.com/docs/self-hosting/configuration#bindings). ## Request flow: a git push 1. `git push` sends `POST /<owner>/<repo>.git/git-receive-pack` with a token in Basic authentication. 2. The Worker resolves the token, loads the repository from D1, computes the caller's role (account role, capped by the token's scope and repository restriction, and by agent rules) and requires write access. A request without credentials gets `401 authentication required`, which makes Git ask for them, and a `read`-scoped token gets `403 this token needs the write scope`. It reads the body (decompressing gzip) and calls the repository's Durable Object. 3. The Durable Object refuses pushes over 100 MB, parses the ref commands and the packfile, and resolves deltas against objects it already has. Nothing is stored yet. 4. **Push protection** scans every new text blob for credentials, whatever its size. A finding blocks the whole push unless a human repository admin passed `-o allow-secrets=<reason>`. Both a block and a bypass are recorded as audit events. 5. The objects are written (SQLite or R2 by size). 6. Each ref update is checked: valid ref name, write role, a job token can't create, change or delete workflow files, `refs/pull/` and merge queue branches are read-only, objects exist, tags can only be moved by maintainers, agents can't push to the default or protected branches, and branch protection rules (pull request required, force push, deletion, linear history; admins bypass unless the rule is enforced for them). When several rules match the branch, the rule whose pattern is exactly the branch name applies; otherwise the oldest matching rule does. With `atomic`, one failure rejects all. 7. Each accepted ref moves with a compare-and-swap, and the object records who moved it and whether that was a person or an agent. 8. After each ref moves, the object emits a `push` event to the events queue and to WebSocket subscribers, updates open pull requests whose head branch moved (new head SHA, stale approvals dismissed when the rule asks for it, a `synchronize` event, new test merge commits and `pull_request` workflows), starts `push` workflows whose filters match, re-syncs schedules when the default branch moved, and re-checks merge queues on that branch. 9. The report goes back to git, including messages like "Create a pull request for 'feature':" with a link. 10. In the background, the Worker records the push time in D1. The events queue consumer archives the event, writes the audit log entry, delivers webhooks and sends notifications. ## Request flow: a workflow run 1. A trigger (push, pull request, dispatch, `repository_dispatch`, a release, schedule, `workflow_run`, merge group) reaches the Durable Object, which loads `.github/workflows/*.yml` and `.diffinery/workflows/*.yml` from the commit, parses them with `@diffinery/workflow-yaml`, and matches triggers and filters. 2. For each matching workflow it inserts a run, evaluates `run-name` and `concurrency` (cancelling older runs with `cancel-in-progress`), creates a `diffinery-run` Workflow instance and emits a `workflow_run` event. 3. The Workflow plans the job graph and starts the jobs that need nothing. For each job the Durable Object expands reusable workflows and the matrix, inserts job rows and a queued check run on the commit, works out the job's effective permissions (from `permissions:`, the repository's default, and the caller of a reusable workflow) and records them, creates a job token and a runtime token, and starts a `RunnerContainer`. A job with `environment:` is first checked against the environment's branch policy, gets the environment's secrets and variables, and creates a deployment that tracks the job. Self-hosted jobs wait for a runner to claim them instead. 4. The runner agent in the container fetches the job spec (steps, contexts, secrets and variables decrypted for this job), checks out the code and runs the steps. Log lines go to the Durable Object, which stores them and broadcasts them on `job:<id>` to open browser tabs. Caches and artifacts go to R2 through the [cache and artifact services](#actions-cache-and-artifact-services); OIDC tokens are signed with `OIDC_PRIVATE_KEY`. 5. On completion the agent reports outputs and conclusion. The Durable Object updates the job and its check run, archives the log to R2, cancels matrix siblings on failure with `fail-fast`, starts the next queued matrix job, and sends the `job-<id>` event to the Workflow instance. 6. The Workflow starts every job whose needs have now all finished. When every job is done, it finishes the run: the Durable Object records the conclusion, emits a `workflow_run` completed event (which drives webhooks and failure emails) and starts any `workflow_run` followers. ## Request flow: a GitHub import 1. `POST /api/v3/diffinery/imports` (also the web form, `diffinery migrate github` and the MCP tool `start_github_import`) reaches the Worker. It validates the request, checks that the caller may import into the target account and is under the limits of imports at a time, and reads the source repository (or the GitHub organization's repository list) from the GitHub REST API. It encrypts the GitHub token with a key derived from `MASTER_KEY`, records the import in D1 (one `imports` row per repository, plus one for an organization import), writes the `import.create` audit entry and creates the `ImportWorkflow` instance. 2. **Repository.** The Workflow checks again that the person or agent who started the import may still import into the target, creates the repository (or reuses the one an earlier attempt of the same import created), copies its description, homepage and topics, and takes the repository's import lock in its Durable Object. While the lock is held, the Durable Object refuses every `git push` that isn't this import's (`repository is locked by an import in progress`). A retry also refuses to continue when the repository holds refs, issues, pull requests, comments, reviews, labels, milestones or protection rules that the import didn't write. 3. **Git transfer.** The Worker mints a push token for the transfer (the starter's account and token scope, this repository only, valid for one day) and starts an `ImportContainer`. The agent fetches its spec from `/api/v3/diffinery/imports/<id>/agent/spec`, runs `git clone --mirror` from GitHub and fetches the LFS objects. The GitHub token reaches git only through per-host configuration in the environment, never in a command line or a log, and git never follows redirects. The agent then pushes the history to Diffinery in chunks of at most 32 MiB, then every branch and tag, the head commit of each pull request to a temporary `refs/diffinery-import/pull/<n>` ref, and the LFS objects. Every push carries `-o diffinery-import`, which the Durable Object honors only for that import's current push token: push protection still scans every new blob, refs can only be created or moved if this import wrote them, and no push event, workflow, webhook or notification follows. Each such push writes one `import.push` audit entry. When old history contains credentials and the import was set to bypass, the push also carries `-o allow-secrets=Imported from GitHub <owner>/<name>`, which works only for a person with the admin role and an `admin` token or browser session (see [Push protection](https://diffinery.com/docs/security/push-protection#imported-history)). 4. The agent reports its progress and then its result, which revokes the push token and wakes the Workflow with a `git-done` event. The Workflow also checks every 10 minutes, for up to 6 hours. A transfer that failed for any reason other than push protection is tried once more with a new container and a new push token. 5. **Paged steps.** Labels and milestones, issues and pull requests (with their original numbers), issue comments, reviews, review comments, branch protection, Actions variables with secret names and deployment environments, and releases are read from the GitHub REST API, a few pages per Workflow step. Issues, pull requests, comments, reviews, labels and milestones are written by GitHub number or id and marked with the import, so a retried step doesn't duplicate them. People are mapped through linked GitHub accounts; everyone else becomes a `github:<login>` mannequin. Nothing these steps write fires workflows, webhooks or notifications. 6. **Finish.** The Durable Object creates `refs/pull/<n>/head` for every imported pull request whose head commit arrived and removes the temporary refs. The Worker sets the default branch and archived state from GitHub, records the counts and push protection findings in the import's summary, releases the lock, revokes every push token, deletes the GitHub token, writes `import.finish` and sends the starter an in-app notification. 7. If a step still fails after its retries, the import is marked `failed` with the error: its tokens are revoked, the lock is released, its containers are stopped, an `import.fail` audit entry is written and the starter gets an in-app notification. The repository is kept with what was imported. An organization import runs a Workflow of its own: it copies the organization's Actions variables (only those the target organization doesn't have yet) and secret names when a person imports an organization into an organization, then starts its repository imports, each its own Workflow instance, up to its concurrency. A repository import that ends starts the next queued one and wakes the organization import when none is left. The organization import also checks every hour, for up to a week, restarting any that are stuck in the queue. ## Packages in the repository | Path | Package | What it contains | | --- | --- | --- | | `apps/diffinery` | `@diffinery/app` | The Worker: HTTP routes, `RepoDO`, `RunWorkflow`, container classes, web UI, email, registry, MCP server, D1 migrations, `wrangler.jsonc`, unit and end-to-end tests. | | `apps/cli` | `diffinery` | The command-line client. | | `containers/runner` | `@diffinery/runner` | The runner agent and sandbox server (bundled to `dist/agent.mjs`) and the container image. | | `packages/git-core` | `@diffinery/git-core` | Git in pure TypeScript: SHA-1, zlib, object encoding, packfiles and deltas, pkt-lines, an object store interface, tree diffs, merge bases and three-way merges. | | `packages/workflow-yaml` | `@diffinery/workflow-yaml` | The GitHub Actions workflow parser and validator, the `${{ }}` expression language (lexer, parser, evaluator), matrix expansion, branch, tag and path filters, the job graph and cron matching. | | `packages/shared` | `@diffinery/shared` | Types shared by the Worker and the runner: the runner API contract (job spec, steps, log lines, completion) and repository events. | ## Data model ### D1 tables | Table | Holds | | --- | --- | | `accounts` | Users, organizations and agents: login, kind, email, status (`active`, `pending`, `disabled`), site admin flag, an agent's owner and policy, profile picture version and appearance setting (`light`, `dark`, or empty to follow the system). | | `org_members` | Organization membership with role `owner`, `admin` or `member`. | | `repos` | Every repository: owner, name, description, homepage, visibility (`public` or `private`), topics, default branch, archived flag and timestamps. | | `repo_redirects`, `login_redirects`, `package_redirects` | Old owner and repository names left by transfers and renames, earlier logins of renamed people and organizations, and old names of container images that moved with their repository, so old URLs keep leading to them. A redirect ends when a repository (or account) takes the old name. | | `repo_access` | Direct repository access for a person or agent, with role `read`, `triage`, `write`, `maintain` or `admin`. | | `tokens` | Personal and agent tokens: SHA-256 hash, prefix, scopes, repository restriction, expiry, last use, revocation, expiry reminder, and the import a GitHub import's push token belongs to. | | `invites` | Invitations to the site, an organization or a repository, by token hash, with expiry and acceptance. | | `access_requests` | Requests from people waiting for an invite. | | `early_access`, `early_access_attempts` | [Early access](https://diffinery.com/docs/accounts/early-access) requests from `/early-access`: the address, name, company, use and hosting given, the state (`unconfirmed`, `confirmed`, `invited`, `declined`), hashes of the confirmation and removal links' codes, and who decided; and one row per form submission with hashes of the IP address and the address typed, kept a day for the daily limits. | | `security_reports` | Private vulnerability reports from `/security/report`: summary, details, affected area, reply-to address, the reporter when signed in, a hash of the sender's IP address (for the daily limits), and the state (`new`, `triaged`, `resolved`) and note a site administrator set. | | `passkeys` | WebAuthn credentials: public key, counter, transports, device type, backup state, name. | | `sessions` | Browser sessions by hash of the cookie value, with the sign-in method and passkey, user agent and expiry. | | `webauthn_challenges` | In-flight passkey ceremonies. | | `signin_links` | Admin-issued single-use sign-in links. | | `notifications` | In-app notifications. | | `audit_log` | The append-only audit log: actor, on-behalf-of, action, target, detail. | | `schedules` | Cron expressions of default-branch workflows, read by the cron trigger. | | `org_secrets` | Organization Actions secrets, encrypted. | | `org_variables` | Organization Actions variables. A copy is kept in each of the organization's repositories. | | `imports` | GitHub imports: kind (one repository or an organization), status, attempt, source, target, options, per-step progress, the Git transfer's progress and result, summary, warnings, error, the encrypted GitHub token while the import runs and the current push token's id. | | `account_links` | Verified links between Diffinery accounts and GitHub accounts, by API base and GitHub's numeric user id. | | `packages`, `package_manifests`, `package_tags`, `package_blobs`, `package_manifest_blobs`, `registry_blobs`, `registry_uploads` | The container registry index: packages, their manifests, tags and blobs, and uploads in progress. The bytes are in R2. | | `notification_prefs`, `repo_watches`, `thread_subscriptions` | Notification settings, repository watching and per-thread subscriptions. | | `known_devices` | Browsers and passkeys a person has signed in with, so only new ones trigger a security email. | | `email_log` | Every email Diffinery tried to send, with status. | ### RepoDO tables | Table | Holds | | --- | --- | | `meta` | The repository's identity (ID, owner, name, default branch, owner kind), its workflow settings and, while a GitHub import fills it, that import's lock. | | `refs` | Branches, tags, `refs/pull/*` and merge queue refs. | | `objects` | Git objects: type, size, deflated data, or a flag that the data is in R2. | | `counters` | Number sequences: issue and pull request numbers, run numbers and merge queue order. | | `issues` | Issues and pull requests (they share one number sequence), with title, body, state, author, assignees, labels and lock state. | | `pulls` | Pull request fields: head and base branches, the head repository, head SHA, draft, merge state, merge commit, requested reviewers, intent, and the auto-merge setting while it is on (method, commit text, who turned it on and with which role). | | `comments` | Issue and pull request comments, and review line comments with path, line, side, commit and resolution. | | `reviews` | Pull request reviews with state and commit. | | `labels` | The repository's labels. | | `milestones`, `issue_milestones` | Milestones, and the milestone of each issue and pull request. | | `import_refs`, `import_findings` | The refs a GitHub import wrote (so a retry only moves its own), and the push protection findings it bypassed. Imported issues, pull requests, comments, reviews, labels, milestones and protection rules carry the import's id in an `imported` column. | | `org_variables` | A copy of the owning organization's Actions variables, for `vars.*`. | | `protections` | Branch protection rules by branch pattern, in the order they were created (the oldest matching pattern applies when no rule names the branch exactly). | | `secrets`, `variables` | Repository Actions secrets (encrypted) and variables. | | `webhooks` | Webhook URL, secret, events, and the last delivery status and time. | | `checks` | Check runs by commit, from workflows and from external tools, with who reported each one (person or agent). | | `statuses` | Commit statuses by commit and context. | | `releases`, `release_assets` | Releases and their asset metadata (name, label, content type, size, download count). | | `deployments`, `deployment_statuses` | Deployments and their status history. | | `environments`, `environment_secrets`, `environment_variables` | Deployment environments with their branch policy, encrypted secrets and variables. | | `reactions` | Reactions on issues, pull requests and comments. | | `issue_events` | The event history of issues and pull requests (for example labeled, assigned, locked). | | `ref_actors` | Who last created or moved each branch and tag, and whether that was a person or an agent. | | `runs`, `jobs` | Workflow runs and their jobs: event, refs, SHAs, status, conclusion, whether the run's code came from an agent, matrix, outputs, steps, timeout, `continue-on-error`, concurrency group, labels. | | `logs` | Log lines of running jobs (moved to R2 when the job completes). | | `rt_caches`, `rt_artifacts` | Caches and artifacts from the cache and artifact services: key, version and branch of each cache, name, size, retention and expiry of each artifact, and uploads in progress. Artifacts from older runner agents are here too, marked `legacy`. The contents are in R2. | | `caches` | Caches saved by older runner agents; the archives are in R2. (The older `artifacts` table is emptied into `rt_artifacts` when the repository's schema is migrated.) | | `job_grants` | The role and effective permissions of each job's token. | | `merge_queue` | Queued pull requests and their merge groups. | | `sandboxes` | Agent sandboxes, who created them and their status. | --- # Status page > The public status page at /status and its JSON: the components, how their statuses are derived, posting incidents, and why it needs an external uptime monitor beside it. Source: https://diffinery.com/docs/self-hosting/status-page Every Diffinery instance has a public status page at `/status`, like GitHub's githubstatus.com. It says whether each part of the instance works, what the site administrators are doing about an incident in progress, and what went wrong in the last 14 days. Anyone can open it without signing in, and it is linked as **Status** at the bottom of every page. The same information is available as JSON for monitors and scripts. The page is served by the instance itself, so it can't report that the whole instance is down. See [Monitoring from outside](#monitoring-from-outside). ## What the page shows From the top: 1. **The overall status**: a banner with the worst status of any component, such as "All systems operational" or "Partial outage". 2. **Active incidents**: each unresolved incident with its title, impact, the components it affects and its updates, newest first. 3. **Components**: each component with its status, and a note when something other than an incident set it. 4. **Past incidents**: incidents resolved in the last 14 days, with all their updates. Updates are plain text: Markdown and HTML show as written. ## Components | Id | Name | What it covers | | --- | --- | --- | | `web` | Website and API | The web UI, the REST API and the MCP server | | `git` | Git operations | Clone, fetch and push over HTTPS, and Git LFS | | `actions` | Actions | Workflow runs and jobs on hosted and self-hosted runners | | `agents` | Agents and sandboxes | Agent tasks, reviews, web terminals and sandboxes | | `packages` | Packages | The container registry | | `email` | Email | Notification and security emails | ## How statuses are derived A component's status uses the names of Atlassian Statuspage, which GitHub's status page uses too. It is the worst of: - the impact of each unresolved incident that names the component: | Incident impact | Component status | | --- | --- | | `critical` | `major_outage` (**Major outage**) | | `major` | `partial_outage` (**Partial outage**) | | `minor` | `degraded_performance` (**Degraded performance**) | | `maintenance` | `under_maintenance` (**Under maintenance**) | - the instance-wide [kill switches](https://diffinery.com/docs/admin/site-administration#kill-switches): while hosted compute is paused, **Actions** and **Agents and sandboxes** are `under_maintenance`, with the notes "Hosted jobs are paused by the site administrators. Jobs on self-hosted runners still run." and "Agent sandboxes are paused by the site administrators." The pause's reason isn't shown. An incident with a worse impact wins, and the note stays. With neither, a component is `operational`. From best to worst: operational, under maintenance, degraded performance, partial outage, major outage. An incident counts from when it is opened until it is resolved, whatever its state (investigating, identified or monitoring) in between. The overall status follows the worst component: | Worst component | `indicator` | Description | | --- | --- | --- | | `operational` | `none` | All systems operational | | `under_maintenance` | `maintenance` | Service under maintenance | | `degraded_performance` | `minor` | Degraded performance | | `partial_outage` | `major` | Partial outage | | `major_outage` | `critical` | Major outage | When the page can't read the D1 database, it still answers: **Website and API** shows as a major outage with the note "This instance can't read its database right now, so most pages and API calls fail, and incidents can't be shown." and no incidents are listed. ## Posting incidents Site administrators manage incidents under **Settings → Status** (`/settings/admin/status`). To open an incident, fill in **Open an incident** and click **Open the incident**: - **Title**: one line, up to 200 characters. - **Impact**: minor, major, critical or maintenance (see the table above). - **Affected components**: at least one. - **State**: investigating, identified or monitoring. - **First update**: what is happening, up to 5,000 characters of plain text. The incident is on the status page at once. Under **Open incidents**, each incident has a form to post an update: pick its new state, write the update and click **Post the update**. Choose **Resolved** to resolve it: its components go back to operational (unless something else affects them) and it moves to past incidents for 14 days. Posting an update with another state to a resolved incident reopens it. Opening, updating and resolving are recorded in the [audit log](https://diffinery.com/docs/security/audit-log) as `status_incident.create`, `status_incident.update` and `status_incident.resolve`, without the text of the updates. Everything typed into an incident is public: don't put internal details or people's data in it. Diffinery doesn't open incidents by itself, except for the kill switch and database signals above, and it doesn't email anyone about them. ## JSON `GET /api/v3/diffinery/status` needs no authentication and is shaped like Statuspage's summary. Shared caches may keep it for 30 seconds (`cache-control: public, max-age=30`). ```json { "status": { "indicator": "major", "description": "Partial outage" }, "components": [ { "id": "web", "name": "Website and API", "status": "operational" }, { "id": "git", "name": "Git operations", "status": "partial_outage" }, { "id": "actions", "name": "Actions", "status": "under_maintenance", "note": "Hosted jobs are paused by the site administrators. Jobs on self-hosted runners still run." } ], "incidents": [ { "id": "4f1c...", "title": "Pushes are failing", "impact": "major", "state": "identified", "components": ["git"], "created_at": "2026-10-14T09:12:00.000Z", "updated_at": "2026-10-14T09:40:00.000Z", "resolved_at": null, "updates": [ { "id": "9a0b...", "state": "identified", "body": "We found the cause and are rolling out a fix.", "created_at": "2026-10-14T09:40:00.000Z" }, { "id": "77de...", "state": "investigating", "body": "We are investigating failed pushes.", "created_at": "2026-10-14T09:12:00.000Z" } ], "html_url": "https://diffinery.com/status#incident-4f1c..." } ], "updated_at": "2026-10-14T09:41:05.120Z" } ``` (The example lists three of the six components.) `incidents` holds only unresolved incidents; `updated_at` is when the summary was made. Site administrators manage incidents through the API too. To anyone else these endpoints answer `404`, as if they didn't exist. | Method and path | Description | Needs | | --- | --- | --- | | `GET /api/v3/diffinery/admin/status/incidents` | Incidents, newest first, resolved or not, as in the summary plus `created_by` and each update's `author`. Takes `per_page` (default 50, up to 200). | site administrator, a `read` token not limited to repositories, or a browser session | | `POST /api/v3/diffinery/admin/status/incidents` | `{"title": "...", "impact": "major", "components": ["git"], "state": "investigating", "body": "..."}`: opens an incident with its first update. Answers `201` with the incident. | site administrator, with an `admin` token not limited to repositories, or a browser session | | `POST /api/v3/diffinery/admin/status/incidents/{id}/updates` | `{"state": "resolved", "body": "..."}`: posts an update and moves the incident to its state. Answers `201` with the incident. | site administrator, with an `admin` token not limited to repositories, or a browser session | What is missing or unknown is refused with `422` and a sentence saying what to fix, such as "Choose at least one affected component." ```sh curl https://diffinery.com/api/v3/diffinery/status echo '{"title": "Pushes are failing", "impact": "major", "components": ["git"], "state": "investigating", "body": "We are investigating failed pushes."}' | diffinery api POST /diffinery/admin/status/incidents --input - echo '{"state": "resolved", "body": "Pushes work again."}' | diffinery api POST /diffinery/admin/status/incidents/<id>/updates --input - ``` ## Monitoring from outside The status page runs in the same Worker as everything else. When the whole Worker is down, because of a bad deploy, a Cloudflare incident or an exceeded plan limit, `/status` is down too and can't tell anyone. Watch the instance from outside as well: - Point an external uptime monitor (any service that checks a URL every minute or so) at `https://<your host>/healthz`, which answers `{"ok": true}` without touching the database, and alert yourself when it fails. - Optionally also watch `/api/v3/diffinery/status` and alert when `status.indicator` isn't `none`, to hear about incidents and kill switches. A database failure shows there as a `critical` indicator while `/healthz` still answers. Tell people where to look when the instance doesn't answer at all, for example a status page hosted elsewhere or a chat channel. --- # REST API > How to call Diffinery's GitHub-compatible REST API at /api/v3: authentication, IDs, pagination, errors, search, archives and what works by area. Source: https://diffinery.com/docs/reference/rest-api Diffinery has a JSON REST API at `https://diffinery.com/api/v3` with the same paths, JSON shapes and errors as GitHub's REST API v3, so Octokit, `curl` scripts and other GitHub clients work when you point them at it. This page explains how the API behaves as a whole: authentication, IDs, pagination, errors, rate limits, search, archives, what works in each area and what doesn't yet. For every endpoint with its parameters, see the generated [API reference](https://diffinery.com/docs/api), also available as an OpenAPI 3.1 document at [/docs/api/openapi.json](https://diffinery.com/docs/api/openapi.json). ## Base URL Every REST endpoint lives under one base URL: ```text https://diffinery.com/api/v3 ``` A `GET` on the base URL itself needs no authentication and describes the API: ```sh curl https://diffinery.com/api/v3 ``` ```json { "name": "Diffinery", "api": "v3", "docs": "https://diffinery.com/docs/api", "mcp": "https://diffinery.com/mcp", "graphql": "https://diffinery.com/api/graphql" } ``` `GET /api/v3/meta` returns `{"name": "Diffinery", "oidc": true}`, where `oidc` says whether this instance can issue OpenID Connect tokens to workflow jobs. Some related HTTP interfaces are not under `/api/v3`: | Interface | Path | Documented in | | --- | --- | --- | | Git smart HTTP and Git LFS | `https://diffinery.com/<owner>/<repo>.git` | [Git](https://diffinery.com/docs/repositories/git), [Git LFS](https://diffinery.com/docs/repositories/git-lfs) | | Release asset uploads (the `upload_url` of a release) | `https://diffinery.com/api/uploads/repos/<owner>/<repo>/releases/<id>/assets` | [Release assets](#release-assets) | | Release asset downloads (`browser_download_url`) | `https://diffinery.com/<owner>/<repo>/releases/download/<tag>/<name>` | [Release assets](#release-assets) | | Source archives for browsers | `https://diffinery.com/<owner>/<repo>/archive/<ref>.zip` and `.tar.gz` | [Archives](#archives) | | Profile pictures | `https://diffinery.com/api/avatars/<login>` | [Diffinery-only endpoints](#diffinery-only-endpoints) | | MCP server for agents | `https://diffinery.com/mcp` | [MCP](https://diffinery.com/docs/agents/mcp) | | Container registry (OCI distribution API) | `https://diffinery.com/v2/` | [Container registry](https://diffinery.com/docs/packages/container-registry) | | OIDC discovery for workflow tokens | `/.well-known/openid-configuration`, `/.well-known/jwks` | [OIDC](https://diffinery.com/docs/actions/oidc) | | Status page | `/status`, and `GET /api/v3/diffinery/status` as JSON | [Status page](https://diffinery.com/docs/self-hosting/status-page) | | Health check | `/healthz` returns `{"ok": true}` | | ## Compatibility with GitHub Point a GitHub client at `https://diffinery.com/api/v3` and it works: ```js import { Octokit } from "@octokit/rest"; const octokit = new Octokit({ auth: process.env.DIFFINERY_TOKEN, baseUrl: "https://diffinery.com/api/v3" }); ``` What matches GitHub: - **Paths and parameters.** `/repos/{owner}/{repo}/pulls`, `/repos/{owner}/{repo}/issues/{number}/comments`, `/repos/{owner}/{repo}/releases`, `/search/issues`, `/user`, `/orgs/{org}/repos` and so on, with GitHub's query parameters and request body fields (`merge_method`, `commit_title`, `event: "APPROVE"`, `required_contexts`, `{"config": {"url": ...}}` on webhooks). - **Response shapes.** Objects carry GitHub's fields, including `node_id`, `url`, `html_url` and the other `*_url` links, full user objects (`login`, `id`, `node_id`, `avatar_url`, `html_url`, `type`) and full label objects (`id`, `name`, `color`, `description`). Timestamps are ISO 8601 without milliseconds, like `2026-10-13T09:30:00Z`. - **Numeric IDs.** Repository, user, issue, comment, review, review comment, check run, release, release asset, commit status, deployment and reaction IDs are numbers, as on GitHub. See [IDs](#ids). - **Account types.** A user object's `type` is `User`, `Organization` or `Bot`. Agents are `Bot`. - **Pagination.** Lists take `per_page` and `page` and send a `Link` header, so `octokit.paginate()` works. See [Pagination](#pagination). - **Errors.** Error bodies have `message` and `documentation_url`, and validation errors add GitHub's `errors` array. See [Errors](#errors). - **Headers.** API responses carry `X-GitHub-Media-Type: github.v3; format=json`, `X-GitHub-Api-Version-Selected: 2022-11-28` and `X-RateLimit-*` headers. - **Aliases.** Collaborator permissions accept GitHub's `pull` and `push` for `read` and `write`, and `private: false` on a repository makes it public. Responses also keep a few Diffinery fields that the CLI, the MCP server and the web UI read. Clients that only know GitHub's fields can ignore them: | Object | Extra fields | | --- | --- | | Pull request (`GET /pulls/{n}`) | `merge_blockers` (why the caller can't merge it directly yet), `approvals`, `required_approvals`, `checks`, `merge_queue`; for agents and workflow tokens also `auto_merge_blockers` (why auto-merge or the merge queue can't land it for them yet; `[]` means it would now). People merge and auto-merge on the same terms, so they don't get it. | | Pull request `auto_merge` | `delete_branch`: whether the head branch is deleted once auto-merge lands it | | Pull request (every response) | `intent`, a short statement of what the change is for, written for reviewers | | Repository (`GET /repos/{owner}/{repo}`) | `stats` (counts of open issues and pull requests and more), and `permissions.role` | | Workflow run | `sha` and `ref` (what the run checked out: the test merge commit for `pull_request` runs) and `pull_request_number` | | Workflow job | Diffinery's own camelCase job fields next to GitHub's snake_case ones | | Check run | `run_id` and `job_id` of the workflow behind an Actions check | | Comparison | `base_sha`, `head_sha` and `merge_base` | | Release | `latest`, whether it is the repository's latest release | | Pull request file | `binary` | Fields that describe GitHub features Diffinery doesn't have are present with fixed values, so typed clients don't break: for example `fork: false`, `forks_count: 0`, `stargazers_count: 0`, `watchers_count: 0`, `language: null`, `license: null` and `has_wiki: false`. ### IDs | Object | `id` | | --- | --- | | Users, organizations, agents, repositories | A positive integer derived from the account's or repository's own ID in Diffinery. It is stable, but nothing looks an account or repository up by it: use the login or `owner/repo` in paths. | | Issues and pull requests | The `id` equals the `number` (issues and pull requests share one number sequence per repository). | | Issue comments, review comments, reviews, check runs, releases, release assets, commit statuses, deployments, deployment statuses, reactions, issue events | Integers, as on GitHub. Use them in paths such as `/issues/comments/{id}` and `/check-runs/{id}`. | | Workflow runs and jobs | 32-character hexadecimal strings. Use them as they are in `/actions/runs/{id}` and `/actions/jobs/{id}`. Getting a run, its jobs, logs and attempts, re-running it and listing its artifacts also accept the run's number (`run_number`, which is `github.run_id` inside a workflow): it picks the latest attempt, and for artifacts it covers every attempt. | | Artifacts and cache entries | Integers, as on GitHub: `/actions/artifacts/{id}`, `/actions/caches/{id}`. An artifact's `workflow_run.id` is the run number. | | Workflows | The workflow's path, such as `.github/workflows/ci.yml`. `/actions/workflows/{id}` also accepts the file name, `ci.yml`. | | Webhooks and tokens | Strings. | `node_id` is an opaque string, stable for an object. The [GraphQL API](https://diffinery.com/docs/reference/graphql-api) accepts the `node_id` of a repository or pull request in `node(id:)` and in its mutations. ### Differences to plan for | Area | GitHub | Diffinery | | --- | --- | --- | | Pull requests | Mergeability spread over several calls | `GET /pulls/{n}` also returns `merge_blockers`, `approvals`, `required_approvals` and `checks`, so one call tells you why a pull request can't merge yet. `GET /pulls/{n}/merge` answers GitHub clients (an `Accept: application/vnd.github...` header) with `204` or `404`, and other clients with Diffinery's full mergeability report. | | Draft pull requests | Marking ready for review or converting to draft is GraphQL only | `PATCH /repos/{owner}/{repo}/pulls/{n}` accepts `"draft": false` (ready for review) and `"draft": true` (back to draft). | | Merge queue | GraphQL only | REST: `PUT` and `DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue`, `GET /repos/{owner}/{repo}/merge-queue/{branch}`. See [Merge queue](https://diffinery.com/docs/collaboration/merge-queue). | | Auto-merge | GraphQL only (`enablePullRequestAutoMerge`) | REST: `PUT` and `DELETE /repos/{owner}/{repo}/pulls/{n}/auto-merge`. The pull request's `auto_merge` field is filled in while it is on. See [Auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge). | | Text filter on issue lists | Not on the list endpoints | A `q` parameter on `GET /repos/{owner}/{repo}/issues` and `/pulls` matches text in the title and body. | | Webhook secret | You always choose it | If you create a webhook without `config.secret`, Diffinery generates one and returns it once as `secret` in the response. | | Release asset upload host | `uploads.github.com` | The same host as the API, under `/api/uploads`. Use the release's `upload_url` and clients find it. | | Required checks | Decided by GitHub Apps and statuses | When Diffinery Actions reports a check, it alone decides it; other reporters can be bound per check. See [Commit statuses, checks and required checks](#commit-statuses-checks-and-required-checks). | | Rate limits | Budgets per hour, reported in headers and `GET /rate_limit` | Per-minute limits that stop floods (sign-in and anonymous requests per IP address, signed-in requests and new content per account). The headers and `GET /rate_limit` report a constant budget. See [Rate limits](#rate-limits). | | CORS | Allowed for some endpoints | No CORS headers. Call the API from a server, a script, a CI job or the CLI, not from JavaScript on another website. | | People imported from GitHub | Every author is a GitHub account | In a repository [imported from GitHub](https://diffinery.com/docs/migrate-from-github), people without a linked Diffinery account are mannequins: a user object with `login` `github:<login>`, `type` `Mannequin`, and `html_url` and `url` pointing at their GitHub profile. They can't sign in, and their imported reviews never count as approvals. Search for them with `author:github:<login>`. | ### Transferred and renamed repositories After a repository is [transferred](https://diffinery.com/docs/repositories/overview#transfer-a-repository) or renamed, requests that name it by its old owner and name in the path keep working, as on GitHub, for anyone who can read it: - `GET` and `HEAD` get `301 Moved Permanently`; every other method gets `307 Temporary Redirect`, so a client that follows it repeats the method and body. - `Location` holds the same URL with the new owner and name (the query string is kept), and the body is `{"message": "Moved Permanently", "url": "<the new URL>", "documentation_url": "..."}`. - Callers who can't read the repository get `404` (or `401` without credentials), the same answer as for the repository under its new name. - Old names that appear elsewhere than in the path (for example `repositories` when creating a token restricted to some repositories) are accepted as the repository they now lead to. `curl` follows redirects with `-L`, and Octokit follows them by itself. The redirect lasts until a repository is created at the old owner and name. Update stored owner and repository names when you see one, and use the repository's numeric `id`, which never changes, to recognize it. ## Authentication Most endpoints need you to be signed in. Scripts, CI jobs, agents and the CLI authenticate with a personal access token or an agent token. Tokens start with `dfy_`. Create one under **Settings → Access tokens** (`/settings/tokens`). No token can create another token, so `POST /api/v3/diffinery/tokens` needs a browser session (see [Personal access tokens](https://diffinery.com/docs/accounts/tokens)). Send the token in the `Authorization` header. Three schemes are accepted, so any GitHub client works: | Scheme | Example | Typical sender | | --- | --- | --- | | `Bearer` | `Authorization: Bearer dfy_...` | curl, fetch, MCP clients, the Diffinery CLI | | `token` | `Authorization: token dfy_...` | Octokit and other GitHub libraries | | `Basic` | `Authorization: Basic base64(diffy:dfy_...)` | git, `curl -u`, Docker | With `Basic`, Diffinery takes the password as the token and ignores the username, so any username works (`x-token`, your login, anything). If the password is empty, the username is used as the token. ```sh curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/user curl -u diffy:dfy_... https://diffinery.com/api/v3/user ``` Inside a workflow job, the job's `GITHUB_TOKEN` works with the API too, with the permissions of the job's `permissions:` rather than token scopes. See [Workflow job tokens](#workflow-job-tokens). Without an `Authorization` header and without a browser session, you are anonymous. Anonymous callers can read public repositories (and `GET /api/v3`, `/api/v3/meta`, `/rate_limit` and search over public repositories); most other endpoints return `401`. As on GitHub, anonymous callers never change anything: every repository request other than `GET` or `HEAD` (opening an issue, commenting, reacting, watching and so on) gets `401 authentication required`, even on a public repository. Anonymous `git clone` and `git fetch` of public repositories and Git LFS downloads keep working, because they only read. Authentication errors: | Status | `message` | Cause and fix | | --- | --- | --- | | `401` | `invalid, expired or revoked token` | The `dfy_` token is unknown, past its expiry date, revoked, or its account is disabled or not yet active. Create a new token. | | `401` | `unrecognised token; create one under Settings → Access tokens` | The credential does not start with `dfy_`. GitHub tokens (`ghp_...`) do not work here. | | `401` | `authentication required` | You called a private resource anonymously, or tried to change something in a repository without a token. Add the `Authorization` header. Reading a private repository anonymously gets `404`, the same as one that doesn't exist. | | `401` | `malformed basic credentials` | The `Basic` value isn't valid base64. Encode `user:token` with base64. | | `401` | `sign in required` | The endpoint needs an account (for example `GET /user`) and you sent no credentials. | | `403` | `your account is waiting for an invite` | Your account exists but no one has invited you yet. Ask an administrator for an invite. | | `403` | `your account is disabled` | A site administrator disabled the account. | Every `401` response carries `WWW-Authenticate: Basic realm="Diffinery", charset="UTF-8"`, which makes git and other HTTP clients prompt for credentials. If a request carries the `Authorization` header twice (git-lfs does this when `http.extraheader` is set), Diffinery uses the first one. ### Token scopes A token has one or more scopes. The scope caps what the token can do in each repository, on top of the role its account has there: | Scope | Highest repository role the token can use | Typical use | | --- | --- | --- | | `read` | read | Clone, fetch, read issues, pull requests, releases and runs. Never changes anything. | | `write` (the default) | maintain | Push, open and merge pull requests, comment, create releases, report statuses and check runs, rerun workflows. | | `admin` | admin | Everything the account can do, including repository settings, secrets, environments, webhooks, branch protection and revoking tokens. No token can create tokens or agents. | The effective role is the lower of the account's role and the token's cap. A `write` token for someone who only has read access to a repository still only reads; an `admin` token for a repository writer still only writes. See [Permissions](https://diffinery.com/docs/accounts/permissions) for what each role allows. A `read` token never writes, even where the `read` role could (opening issues, commenting, reacting): every repository request other than `GET` or `HEAD` gets `403 this token needs the write scope`. `git fetch` and Git LFS downloads, which use `POST` but only read, are the exceptions. MCP tools that aren't read-only also need a `write` token. A write to a private repository the token can't see is `404 repository not found`, not a scope error, so a `read` token can't find out that the repository exists. A token can also be restricted to a list of repositories. Requests for any other repository then behave as if the repository does not exist (`404`). The list may only name repositories the account can see; any other name is refused with `422 unknown repository owner/name`, whether or not it exists. When you create a token (`POST /api/v3/diffinery/tokens` or `POST /api/v3/diffinery/agents/{login}/tokens`) or an agent with a first token (`token.scopes` on `POST /api/v3/diffinery/agents`), `scopes` must be a non-empty list of `read`, `write` and `admin`. Leave it out for `["write"]`. An empty list, a value that isn't a list or an unknown name is refused with `422 scopes must be a non-empty list of: read, write, admin`. Account-level endpoints check the scope directly, and most of them also refuse tokens restricted to repositories with `403 this token is limited to repositories and can't change account settings`. A browser session can call all of them. | Endpoint | Needs | | --- | --- | | `POST /api/v3/user/repos`, `POST /api/v3/orgs/{org}/repos` | A `write` or `admin` token not restricted to repositories. | | `PUT /api/v3/notifications`, `PUT /api/v3/diffinery/notification-settings`, `POST /api/v3/diffinery/emails/test` | A `write` or `admin` token not restricted to repositories (`403 this token needs the write scope` for a `read` token). | | `PUT` and `DELETE /api/v3/repos/{owner}/{repo}/subscription` | A `write` or `admin` token (`403 this needs a write or admin token`). | | `PUT` and `DELETE /api/v3/user/avatar` and `/api/v3/orgs/{org}/avatar` | A `write` or `admin` token that is not limited to repositories (`403 changing a profile picture needs a write or admin token that is not limited to repositories`). | | `POST /api/v3/diffinery/tokens` | A browser session. Every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. | | `DELETE /api/v3/diffinery/tokens/{id}` | An `admin` token not restricted to repositories. | | `POST /api/v3/user/orgs`, `PATCH /api/v3/orgs/{org}` | An `admin` token not restricted to repositories. | | `POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations` | An `admin` token not restricted to repositories. | | `GET /api/v3/user/repository_invitations`, `GET /api/v3/user/memberships/orgs[/{org}]` | A token not restricted to repositories (any scope). | | `PATCH` and `DELETE /api/v3/user/repository_invitations/{id}`, `PATCH /api/v3/user/memberships/orgs/{org}` | A `write` or `admin` token not restricted to repositories. | | `GET`, `PUT` and `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | An `admin` token not restricted to repositories. | | `POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens` | A browser session (they mint tokens). Every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. | | `DELETE /api/v3/diffinery/agents/{login}` | An `admin` token not restricted to repositories. | | `POST /api/v3/diffinery/admin/emails/samples` | A site administrator with an `admin` token not restricted to repositories. | | `POST`, `PATCH` and `DELETE /api/v3/orgs/{org}/actions/variables[/{name}]` | An `admin` token not restricted to repositories (reading them needs only an organization owner or admin). | | `POST /api/v3/diffinery/imports` | A `write` or `admin` token (`403 starting an import needs a token with write or admin scope`). An import that should bypass push protection for old secrets needs a person's `admin` token or browser session. | | `POST /api/v3/diffinery/imports/{id}/cancel`, `/retry` and `/selection` | A `write` or `admin` token not restricted to repositories. | | `POST /api/v3/diffinery/user/linked-accounts/github/challenge`, `POST /api/v3/diffinery/user/linked-accounts/github`, `DELETE /api/v3/diffinery/user/linked-accounts/{id}` | An `admin` token not restricted to repositories. | | `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | A site administrator with an `admin` token not restricted to repositories. | A token whose scope is too low for one of these gets `403 this token needs the write scope` or `403 this token needs the admin scope`. Agent accounts have extra guardrails that apply however they authenticate. Among them: they can't push or merge to the default branch or protected branches, bypass branch rules when merging, change branch protection, add, change or delete secrets or Actions variables, change environments or their branch policies, dismiss reviews or delete repositories, and the commit statuses and check runs they report never satisfy a required check. See [Agents](https://diffinery.com/docs/agents/overview#guardrails). ### Browser sessions and tokens The web UI uses a session cookie that you get by signing in with a passkey. The API endpoints also accept that cookie. A browser session acts with the full role of its account (like an `admin` token). Because the cookie is sent automatically by the browser, Diffinery refuses state-changing requests that rely on it from another site: - The check applies to every method except `GET`, `HEAD` and `OPTIONS`, and only when the request has no `Authorization` credential. - If the browser sends `Sec-Fetch-Site` and its value is anything other than `same-origin` or `none`, the request is refused. - If there is no `Sec-Fetch-Site` header but there is an `Origin` header that differs from the instance's own origin (`https://diffinery.com`), the request is refused. - A refused request gets `403` with `{"message": "cross-site request refused"}`. Requests that carry a token in the `Authorization` header are exempt: they carry no ambient credentials, so this check does not apply to curl, scripts, the CLI, MCP clients or runners. If you see `cross-site request refused` from a script, it means the script sent no token; add the `Authorization` header. Machines should always use tokens, never browser cookies. Passkeys and browser sessions can only be managed in the web UI; tokens can't add or remove passkeys, and they can't create tokens or agents. Web forms (`/settings/...`, repository settings and the other pages' buttons) refuse requests that carry a token with `403 web forms need a browser session; use the API with a token`, so a token's scope and repository limits always apply. ### Workflow job tokens A workflow job's `GITHUB_TOKEN` (`secrets.GITHUB_TOKEN`, or `github.token`) authenticates to the REST API at `github.api_url`, to git and to the container registry while the job runs, and stops working when the job ends. It follows GitHub's permission model, not the scopes of personal access tokens: - **Its permissions** come from the job's `permissions:`, else the workflow's, else the repository default. The default is a repository setting (`default_workflow_permissions` in `GET` and `PUT /repos/{owner}/{repo}/actions/permissions/workflow`, admins only): `read` gives `contents` and `packages` read and nothing else, `write` gives read and write on every scope except `id-token`. New repositories start with `read`; repositories created before the setting existed keep `write`. A reusable workflow never gets more than its caller, and a run for a pull request from another repository only reads. - **Each endpoint needs its permission**: `issues` for issues and labels, `pull-requests` for pull requests and reviews, `contents` for code, commits, branches, releases and merges, `statuses`, `checks`, `deployments` (also environments), `actions` for runs, artifacts and caches, and so on. Without it: `403 Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: issues or pull-requests: write)`. Reading a public repository works whatever the permissions. - **Out of reach:** repository settings, collaborators, webhooks, secrets, variables, runners, branch protection, environment changes and the Actions permissions setting (`403 this endpoint is not available to a workflow's GITHUB_TOKEN`). The token's role never goes above write, so branch protection always applies to it. - **Workflow files:** a job token never creates, changes or deletes files under `.github/workflows/` or `.diffinery/workflows/`, by push, API commit or merge: `refusing to allow a GitHub App to create or update workflow <path> without workflows permission`. - **Pull requests:** it can open pull requests and approve or request changes only when the repository allows it (`can_approve_pull_request_reviews`); otherwise `403 GitHub Actions is not permitted to create pull requests` (or `approve`). Its approvals never count toward required approvals. It can't re-run workflows. - **Other repositories:** it reads public repositories (to clone a public submodule or download a public repository's artifacts) and gets `404` for other private ones. - **Agents:** a run whose code came from an agent (an agent started it, pushed the branch last or opened the pull request, and re-runs of such runs and `workflow_run` runs that follow them) acts as an agent, so the agent guardrails hold for its token. - **No loops:** what a job does with its token starts no new workflow runs, except `workflow_dispatch` and `repository_dispatch`. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions) for the full model and examples. ### Apps A GitHub-compatible app signs in with a JWT made with its private key (`Authorization: Bearer <jwt>`) on the app endpoints (`/app`, `/app/installations/...`, `/repos/{owner}/{repo}/installation`), and trades it for installation access tokens (`dia_...`) that last an hour. An installation token reaches the repositories the app is installed on, checked per endpoint with the same permission table as a workflow's `GITHUB_TOKEN`, as `<slug>[bot]`; requests outside its permissions get `403 Resource not accessible by integration (the app needs the issues permission: write)`. It never changes settings, secrets or workflow files, and gets `403` on endpoints for people such as `/user`. See [Apps](https://diffinery.com/docs/repositories/apps). ## Read-your-writes and the x-d1-bookmark header Account-level data (accounts, organizations, memberships, tokens, the list of repositories and who can access them, invitations, notifications, the audit log, the package index and email settings) can be served from a copy that briefly lags behind the latest write. Every response that read or wrote this data carries a bookmark header that marks how new the data it saw was: ```http x-d1-bookmark: 00000085-0000024c-00004c6d-8e61117bf38d7adb71b934ebbf891683 ``` How a request picks which data it reads: | Caller | Reads data that is | Sees its own earlier writes? | | --- | --- | --- | | Token request with an `x-d1-bookmark` header | At least as new as that bookmark (possibly from a nearby copy) | Yes, every write made up to that bookmark | | Token request without the header | The latest | Yes, always (at the cost of a longer round trip) | | Browser | At least as new as the bookmark in its `__Host-dfy_d1` cookie, which Diffinery sets after a write or once you are signed in | Yes | | Request without credentials or bookmark | From the nearest copy | Not guaranteed | For API clients this means: - **You don't have to do anything to be correct.** Token requests without a bookmark read the latest data, so a `GET` right after your `POST` sees the change. - **To read faster, echo the bookmark.** Take the `x-d1-bookmark` value from your latest response and send it as a request header on the next request. Diffinery may then answer from a copy near you, and it still includes every write up to your bookmark. - **To hand consistency to another client,** pass the bookmark along. A second process that sends it sees everything the first process wrote before that response. - Diffinery ignores a header that doesn't look like a bookmark (hexadecimal groups separated by dashes, at most 128 characters) and falls back to the default for your caller type. ```sh # Create a repository, then list repositories from a copy that already has it. bm=$(curl -sS -D - -o /dev/null -X POST https://diffinery.com/api/v3/user/repos \ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ --data '{"name":"website","private":true}' | tr -d '\r' | sed -n 's/^x-d1-bookmark: //Ip') curl -sS https://diffinery.com/api/v3/user/repos \ -H "Authorization: Bearer dfy_..." -H "x-d1-bookmark: $bm" ``` Repository data (git objects and refs, issues, pull requests, reviews, checks, statuses, releases, deployments, environments, workflow runs, secrets and webhooks) is always strongly consistent. Bookmarks don't affect it: a read always sees every earlier write to the same repository. ## Requests and content types - Send request bodies as JSON. Setting `Content-Type: application/json` is recommended. A body that does not parse as JSON returns `400`, with `{"message": "request body must be JSON"}` or GitHub's `{"message": "Problems parsing JSON"}` depending on the endpoint. - Endpoints that take no body ignore one. A few `PUT` and `POST` endpoints accept an empty body and use defaults (for example `PUT /repos/{owner}/{repo}/pulls/{n}/merge` and `PUT /repos/{owner}/{repo}/issues/{n}/lock`). - File contents in the contents and git data APIs are base64: `PUT /repos/{owner}/{repo}/contents/{path}` takes `content` as base64, `POST /git/blobs` takes `encoding: "utf-8"` (the default) or `"base64"`, and reads return `"encoding": "base64"` with the content split into lines of 60 characters. The Diffinery multi-file commit endpoint takes `encoding: "utf-8"` (the default) or `"base64"` per file. - Path segments that contain a branch, tag, label or file path are taken as written; URL-encode reserved characters (a label named `50%` is `/labels/50%25`). - Release asset uploads send the file itself as the request body (not JSON). See [Release assets](#release-assets). Responses: | Endpoint | Content type | | --- | --- | | Almost everything | `application/json` | | `GET /repos/{owner}/{repo}/contents/{path}`, `/readme` and `/git/blobs/{sha}` with `Accept: application/vnd.github.raw` (or `application/vnd.github.v3.raw`) | The raw bytes, as `text/plain; charset=utf-8` | | `GET /repos/{owner}/{repo}/raw/{ref}/{path}` | `text/plain; charset=utf-8` for text files, `application/octet-stream` for binary files | | `GET /repos/{owner}/{repo}/zipball/{ref}`, `/tarball/{ref}` | `application/zip` or `application/gzip`, as a download (see [Archives](#archives)) | | `GET /repos/{owner}/{repo}/releases/assets/{id}` with `Accept: application/octet-stream` | The asset's bytes, as a download | | `GET /repos/{owner}/{repo}/actions/runs/{id}/logs` | `application/zip`, one text file per job | | `GET /repos/{owner}/{repo}/actions/artifacts/{id}/zip` | A `302` redirect to a signed URL, valid for 10 minutes, that serves the artifact (a zip, or the file itself for an artifact uploaded unzipped) | | `GET /repos/{owner}/{repo}/actions/jobs/{id}/logs` | `text/plain`, one line per log line prefixed with its timestamp. Add `?format=json` for an array of `{t, step, text}` objects. | | `DELETE` endpoints and some `PUT` endpoints | No body (`204`) | Every response also carries security headers (`Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`) and, whenever the request read or wrote account-level data, the `x-d1-bookmark` header described above. Answers to signed-in people and tokens that aren't errors also carry a `Server-Timing` header, which browsers' developer tools show under Timing: the whole request (`total`), the database queries it made (`d1`, with their count and, when a query said so, the database region that answered and whether it was the primary or a nearby copy) and the calls to the repository's storage (`do`, with their count), in milliseconds, and the Cloudflare location that answered (`colo`). Parallel calls overlap, so their times can add up to more than the total. Downloads (raw files, archives and release assets) are sent with `Content-Security-Policy: sandbox` and as attachments, so browsers never run them as pages. ## Errors Every API error is a JSON object with a `message`, a `documentation_url` and an HTTP status code, like GitHub's: ```http HTTP/1.1 404 Not Found content-type: application/json {"message": "repository not found", "documentation_url": "https://docs.github.com/rest"} ``` `documentation_url` points at GitHub's REST documentation, so that clients which expect the field (Octokit's `RequestError`, for example) keep working. Validation errors (`422`) usually add GitHub's `errors` array, which names the resource, the field and a code: ```json { "message": "title is required", "documentation_url": "https://docs.github.com/rest", "errors": [{ "resource": "Issue", "code": "missing_field", "field": "title" }] } ``` Unexpected server errors return `500` with `{"message": "internal error", "request_id": "..."}`; the details stay on the server. Every `5xx` answer carries a `request_id` in the body and the same value in the `x-request-id` header (on Cloudflare it is the request's Ray ID). Include it when you report a failure: a site administrator finds the error under [Server errors](https://diffinery.com/docs/admin/site-administration#server-errors). Write your client to branch on the status code and show `message` to people. Messages are written for humans and may change wording. A path under `/api/` that Diffinery doesn't know, or a method a known path doesn't support, gets GitHub's JSON `404`, never the web UI's HTML error page: ```json { "message": "Not Found", "documentation_url": "https://docs.github.com/rest" } ``` ### Status codes | Status | Meaning in Diffinery | Examples of `message` | | --- | --- | --- | | `200 OK` | Success with a body. Also returned when you add a reaction you had already added. | | | `201 Created` | Something was created: a repository, issue, comment, pull request, review, release, asset, status, check run, deployment, token, webhook, workflow run, sandbox, commit, ref. Also returned by `PUT .../merge-queue` when the pull request joined the queue, and by `PUT .../environments/{env}/secrets/{name}` for a new secret. | | | `202 Accepted` | Work started in the background: `POST .../actions/runs/{id}/cancel`, `PUT .../pulls/{n}/update-branch`. Also returned by `POST /repos/{owner}/{repo}/transfer` and `DELETE /orgs/{org}`, which have finished when they answer. | | | `204 No Content` | Success without a body (deletes, settings changes, `POST .../dispatches`). `POST .../merges` also returns `204` when there was nothing to merge, and `POST .../actions/runners/claim` when no job is waiting. | | | `301 Moved Permanently` | A `GET` or `HEAD` named a repository by an old owner or name, after a [transfer or rename](#transferred-and-renamed-repositories). `Location` (and `url` in the body) is the same request under the new name. | `Moved Permanently` | | `302 Found` | `GET .../actions/artifacts/{id}/zip` redirects to a signed download URL. | | | `307 Temporary Redirect` | Any other method on a transferred or renamed repository's old name. Repeat the request, with its method and body, at `Location`. | `Moved Permanently` | | `205 Reset Content` | `PUT /api/v3/notifications`: every notification marked as read. | | | `400 Bad Request` | The body is not valid JSON, or the URL has a malformed percent-encoding. | `request body must be JSON`, `Problems parsing JSON`, `malformed percent-encoding in the URL` | | `401 Unauthorized` | No credentials where they are needed, or a bad token. | See [Authentication](#authentication). | | `403 Forbidden` | You are signed in but your role, token scope or account kind doesn't allow this. | `write access required`, `admin access required`, `this token needs the write scope`, `this token is limited to repositories and can't change account settings`, `repository is archived (read-only)`, `agents cannot change branch protection`, `agents cannot manage secrets`, `agents cannot manage variables`, `agents can't dismiss reviews`, `only the app that created a check run can update it`, `cross-site request refused`, `Resource not accessible by integration (...)` (a workflow's `GITHUB_TOKEN`), `Confirm it's you: sign in with your passkey again to do this.` (a sensitive call from a browser session signed in more than 2 hours ago; the body has a `confirm` URL) | | `404 Not Found` | The resource doesn't exist, or it is private and you can't see it. Diffinery doesn't reveal whether a private repository exists, and draft releases are hidden from readers the same way. | `repository not found`, `Branch not found`, `Not Found` | | `405 Method Not Allowed` | A pull request merge was refused by policy. | `pull request is not mergeable: ...` (followed by the blockers), `main requires a merge queue: add the pull request to the queue`, `main requires linear history; use squash or rebase` | | `409 Conflict` | Someone else changed the branch, ref or file first, or a merge conflicts. Fetch the latest state and retry. | `branch moved; refresh and retry`, `head branch was modified; review and try again`, `README.md does not match <sha>`, `Merge conflict`, `Git Repository is empty.`, `Already exists - Variable already exists` | | `410 Gone` | The artifact has expired; it is still listed, but its content is gone. | `Artifact has expired` | | `413 Payload Too Large` | An upload or a generated archive is over a limit. | `release assets are limited to 100 MB`, `repository is too large for an on-the-fly archive; clone it with git instead` | | `422 Unprocessable Entity` | The request is understood but invalid. | `title is required`, `invalid secret name`, `webhook URL must use https`, `state must be one of error, failure, pending, success`, `The search is longer than 256 characters.`, `push protection: possible secret detected (...)`, `scopes must be a non-empty list of: read, write, admin` | | `429 Too Many Requests` | A per-repository budget ran out (only new source archives have one). | `too many archives were built for this repository in the last hour; try again later or clone it with git` | | `500 Internal Server Error` | A bug or an outage. The body and the `x-request-id` header carry a `request_id` a site administrator can look up. | `internal error` | | `503 Service Unavailable` | A sandbox container didn't start. | `the sandbox container did not start; try again` | Every write to an archived repository returns `403 repository is archived (read-only)`, whatever role it needs: pushes, issues, comments, reviews, labels, branch protection, collaborators, secrets, variables, webhooks and MCP tools alike. Only a few calls still work: unarchiving it with `PATCH /repos/{owner}/{repo}` and `{"archived": false}` (admins), deleting it, watching or unwatching it, and linking a package to it. See [Archive a repository](https://diffinery.com/docs/repositories/overview#archive-a-repository). ## Pagination Lists take GitHub's `page` and `per_page` query parameters and answer with a `Link` header that points at the other pages: ```http link: <https://diffinery.com/api/v3/repos/acme/website/issues?state=all&per_page=50&page=2>; rel="next", <https://diffinery.com/api/v3/repos/acme/website/issues?state=all&per_page=50&page=7>; rel="last" ``` - `page` starts at 1. `per_page` is 30 by default and at most 100; larger values are treated as 100. - The header has `rel="next"` while there are more pages, `rel="prev"` and `rel="first"` after the first page, and `rel="last"` when the total is known. On the last page there is no `next`. - Follow `rel="next"` until it is missing. That is what `octokit.paginate()` and `gh api --paginate` do. - Paging over a list that changes while you read it can skip or repeat items, as on GitHub. Lists with other defaults or without the header: | Endpoint | Default `per_page` | Notes | | --- | --- | --- | | `GET /repos/{owner}/{repo}/issues` and `/pulls` | 30 | Also sends `x-total-count` with the number of matching items. | | `GET /repos/{owner}/{repo}/actions/runs` | 25 | `total_count` in the body. | | `GET /repos/{owner}/{repo}/actions/workflows/{id}/runs` | 30 | `total_count` in the body. No `Link` header: page with `page` until you have `total_count` runs. | | `GET .../actions/artifacts`, `.../actions/runs/{id}/artifacts`, `.../actions/caches` | 30 | `total_count` in the body, and a `Link` header. | | `GET /repos/{owner}/{repo}/compare/{base}...{head}` (its `commits`), `GET .../pulls/{n}/commits` | 100 | At most 250 commits in total, as on GitHub. | | `GET .../git/refs`, `.../git/matching-refs/{ref}` | 100 | | | `GET .../commits/{ref}/status` (its `statuses`) | 100 | | | `GET .../environments/{env}/variables` | 10 | As on GitHub. | | `GET /search/issues`, `/search/repositories` | 30 | Only the first 1,000 results can be paged to. | | `GET /user/packages/container/{name}/versions` (and the same path under `/users/{owner}` and `/orgs/{owner}`) | 30 | No `Link` header; loop until a page has fewer items than `per_page`. | | `GET /api/v3/diffinery/emails` | 50 | At most 500, no `page` parameter. | A few lists return everything in one response, with no paging: `GET /user/orgs`, `/orgs/{org}/members`, `/repos/{owner}/{repo}/actions/workflows`, `/actions/secrets`, `/orgs/{org}/actions/secrets`, `/api/v3/diffinery/tokens`, `/api/v3/diffinery/agents`, and `GET /api/v3/notifications`, which returns up to 100 of your most recent unread notifications (add `all=true` to include read ones). The repository lists (`GET /user/repos`, `/users/{login}/repos` and `/orgs/{org}/repos`) page over at most 500 repositories, most recently pushed first. `GET /user/repos` lists every repository you can see, including public ones you don't own. See [Limits](https://diffinery.com/docs/reference/limits) for every cap. Filters on the issue and pull request lists: | Parameter | Meaning | | --- | --- | | `state` | `open` (default), `closed` or `all` | | `labels` | Comma-separated label names; an item must have all of them | | `creator` | The author's login | | `assignee` | A login, `none` (no assignee) or `*` (any assignee) | | `since` | Only items updated at or after this ISO 8601 time | | `head`, `base` | Pull requests from or into this branch (`head` also accepts `owner:branch`) | | `sort`, `direction` | `created` (default), `updated` or `comments` (`popularity` on pulls); `desc` (default) or `asc` | | `q` | Diffinery: text in the title or body | Like GitHub, `/issues` includes pull requests; each pull request item has a `pull_request` field. Other filters: workflow runs take `workflow`, `branch`, `status`, `event` and `head_sha`; commits take `sha` (where to start, default `HEAD`), `path`, `author`, `since` and `until`; deployments take `sha`, `ref`, `task` and `environment`; check runs for a ref take `check_name` and `status`; repository-wide comment lists take `since`, `sort` and `direction`. ## Rate limits Diffinery limits requests per minute to stop floods and scripted abuse: | What | Limit | Counted by | | --- | --- | --- | | Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`), security reports (`POST /security/report`), early access requests, confirmations and removals (`POST /early-access`, `/early-access/confirm`, `/early-access/remove`, `/early-access/remove/confirm`) | 20 a minute | IP address | | Anything else without signing in (web pages, API, git) | 300 a minute | IP address | | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account | | Creating repositories, organizations, forks, issues, pull requests, releases, comments, reviews and reports, and MCP tools that change something | 60 a minute | Account | | Agents' model requests through the model proxy (`/-/model/*`) | 600 a minute | The person who pays for the run | Workflow jobs, sandboxes, self-hosted runners and imports use their own credentials and aren't counted. The counts are kept in each Cloudflare location, so they stop bursts rather than meter use exactly. Past a limit, the request answers `429` with a message naming the limit, `retry-after: 60` and `x-ratelimit-remaining: 0`; Octokit's throttling plugin waits and retries. Other responses under `/api/v3` still carry GitHub's budget headers with a constant, generous value, so clients that check their budget keep working: ```http x-ratelimit-limit: 15000 x-ratelimit-remaining: 15000 x-ratelimit-used: 0 x-ratelimit-reset: 1791887400 x-ratelimit-resource: core ``` `GET /api/v3/rate_limit` answers like GitHub's, with `resources.core`, `search`, `graphql`, `code_search` and others, and the same `rate`. Cloudflare's own platform limits still apply to each request (for example the maximum request body size of 100 MB). A few operations have budgets of their own, such as building new source archives (20 per repository per hour). Write clients that back off and retry on `429`, `503` and network errors. Size and count limits, such as 100 MB per git push or 256 jobs per matrix, are listed in [Limits](https://diffinery.com/docs/reference/limits). ## What works, by area Each area lists what you can do; the [API reference](https://diffinery.com/docs/api) has every endpoint with its fields. Unless noted, reading needs read access to the repository and changing needs write access. ### Repositories - Get, create (`POST /user/repos`, `POST /orgs/{org}/repos`), update (`PATCH`, admin) and delete (`DELETE`, admin, people only). `visibility` is `public` or `private`; `private: false` makes a repository public. - Topics: `GET` and `PUT /repos/{owner}/{repo}/topics` (admin). At most 20 topics, each starting with a lowercase letter or digit, up to 50 characters of lowercase letters, digits and hyphens. - `GET /languages`: bytes per language, by file extension, leaving out vendored directories such as `node_modules`, `vendor` and `dist`. - Collaborators: list, add or change (`PUT` with `permission`), remove (admin). As on GitHub, `PUT` invites a person who doesn't have access yet and answers `201` with the invitation (`id`, `repository`, `invitee`, `inviter`, `permissions`, `created_at`, `expires_at`, `html_url`); they get the role once they accept it. For an agent, an existing direct collaborator, a member of the organization that owns the repository or yourself, the role applies at once and `PUT` answers `204`. See [Add people to a repository](https://diffinery.com/docs/accounts/invitations#add-people-to-a-repository). - Pending invitations: `GET /repos/{owner}/{repo}/invitations` lists them and `DELETE /repos/{owner}/{repo}/invitations/{id}` revokes one (admin). Each has `id`, `email` (`null` for an invitation sent to a username), `login` (`null` for one sent to an email address), `role`, `inviter`, `created_at` and `expires_at`. - Branches (with `protected=true|false`), one branch, tags, README (`GET /readme` and `/readme/{dir}`), commits, one commit with its files and stats, compare (`base...head`), and `GET /commits/{sha}/pulls`. - `POST /merges` merges a branch or commit into a branch (`201` with the merge commit, `204` when there was nothing to merge, `409 Merge conflict`). - `POST /dispatches` sends a `repository_dispatch` event: `event_type` (up to 100 characters) and an optional `client_payload` object (at most 10 top-level properties, 64 KiB). It returns `204` and starts workflows with `on: repository_dispatch`. - `POST /transfer` moves the repository to `new_owner` (your own account, or an organization you own or administer), optionally under `new_name`, and returns `202` with the repository. People only, with an `admin` token that isn't limited to repositories. See [Transfer a repository](https://diffinery.com/docs/repositories/overview#transfer-a-repository). - Archived repositories are read-only through the API, for everyone, except unarchiving, deleting, transferring and watching. See [Errors](#errors). `PATCH` and the collaborator endpoints are recorded in the audit log, like changes in the web settings pages. ### Your invitations and organization memberships As on GitHub, a person invited by username accepts or declines the invitation: - `GET /user/repository_invitations` lists your pending repository invitations, those sent to your username and those sent to your email address. `PATCH /user/repository_invitations/{id}` accepts one and `DELETE` declines it (both `204`). - `GET /user/memberships/orgs` lists your organization memberships, pending invitations first (`?state=active` or `?state=pending` for one kind), and `GET /user/memberships/orgs/{org}` returns one. Each has `state` (`active` or `pending`), `role` (`admin` or `member`; owners count as `admin`, as on GitHub), `diffinery_role` (`owner`, `admin` or `member`), `organization` and `user`. - `PATCH /user/memberships/orgs/{org}` with `{"state": "active"}` accepts a pending invitation to the organization and returns the membership. An unknown id, or someone else's invitation, answers `404 no pending invitation with this id`. See [Invitations you receive](https://diffinery.com/docs/accounts/invitations#accept-or-decline-with-the-api). ### Contents - `GET /contents/{path}` returns a file (base64, with `sha`), a directory listing, or a symlink (with `target`). `ref` picks the branch, tag or commit (default: the default branch). `Accept: application/vnd.github.raw` returns the bytes. - `PUT /contents/{path}` creates or updates one file with a commit: `message`, base64 `content`, optional `branch`, `author` and `committer`. Updating an existing file needs its current blob `sha`, as on GitHub: without it you get `422 "sha" wasn't supplied`, and a stale one gets `409 <path> does not match <sha>`. It returns `201` for a new file and `200` for an update. - `DELETE /contents/{path}` removes a file with a commit; `sha` is required. - Every write refuses unsafe tree entry names: `.`, `..`, `.git` in any case and spellings that some systems treat as `.git` (such as `.git.` or `git~1`), and names with `\` or NUL. Push protection scans new file content as it does for `git push`, here and in `POST /repos/{owner}/{repo}/diffinery/commits`: content that looks like a credential is refused with `422 push protection: possible secret detected (...)`. Only the multi-file commit endpoint takes a bypass: a repository admin who is a person sends `"allow_secrets": "<reason>"`, which is audited as `api.secret_bypassed` and emailed to the repository's admins. ### Git data - Blobs (`POST /git/blobs`, `GET /git/blobs/{sha}`), trees (`POST /git/trees` with `base_tree`, and entries by `sha` or inline `content`; `GET /git/trees/{sha}` with `recursive=1`, cut at 100,000 entries with `truncated: true`), commits (`POST /git/commits` with `author`, `committer` and `signature`; `GET`), annotated tags (`POST /git/tags`, `GET`). - Refs: `GET /git/ref/{ref}`, `GET /git/refs`, `GET /git/matching-refs/{prefix}`, `POST /git/refs`, `PATCH /git/refs/{ref}` (with `force`), `DELETE /git/refs/{ref}`. Ref updates follow branch protection and the agent guardrails, like a push. - One tree request can change at most 10,000 entries. ### Archives `GET /repos/{owner}/{repo}/zipball/{ref}` and `/tarball/{ref}` return a source archive of a branch, tag or commit (the default branch without `{ref}`), with a top directory named `<owner>-<repo>-<short sha>/`, like GitHub's. Browsers can use `https://diffinery.com/<owner>/<repo>/archive/<ref>.zip` or `.tar.gz` (also `refs/heads/<branch>.zip`). Releases and tags link to these as `zipball_url` and `tarball_url`. - Archives are built on demand the first time a commit is asked for, then cached in R2. The cache can be evicted at any time; the archive is then built again on the next download. - An archive holds at most 24 MB of file content and 100,000 tree entries. Bigger repositories fail with `413 repository is too large for an on-the-fly archive; clone it with git instead`. - A zip holds at most 65,535 files (`413 this repository has too many files for a zip archive (more than 65,535); download the .tar.gz archive or clone it with git`). Use the tarball for more. - Each repository can build 20 new archives per hour. Downloads of cached archives don't count. Past that you get `429`. - Entries with unsafe names (see [Contents](#contents)) are left out, and submodules appear as empty directories. Clone bigger repositories with git. ### Releases - List (newest first), get by id, get by tag (`/releases/tags/{tag}`), latest (`/releases/latest`), create, update and delete. Drafts are visible to writers only: readers get `404` for them and don't see them in lists. - Publishing a release whose `tag_name` doesn't exist creates the tag at `target_commitish` (default: the default branch); a draft creates its tag when it is published. Ref rules apply to that tag like any other ref update. Only one published release can exist per tag. - `make_latest` (`true`, `false` or `legacy`), `prerelease`, `draft`, `generate_release_notes`. The body can be up to 125,000 characters. - `POST /releases/generate-notes` drafts notes from the pull requests merged since the previous release and honours `.github/release.yml` (or `configuration_file_path`). It creates nothing. - Releases send the `release` webhook event for every change, and start `on: release` workflows (filter with `types:`, such as `published`, `created`, `edited` or `deleted`). Drafts start no workflows until they are published. - Reactions work on releases too. See [Releases](https://diffinery.com/docs/collaboration/releases) for the web UI and the full model. ### Release assets Upload an asset to the release's `upload_url`, which looks like `https://diffinery.com/api/uploads/repos/acme/website/releases/42/assets{?name,label}`. Send the file as the raw request body, with its content type, and the file name in the `name` query parameter: ```sh curl -X POST "https://diffinery.com/api/uploads/repos/acme/website/releases/42/assets?name=website-1.4.0.tar.gz&label=Source" \ -H "Authorization: Bearer dfy_..." \ -H "Content-Type: application/gzip" \ --data-binary @website-1.4.0.tar.gz ``` - An asset can be up to 100 MB, the largest request body Cloudflare Workers accept (`413 release assets are limited to 100 MB`). The bytes are stored in R2. - Names are cleaned like GitHub does: characters other than letters, digits and `._+@~-` become `.` (so `tool linux.bin` is stored as `tool.linux.bin`), and a name must be 1 to 255 characters. Each name can appear once per release. - `GET /releases/{id}/assets` lists assets; `GET`, `PATCH` (`name`, `label`) and `DELETE /releases/assets/{id}` manage one. - Download an asset from its `browser_download_url` (`https://diffinery.com/<owner>/<repo>/releases/download/<tag>/<name>`), or from `GET /releases/assets/{id}` with `Accept: application/octet-stream`. Both count toward `download_count`. Assets of private repositories need a token. ### Commit statuses, checks and required checks - **Commit statuses:** `POST /statuses/{sha}` (`state` of `error`, `failure`, `pending` or `success`, `context`, `description`, `target_url`), `GET /commits/{ref}/statuses` (also `/statuses/{ref}`) and the combined status `GET /commits/{ref}/status`. A status counts as a check, so a branch protection's required checks can name a status `context`. `context` defaults to `default`. Each commit can have up to 1,000 statuses per context. - **Check runs:** create (`POST /check-runs`), update (`PATCH /check-runs/{id}`), get, list for a ref (`GET /commits/{ref}/check-runs`) and annotations (`GET /check-runs/{id}/annotations`). - **Check suites:** `GET /commits/{ref}/check-suites`, `GET /check-suites/{id}` and its check runs. Each workflow run is a suite, and checks from other reporters are grouped into one suite per reporter. - **Who decides a required check:** 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" }` (or `diffinery-actions`). A required check fails if any counted report of it fails. Only the account that created a check run can update it, and Actions check runs can't be changed over REST (`403 check runs from Diffinery Actions can't be changed over the API`). Statuses and check runs reported by agents never satisfy a required check. See [Checks and commit statuses](https://diffinery.com/docs/collaboration/checks-and-statuses) and [Branch protection](https://diffinery.com/docs/repositories/branch-protection). ### Issues and pull requests - Issues: list, create (`labels` and `assignees` from someone without triage access are dropped without an error, as on GitHub), get, update (including `state_reason`; `labels` and `assignees` from someone without triage access are dropped here too), labels (create, get, update, delete; add, set, remove on an issue), assignees (add, remove, `GET /assignees`, `GET /assignees/{login}`), lock and unlock (`PUT` and `DELETE /issues/{n}/lock`, triage), comments (per issue and repository-wide `GET /issues/comments`; get, edit and delete one), events (`GET /issues/{n}/events`, `/issues/events`, `/issues/events/{id}`) and the timeline (`GET /issues/{n}/timeline`). - Pull requests: list (with the filters in [Pagination](#pagination)), create, get, update, files, commits, merge (`PUT /pulls/{n}/merge` with `merge_method`, `commit_title`, `commit_message`, `sha`), auto-merge (`PUT` and `DELETE /pulls/{n}/auto-merge`), reviews (list, get, submit with line comments), review comments (list per pull request and repository-wide, get, edit, delete, create, and replies with `POST /pulls/{n}/comments/{id}/replies` or `in_reply_to`), requested reviewers (list, add, remove), review dismissal and `update-branch`. - `GET /pulls/{n}` reports `mergeable`, `mergeable_state` (`clean`, `blocked`, `dirty`, `unknown` and so on) and `rebaseable`, plus Diffinery's `merge_blockers`. - **Auto-merge:** GitHub turns auto-merge on and off only through GraphQL; Diffinery has `PUT /pulls/{n}/auto-merge` (optional `merge_method`, `commit_title`, `commit_message`) and `DELETE /pulls/{n}/auto-merge`. `PUT` answers `{"state": "enabled" | "queued" | "merged", "auto_merge": {...}, "merge_commit_sha": ...}`, and pull request objects carry GitHub's `auto_merge` object (`enabled_by`, `merge_method`, `commit_title`, `commit_message`) while it is on. Agents and workflow tokens can use it on the default branch or a protected branch only where the rule sets `allowAgentAutoMerge`. See [Auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge). - **Drafts:** `PATCH /pulls/{n}` with `"draft": false` marks a draft ready for review, and `"draft": true` converts it back (a Diffinery addition; GitHub only offers this through GraphQL). The pull request's author or anyone with write access can do it, only while it is open (`422 the pull request is not open`; others get `403 only the author or someone with write access can change draft status`). It records a `ready_for_review` or `convert_to_draft` event on the timeline (GitHub's names) and sends a `pull_request` webhook with the action `ready_for_review` or `converted_to_draft`. The web UI's **Ready for review** button and `diffinery pr ready` do the same. - **Merging past branch rules:** a merge that branch protection blocks fails with `405 pull request is not mergeable: ...`. A repository admin who is a person can merge anyway by sending `"bypass_rules": true` with `PUT /pulls/{n}/merge`, unless the rule turns on **Do not allow administrators to bypass these rules** (`enforceAdmins`), and only when the pull request has no conflicts and isn't a draft. Without the flag, admins are blocked like everyone else, and the error ends with `(as an admin you can bypass with bypass_rules)`. Each flag covers only what it names: `"bypass_rules": true` skips unmet rules (and linear history), and `"bypass_merge_queue": true` merges past a required merge queue but not past unmet rules. A merge that bypassed rules, linear history or the queue is recorded in the audit log as `pull_request.merge_bypassed`. `diffinery pr merge --admin` sends both flags. Agents and workflow tokens can't bypass. - **Linear history:** when the base branch's rule requires linear history, `merge_method: "merge"` is refused with `405 <branch> requires linear history; use squash or rebase` (unless an admin sends `bypass_rules`), and a merge without `merge_method` uses `squash`. - **Workflow tokens and reviews:** a workflow's `GITHUB_TOKEN` can open pull requests and approve or request changes only when the repository allows it (see [Workflow job tokens](#workflow-job-tokens)), and only people's approvals count toward required approvals. - Changing an issue's or pull request's title or body sends an `issues` or `pull_request` webhook with the action `edited`. - `PUT /pulls/{n}/update-branch` merges the base branch into the head branch (optional `expected_head_sha`) and returns `202`. - `PUT /pulls/{n}/reviews/{id}/dismissals` with a `message` dismisses an approving or change-requesting review. It takes maintain access; the pull request's author can't dismiss reviews on it, and agents can't dismiss reviews at all. - **Locked conversations** refuse new comments, review comments and reactions from people without triage access. - **Reactions** (`+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket`, `eyes`) on issues and pull requests, issue comments, review comments and releases: list (filter with `content`), add (`201`, or `200` if you had already reacted) and delete by id. - Comment, review, review comment, event and reaction IDs are numbers. - Team reviewers (`team_reviewers`) are refused with `422 team reviewers are not supported; request people by login`. - **Milestones:** `GET /milestones` (`state` `open`, `closed` or `all`), `GET /milestones/{n}`, `POST /milestones` and `PATCH /milestones/{n}` (triage). There is no `DELETE`. Set an issue's or pull request's milestone with `PATCH /issues/{n}` and `"milestone": <number>` or `null` (triage); issues and pull requests carry a `milestone` object. ### Moderation As on GitHub, a person or an organization's owners block people, people report abuse, and site administrators suspend accounts. See [Blocking and reporting](https://diffinery.com/docs/accounts/blocking-and-reporting) and [Site administration](https://diffinery.com/docs/admin/site-administration#reports). - **Blocks:** `GET /user/blocks`, and `GET`, `PUT`, `DELETE /user/blocks/{username}`; `GET /orgs/{org}/blocks`, and `GET`, `PUT`, `DELETE /orgs/{org}/blocks/{username}` (organization owners). `GET` on one answers `204` if blocked and `404` if not. In the blocker's repositories, a blocked person (or their agent) gets `403 you can't open issues or pull requests, comment, review or react in <owner>'s repositories: <owner> blocked you` for new issues, pull requests, comments, reviews and reactions, and adding them as a collaborator fails with `422`. - **Reports:** `POST /diffinery/reports` (people, `write` scope; at most 20 a day). Site administrators: `GET /admin/reports` (`state` `open`, `resolved` or `all`; `page`, `per_page`; the total is in `x-total-count`), `GET /admin/reports/{id}` and `PATCH /admin/reports/{id}` with `state` (`resolved` with a `resolution` note, or `open`). - **Suspensions:** `PUT /users/{username}/suspended` with `{"reason": "..."}` and `DELETE /users/{username}/suspended` (site administrators, `admin` scope; GitHub Enterprise's endpoints). While suspended, the person's tokens and their agents' get `401`, and their profile, repositories, issues and pull requests answer `404` to everyone but site administrators; their comments and reviews are left out of lists. Site administrators see `suspended_at` and `suspension_reason` on `GET /users/{username}`. - **Hidden comments:** `PUT /repos/{owner}/{repo}/diffinery/comments/{id}/minimized` with `reason` (`spam`, `abuse`, `off_topic`, `outdated`, `duplicate` or `resolved`) and `DELETE` on the same path (write access, people only), GitHub's GraphQL `minimizeComment` and `unminimizeComment` as REST. See [Hide a comment](https://diffinery.com/docs/collaboration/issues#hide-a-comment). ### Deployments and environments - **Deployments:** create, list, get and delete; deployment statuses: create, list and get. A deployment without `required_contexts` needs every check and status on the commit to have passed, as on GitHub (those reported by agents are not counted); send `"required_contexts": []` to skip the check. A deployment whose contexts haven't passed is refused with `409`. - **Environments:** list, get, create or update (`PUT /environments/{name}`), delete; deployment branch policies (`protected_branches` or `custom_branch_policies` with branch and tag patterns); environment secrets (names only, never values) and variables. Changing environments, their branch policies and secrets takes admin access, and agents can't do it. Required reviewers (people) and wait timers hold jobs before they start; pending deployments (`GET` and `POST /actions/runs/{run_id}/pending_deployments`) and `GET /actions/runs/{run_id}/approvals` work as on GitHub. - An environment with no branch policy is limited to protected branches when its first secret is added. Environment secrets never go to `refs/pull/*` runs. Runs started by agents get them only when the environment allows protected branches only. Tags pushed by agents never match an environment's tag patterns. - A workflow 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. See [Environments](https://diffinery.com/docs/actions/environments). ### Actions - Workflows (list, get by path or file name, `POST /actions/workflows/{id}/dispatches`), runs (list, per workflow, get, attempts `GET /actions/runs/{id}/attempts/{n}`, cancel, `rerun` and `rerun-failed-jobs`), jobs (per run, get, `POST /actions/jobs/{id}/rerun`), logs (a zip per run, or text per job). - **Artifacts:** `GET /actions/artifacts` (every artifact, newest first, `name` filter), `GET /actions/runs/{id}/artifacts` (one run; a run number covers all its attempts), `GET` and `DELETE /actions/artifacts/{id}`, and `GET /actions/artifacts/{id}/zip`, which redirects (`302`) to a signed URL valid for 10 minutes. Artifacts have numeric IDs, `digest`, `expires_at` and `expired`. An artifact past its retention (1 to 90 days, from `retention-days`) loses its content but stays listed with `expired: true` for 90 more days; downloading it gets `410 Artifact has expired`. Deleting needs write access and is audited as `actions.artifact.delete`. - **Caches:** `GET /actions/caches` (filter with `key` prefix and `ref`, sort by `last_accessed_at`, `created_at` or `size_in_bytes`), `DELETE /actions/caches?key=<key>[&ref=<ref>]` (returns the deleted entries), `DELETE /actions/caches/{id}`, and `GET /actions/cache/usage` (`active_caches_size_in_bytes`, `active_caches_count`). Each entry has its `ref` (like `refs/heads/main`), `key` and `version`. Deleting needs write access and is audited as `actions.cache.delete`. See [Caching and artifacts](https://diffinery.com/docs/actions/caching-and-artifacts) and [Limits](https://diffinery.com/docs/reference/limits#caches-and-artifacts). - **Workflow permissions:** `GET` and `PUT /actions/permissions/workflow` read and change `default_workflow_permissions` (`read` or `write`) and `can_approve_pull_request_reviews`. Admins only, people only; each change is audited as `repo.actions_workflow_settings`. - Repository secrets (list names, set with `value`, delete; admin, people only), organization secrets (organization owners and admins, people only), repository variables (`GET`, `POST`, `PATCH`, `PUT`, `DELETE`; reading needs write access, changing needs admin), organization variables (`GET`, `POST`, `PATCH`, `DELETE /orgs/{org}/actions/variables`; organization owners and admins, people only), and environment secrets and variables. - Organization variables reach every repository of the organization as `vars.NAME`; a repository variable with the same name wins. `GET /repos/{owner}/{repo}/actions/variables` lists only the repository's own. Unlike GitHub, `POST` on an existing organization variable replaces its value instead of answering `409`, `PATCH` can't rename, and `visibility` is always `all`. Changes are audited as `org.variable_set` and `org.variable_delete`. - Changing repository secrets and variables, environment variables and organization secrets is recorded in the audit log (`repo.secret.set`, `repo.variable.set`, `environment.variable.set`, `org.secret.set` and the matching `.delete` actions). Renaming a variable onto a name that exists fails with `409 Already exists - Variable already exists`. - Secrets are sent as plain `value` over HTTPS and encrypted at rest. GitHub's sealed-box `encrypted_value` is not supported yet: `422 encrypted_value (sealed box) is not supported yet: send the secret as value over HTTPS; it is encrypted at rest`. ### Webhooks List, create (`config.url` must use `https`, `events` defaults to all) and delete repository webhooks (admin; creating and deleting are audited as `repo.webhook.create` and `repo.webhook.delete`). Webhooks get the `release`, `status`, `deployment`, `deployment_status` and `workflow_dispatch` events as well as pushes, pull requests, issues and the rest, including the `edited`, `ready_for_review` and `converted_to_draft` actions. A delivery that fails with a network error or timeout, `408`, `429` or a `5xx` status is retried up to five times (after 1 minute, 5 minutes, 30 minutes, 1 hour and 3 hours) with the same `X-GitHub-Delivery` ID. See [Webhooks](https://diffinery.com/docs/repositories/webhooks) and [Webhook events](https://diffinery.com/docs/reference/webhook-events). ### Workflows started by API calls - A release starts `on: release` workflows (with `types:` matching the action, such as `published`). Drafts start nothing until they are published. - `POST /repos/{owner}/{repo}/dispatches` starts `on: repository_dispatch` workflows (with `types:` matching `event_type`), with `client_payload` in `github.event.client_payload`. - `POST /actions/workflows/{id}/dispatches` starts a `workflow_dispatch` run. - As on GitHub, events caused by a job's `GITHUB_TOKEN` don't start new workflow runs, except `workflow_dispatch` and `repository_dispatch`. See [Events that trigger workflows](https://diffinery.com/docs/actions/events). ## Search `GET /search/issues` searches issues and pull requests, and `GET /search/repositories` searches repositories, with GitHub's query syntax in `q`: ```sh curl -sS -G https://diffinery.com/api/v3/search/issues \ -H "Authorization: Bearer dfy_..." \ --data-urlencode 'q=repo:acme/website is:pr is:open label:bug "contact sales"' \ --data-urlencode 'sort=updated' ``` The response is `{"total_count": ..., "incomplete_results": false, "items": [...]}`; items are issue or repository objects with `score: 1`. Search only covers repositories you can read. Anonymous callers search public repositories, and a workflow job's token searches its own repository. ### Issue and pull request search | Qualifier | Meaning | | --- | --- | | `repo:owner/name` | Only this repository (repeat it for several) | | `user:`, `org:`, `owner:` | Only repositories of this account | | `is:issue`, `is:pr` (also `type:`) | Issues or pull requests | | `is:open`, `is:closed` (also `state:`) | By state | | `is:merged`, `is:unmerged` | Merged or unmerged pull requests | | `is:draft`, `draft:true`, `draft:false` | Draft pull requests | | `is:locked`, `is:unlocked` | Locked conversations | | `label:bug` | Has this label. `label:a,b` and repeated `label:` qualifiers require every label. | | `-label:wontfix` | Doesn't have this label | | `no:label`, `no:assignee` | Has no labels, or no assignee | | `author:diffy`, `-author:diffy` | Opened, or not opened, by this login | | `assignee:diffy` | Assigned to this login | | `head:branch`, `base:branch` | Pull requests from or into this branch | | `in:title`, `in:body`, `in:comments` | Where words must match (default: title and body); combine as `in:title,body` | | `created:`, `updated:`, `closed:` | Dates: `2026-10-01`, `>2026-10-01`, `>=`, `<`, `<=`, `2026-09-01..2026-09-30`, `2026-09-01..*` | | `sort:created-desc` | `created`, `updated` or `comments`, with `-asc` or `-desc`. The `sort` and `order` query parameters override it. | Words without a qualifier must all appear (case-insensitive), `"quoted phrases"` match as written, and `-word` excludes items that contain the word. Unknown qualifiers are searched as text. Without a sort, results are newest first. ### Repository search | Qualifier | Meaning | | --- | --- | | `user:`, `org:`, `owner:` | Repositories of this account | | `repo:owner/name` | This repository | | `topic:docs` | Has this topic (repeat for several) | | `is:public`, `is:private` | By visibility | | `archived:true`, `archived:false` | Archived or not | | `in:name`, `in:description`, `in:topics` | Where words must match (default: all three) | Words match the name, `owner/name`, description and topics. `sort=updated` sorts by the last push; otherwise the most recently pushed repositories come first. The qualifiers `fork:`, `language:`, `stars:`, `forks:`, `size:`, `pushed:` and `created:` are accepted but ignored: Diffinery has no forks, stars or language index. ### Search limits | Limit | Value | | --- | --- | | Query length | 256 characters (`422 The search is longer than 256 characters.`). An empty `q` returns `422 Validation Failed`. | | Results you can page to | The first 1,000 (`422 Only the first 1000 search results are available`) | | Repositories one issue search looks at | 50 readable repositories (10 without signing in), taken from the up to 500 most recently pushed repositories you can see. When more matched, `incomplete_results` is `true`; narrow the search with `repo:` or `org:`. | | Repositories one repository search checks | 300 candidates, from the same up to 500. More sets `incomplete_results`. | | Issues scanned per repository | The 5,000 most recent issues and pull requests | | Comments read for `in:comments` | The 2,000 most recent comments per repository | Code search and commit search are not supported yet. ## Not supported yet - GraphQL (`/api/graphql`) beyond repositories, pull requests and CI. See [GraphQL API](https://diffinery.com/docs/reference/graphql-api) for what it answers. - Setting secrets with a sealed-box `encrypted_value`; send `value` instead. - Team reviewers and custom deployment protection rules on environments. - Forks, and team reviewers. - Apps: signing people in with an app's OAuth (user-to-server tokens), the app manifest flow, and GraphQL with installation tokens. - Code search and commit search. Search qualifiers for stars, forks and languages are accepted but ignored. An Octokit method for an endpoint Diffinery doesn't have fails, usually with `404`. Check the [API reference](https://diffinery.com/docs/api) before you reuse a GitHub script. ## Diffinery-only endpoints Endpoints that have no GitHub equivalent live under `/api/v3/diffinery` (or `/repos/{owner}/{repo}/diffinery/...` when they belong to a repository): | Endpoint | What it does | | --- | --- | | `GET`, `POST /diffinery/tokens`, `DELETE /diffinery/tokens/{id}` | List, create and revoke your tokens. `POST` takes `name`, `scopes` (a non-empty list of `read`, `write`, `admin`; default `["write"]`), `repositories` (`["acme/website"]`, only repositories you can see) and `expires_in_days`, and returns the token once. Creating needs a browser session; revoking needs an `admin` token not restricted to repositories. | | `GET`, `POST /diffinery/agents` | List the agents you or your organizations own, and create an agent (it comes with a first `write` token valid for 90 days unless you pass `token`). Creating needs a browser session. | | `POST /diffinery/agents/{login}/tokens` | Create another token for an agent you own. Needs a browser session. | | `DELETE /diffinery/agents/{login}` | Disable an agent and revoke its tokens. Needs an `admin` token not restricted to repositories. | | `POST /repos/{owner}/{repo}/diffinery/commits` | Commit several files at once without a git client: `branch`, `message`, optional `base` (creates the branch from it) and `expected_head_sha` (fails with `409` if the branch moved), and `files` (each with `path`, `content`, `encoding`, `mode` or `delete`). Push protection applies; a repository admin who is a person can pass `allow_secrets` with a reason. Needs write access. | | `POST /repos/{owner}/{repo}/diffinery/sandboxes` | Start a sandbox container with the repository checked out. See [Sandboxes](https://diffinery.com/docs/agents/sandboxes). | | `POST /repos/{owner}/{repo}/diffinery/sandboxes/{id}/exec`, `/read`, `/write`, `/close` | Run a command, read or write a file, or stop the sandbox. | | `GET`, `PUT /diffinery/notification-settings` | Your notification settings. `PUT` is a partial update: send only the groups you change. | | `GET /diffinery/emails` | Emails Diffinery sent you recently (`per_page`, `kind`, `since`). Site administrators can add `all=1`. | | `POST /diffinery/emails/test` | Send yourself a test email now. | | `GET /diffinery/admin/email`, `POST /diffinery/admin/emails/samples` | Site administrators: email delivery status, and send every email template to yourself. | | `GET`, `POST /diffinery/imports`, `GET /diffinery/imports/{id}`, `POST /diffinery/imports/{id}/cancel`, `/retry`, `/selection` | Import repositories from GitHub, follow and manage the imports. See [Imports](#imports). | | `GET /diffinery/user/linked-accounts`, `POST /diffinery/user/linked-accounts/github/challenge`, `POST /diffinery/user/linked-accounts/github`, `DELETE /diffinery/user/linked-accounts/{id}` | Link your GitHub account (gist proof, no token), so imports attribute your GitHub work to you. | | `DELETE /diffinery/admin/linked-accounts/{id}` | Site administrators: clear anyone's GitHub link. | | `POST /diffinery/reports` | Report a person, repository, issue, pull request or comment to the site administrators. See [Moderation](#moderation). | | `PUT`, `DELETE /repos/{owner}/{repo}/diffinery/comments/{id}/minimized` | Hide (minimize) a comment with a reason, or show it again. | | `GET /diffinery/status` | The [status page](https://diffinery.com/docs/self-hosting/status-page) as JSON, shaped like Statuspage's summary: `status`, `components`, unresolved `incidents` and `updated_at`. No authentication needed. | | `GET`, `POST /diffinery/admin/status/incidents`, `POST /diffinery/admin/status/incidents/{id}/updates` | Site administrators: list and open status incidents, and post updates that resolve them. See [Site administration](https://diffinery.com/docs/admin/site-administration#status). | Other Diffinery additions sit next to the GitHub-shaped endpoints: - `POST /api/v3/user/orgs` creates an organization; `POST /api/v3/invitations` (site administrators) and `POST /api/v3/orgs/{org}/invitations` invite people by email. `GET /api/v3/orgs/{org}` and `PATCH /api/v3/orgs/{org}` (owners) follow GitHub: `default_repository_permission` is the organization's base permission (`none`, `read`, `write` or `admin`), shown to members and site administrators, and `PATCH` also takes the display `name`. See [Base permission](https://diffinery.com/docs/accounts/organizations#base-permission). - `PUT /api/v3/user/avatar` sets your profile picture from the raw image in the request body (JPEG, PNG or WebP, up to 5 MB and 3072×3072 pixels), and `DELETE` goes back to generated initials. `PUT` and `DELETE /api/v3/orgs/{org}/avatar` do the same for an organization (owners only). Both return `{"avatar_url": ...}`. `GET /api/avatars/{login}?s=64|128|256` serves the picture to anyone, as WebP, or initials as SVG; it is the `avatar_url` of every user object. - `PUT`, `DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue` and `GET /repos/{owner}/{repo}/merge-queue/{branch}` drive the merge queue. - `PUT /repos/{owner}/{repo}/pulls/{n}/auto-merge` turns on auto-merge (GitHub's `enablePullRequestAutoMerge`, which it offers through GraphQL only) with `merge_method`, `commit_title`, `commit_message`, `delete_branch` and `sha` (`409` if the head moved); `DELETE` turns it off (`diffinery pr merge --auto` and `--disable-auto`). It returns `{"state": "enabled" | "queued" | "merged", "auto_merge": ..., "merge_commit_sha": ...}`. - `POST /repos/{owner}/{repo}/actions/runners/claim` is how self-hosted runners pick up jobs, with the runner token they get from `POST .../actions/runners/register` and a registration token (`POST .../actions/runners/registration-token`). `GET .../actions/runners` and `DELETE .../actions/runners/{id}` list and remove them (see [Runners](https://diffinery.com/docs/actions/runners#manage-runners)). - `GET /repos/{owner}/{repo}/raw/{ref}/{path}` returns a file's raw bytes. - `GET /repos/{owner}/{repo}/packages` lists container images linked to a repository, and `PATCH` on a package changes its visibility or linked repository. ```sh curl -X PUT https://diffinery.com/api/v3/user/avatar \ -H "Authorization: Bearer dfy_..." -H "Content-Type: image/png" --data-binary @me.png ``` The [API reference](https://diffinery.com/docs/api) lists every endpoint with its request and response fields. ## Imports Diffinery imports repositories from GitHub.com or GitHub Enterprise Server on the server: Git history with LFS objects, issues and pull requests with their original numbers, comments, reviews, labels, milestones, branch protection, Actions variables, deployment environments and releases. You start an import with one request and poll it; nothing goes through your machine. The web form (**Import from GitHub**), `diffinery migrate github` and the MCP tool `start_github_import` use the same service. For the whole procedure, what is and isn't copied, and how people are attributed, read [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). ```sh # Import one repository into the acme organization curl -sS -X POST https://diffinery.com/api/v3/diffinery/imports \ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ -d '{"source": "diffy/hello-world", "owner": "acme", "name": "website", "github_token": "ghp_..."}' # Follow it until status is succeeded, failed or cancelled curl -sS https://diffinery.com/api/v3/diffinery/imports/<id> -H "Authorization: Bearer dfy_..." ``` | Endpoint | What it does | | --- | --- | | `POST /diffinery/imports` | Start an import: `source` (one repository) or `source_owner` (a GitHub organization or user, with optional `repositories`, `exclude_forks`, `exclude_archived` and `concurrency`), `owner`, and optional `name`, `visibility` (`keep`, `private` or `public`), `github_token`, `github_api_url` and `options`. Returns `201` with the import. | | `GET /diffinery/imports`, `GET /diffinery/imports/{id}` | Your imports, and one import with its `status`, per-step progress (`steps`), the Git transfer (`git`), `summary` (counts, secret names to set by hand, push protection findings), `warnings`, `error` and, for an organization import, `children`. | | `POST /diffinery/imports/{id}/cancel` | Stop an import that hasn't finished. | | `POST /diffinery/imports/{id}/retry` | Run a failed or cancelled import again (`202`), optionally with a new `github_token`. Only whoever started it. | | `POST /diffinery/imports/{id}/selection` | Start an organization import that was drafted in the web form, with the chosen `repositories`. | Things to know when you script imports: - **Token:** starting one needs a `write` or `admin` token (or a browser session). Old history with credentials in it is imported with an audited push protection bypass only when a person who administers the target started the import with an `admin` token or the web UI; otherwise the import fails and names the findings. See [Push protection](https://diffinery.com/docs/security/push-protection). - **Agents** can import only into an organization that owns them or that their owner owns or administers. Their imports never bypass push protection and skip Actions variables and environments. - **Secret values are never read or copied.** Only secret names are listed in `summary`, for a repository admin to set by hand. - **Limits:** one organization import and three repository imports at a time per account and per target (`429`), and more. See [Limits](https://diffinery.com/docs/reference/limits#imports). - **The GitHub token** is encrypted, used only by that import and deleted when it ends. It is never returned. Every endpoint, with its fields and errors, is in the [API reference](https://diffinery.com/docs/api). ## Using the API The examples below list open pull requests in `acme/website` and open a new issue. Replace `dfy_...` with your token. ### curl ```sh export DIFFINERY_TOKEN=dfy_... # Who am I? curl -sS https://diffinery.com/api/v3/user -H "Authorization: Bearer $DIFFINERY_TOKEN" # Open pull requests curl -sS "https://diffinery.com/api/v3/repos/acme/website/pulls?state=open" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" # Open an issue curl -sS -X POST https://diffinery.com/api/v3/repos/acme/website/issues \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "content-type: application/json" \ --data '{"title": "Broken link on the pricing page", "body": "The FAQ link returns 404.", "labels": ["bug"]}' # Report an external CI result on a commit curl -sS -X POST https://diffinery.com/api/v3/repos/acme/website/statuses/9be41d07 \ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "content-type: application/json" \ --data '{"state": "success", "context": "ci/external", "target_url": "https://ci.example.com/builds/812"}' ``` Add `-f` (`--fail`) in scripts so curl exits non-zero on `4xx` and `5xx`, or `--fail-with-body` to still print Diffinery's `message`. To follow the `Link` header in a shell script: ```sh url="https://diffinery.com/api/v3/repos/acme/website/issues?state=all&per_page=100" while [ -n "$url" ]; do curl -fsS -D headers.txt "$url" -H "Authorization: Bearer $DIFFINERY_TOKEN" | jq -r '.[] | "#\(.number) \(.title)"' url=$(tr -d '\r' < headers.txt | sed -n 's/^link: .*<\([^>]*\)>; rel="next".*/\1/Ip') done ``` ### fetch From Node.js 22 or later, Deno, Bun or a Cloudflare Worker: ```js const base = "https://diffinery.com/api/v3"; const headers = { authorization: `Bearer ${process.env.DIFFINERY_TOKEN}`, "content-type": "application/json" }; async function api(method, path, body) { const res = await fetch(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined }); const data = res.status === 204 ? null : await res.json().catch(() => null); if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${data?.message ?? res.statusText}`); return data; } const pulls = await api("GET", "/repos/acme/website/pulls?state=open"); for (const pr of pulls) console.log(`#${pr.number} ${pr.title} (${pr.head.ref})`); const issue = await api("POST", "/repos/acme/website/issues", { title: "Broken link on the pricing page" }); console.log(issue.html_url); ``` Browsers on other websites can't call the API: Diffinery sends no CORS headers. ### Octokit Point Octokit at Diffinery with `baseUrl`. Octokit sends the token as `Authorization: token dfy_...`, which Diffinery accepts, and its pagination, errors and release uploads work as they do against GitHub: ```js import { readFile } from "node:fs/promises"; import { Octokit } from "@octokit/rest"; const octokit = new Octokit({ baseUrl: "https://diffinery.com/api/v3", auth: process.env.DIFFINERY_TOKEN }); const R = { owner: "acme", repo: "website" }; const { data: repo } = await octokit.rest.repos.get(R); console.log(repo.default_branch, repo.html_url); // Every open issue and pull request, following the Link header. const all = await octokit.paginate(octokit.rest.issues.listForRepo, { ...R, state: "open", per_page: 100 }); console.log(`${all.length} open issues and pull requests`); await octokit.rest.issues.createComment({ ...R, issue_number: 42, body: "Looks good to me." }); await octokit.rest.pulls.merge({ ...R, pull_number: 42, merge_method: "squash" }); // A release with an asset. const { data: release } = await octokit.rest.repos.createRelease({ ...R, tag_name: "v1.4.0", generate_release_notes: true }); await octokit.rest.repos.uploadReleaseAsset({ ...R, release_id: release.id, url: release.upload_url, name: "website-1.4.0.tar.gz", data: await readFile("website-1.4.0.tar.gz"), headers: { "content-type": "application/gzip" }, }); // Errors carry status, message and documentation_url. try { await octokit.rest.repos.getContent({ ...R, path: "missing.txt" }); } catch (e) { console.log(e.status, e.response.data.message); // 404 Not Found } ``` Things to know when you reuse GitHub code: - Use [Personal access tokens](https://diffinery.com/docs/accounts/tokens) or agent tokens from Diffinery, not GitHub tokens or GitHub App credentials. - Methods for features Diffinery doesn't have (see [Not supported yet](#not-supported-yet)) fail, usually with `404` or `422`. - `octokit.paginate()` doesn't work on `listWorkflowRunsForWorkflow` or on package versions, which send no `Link` header; page those with `page`. - Workflow run and job IDs are strings. TypeScript types say `number`; pass the value you got back unchanged. ### The Diffinery CLI The `diffinery` CLI wraps the API with commands for common tasks (`diffinery pr list`, `diffinery issue create`, `diffinery run watch`) and has a raw `api` command for anything else: ```sh curl -fsSL https://diffinery.com/install | bash diffinery login # in a browser; scripts use --token dfy_... or DIFFINERY_TOKEN ``` `diffinery api METHOD PATH` sends an authenticated request and prints the JSON response. `PATH` is relative to `/api/v3`. `-f key=value` adds a string field to the JSON body (or to the query string for `GET`), and `--input FILE` (or `-` for stdin) sends a JSON body from a file. It exits with status 1 on any `4xx` or `5xx` response. ```sh diffinery api GET /repos/acme/website/pulls -f state=open diffinery api POST /repos/acme/website/issues -f title="Broken link on the pricing page" echo '{"event":"APPROVE","body":"Ship it"}' | diffinery api POST /repos/acme/website/pulls/42/reviews --input - ``` The CLI reads `DIFFINERY_URL` and `DIFFINERY_TOKEN` from the environment before its config file, which is handy in CI. See the [CLI reference](https://diffinery.com/docs/reference/cli) for every command. ## Versioning There is one version of the API, `v3`, named after the GitHub REST API version it follows. It is part of the path (`/api/v3`). Diffinery does not read GitHub's `X-GitHub-Api-Version` request header, so sending it is harmless; responses report `X-GitHub-Api-Version-Selected: 2022-11-28`. New endpoints and new response fields are added within `v3`; clients should ignore fields they don't know. The [API reference](https://diffinery.com/docs/api) and [/docs/api/openapi.json](https://diffinery.com/docs/api/openapi.json) are generated from the running code, so they always describe the version of Diffinery that serves them. ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `401 unrecognised token; create one under Settings → Access tokens` | The client sent a GitHub token or another credential. Use a `dfy_` token. | | `404 repository not found` for a repository you expect | The token is restricted to other repositories, your role doesn't reach it, or (for an agent) its owner can't see it. | | `404` from an Octokit method that works on GitHub | The endpoint isn't implemented (the body is `{"message": "Not Found", ...}`). Check the [API reference](https://diffinery.com/docs/api). | | `401 authentication required` when opening an issue or commenting on a public repository | Anonymous callers can only read. Send a token. | | `403 this token needs the write scope` | The token has only the `read` scope. Use a `write` token. | | `403 this token needs the admin scope` or `403 this token is limited to repositories and can't change account settings` | Account-level endpoints (organizations, invitations, agents, organization secrets, revoking tokens) need an `admin` token without a repository list. See [Token scopes](#token-scopes). | | `405 pull request is not mergeable: ... (as an admin you can bypass with bypass_rules)` | Meet the requirements, or, as an admin, send `"bypass_rules": true`. `bypass_merge_queue` alone doesn't skip rules. The bypass is audited. | | `403 Resource not accessible by integration (...)` from a workflow | The job's `permissions:` don't grant what the endpoint needs. Add the permission the message names, for example `issues: write`. See [Workflow job tokens](#workflow-job-tokens). | | `403 GitHub Actions is not permitted to create pull requests` (or `approve`) | An admin allows it with `PUT /repos/{owner}/{repo}/actions/permissions/workflow` and `{"can_approve_pull_request_reviews": true}`. | | `refusing to allow a GitHub App to create or update workflow ...` | A workflow's token can't change workflow files. Push that change with a personal or agent token. | | `410 Artifact has expired` | The artifact passed its retention. Upload it again with a longer `retention-days` (at most 90). | | `403 repository is archived (read-only)` | Unarchive the repository first (admin): `PATCH /repos/{owner}/{repo}` with `{"archived": false}`. | | `301` or `307` with `{"message": "Moved Permanently", "url": ...}` | The repository was transferred or renamed. Follow `Location` (`curl -L`, Octokit follows it), and update the owner and name your client uses. See [Transferred and renamed repositories](#transferred-and-renamed-repositories). | | `409 <path> does not match <sha>` from the contents API | The file changed since you read it. Read it again and send the new `sha`. | | `422 push protection: possible secret detected (...)` | The content looks like a credential. Remove it; see [Push protection](https://diffinery.com/docs/security/push-protection). | | `413` or `429` from `zipball` or `tarball` | The repository is too big for an archive, or too many new archives were built this hour. Clone it with git. | | `413 release assets are limited to 100 MB` | Split the file, or host it elsewhere and link to it from the release notes. | | `search` returns `incomplete_results: true` | More than 50 repositories matched. Add `repo:` or `org:` to the query. | | A list stops after one page | The client doesn't follow `Link`. Use `octokit.paginate()` or loop on `rel="next"`. | | `429 too many repository imports are already running (at most 3 at a time); wait for one to finish` | You, or the account you import into, already run three repository imports (or one organization import: `an organization import is already in progress`). Wait, or cancel one with `POST /diffinery/imports/{id}/cancel`. | | `422 github_api_url is required when a github_token is given and the source names a GitHub Enterprise host` | Send `github_api_url` (such as `https://ghe.example.com/api/v3`) on the same host as the source. Diffinery never sends a token to a host taken only from the source URL. | --- # GraphQL API > The GitHub-shaped GraphQL endpoint at /api/graphql that the GitHub CLI and Claude Code use for pull requests and CI: what it answers, limits and errors. Source: https://diffinery.com/docs/reference/graphql-api Diffinery answers GraphQL at `https://diffinery.com/api/graphql` with the part of GitHub's GraphQL schema that covers repositories, pull requests, reviews and CI. Type, field, argument and enum names are GitHub's, so tools written for GitHub's GraphQL API work unchanged for those queries. The main users are the GitHub CLI (`gh pr create`, `gh pr view`, `gh pr checks`, `gh pr status`, `gh pr merge` and the rest, see [GitHub CLI and Claude Code](https://diffinery.com/docs/agents/github-cli)) and Claude Code, which shows a session's pull request and its CI from it. Everything else (issues, releases, Actions, settings and so on) is in the [REST API](https://diffinery.com/docs/reference/rest-api). ## Requests Send a `POST` with a JSON body and a token, as on GitHub: ```sh curl https://diffinery.com/api/graphql \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "query { viewer { login } }"}' ``` | Property | Value | | --- | --- | | URL | `https://diffinery.com/api/graphql` | | Method | `POST` with `{"query": "...", "variables": {...}, "operationName": "..."}`. A `GET` answers `405`. | | Authentication | Required. A personal access token or agent token (`Authorization: Bearer dfy_...`, or `token dfy_...`), or a browser session. Without one the answer is `401`. A workflow job's token can't use it yet: in workflows, use the REST API. | | Token scopes | `read` is enough for queries. Mutations need `write`, like the REST calls that make the same change. | | Permissions | The same as over REST: a repository you can't see answers as if it didn't exist, and each mutation needs the role its REST equivalent needs. Branch protection, required checks, required reviews and the agent rules apply to merges here too. | | Introspection | `__schema` and `__type` work, so you can read the whole schema with any GraphQL client. | | Subscriptions | Not supported. | ## What it answers **Queries** | Field | What you get | | --- | --- | | `viewer` | The signed-in account. | | `repository(owner, name)` | A repository: its id, name, owner, visibility, default branch, merge methods, your permission, `pullRequest(number)`, `pullRequests(states, headRefName, baseRefName, labels, orderBy)`, `labels`, `milestones`, `assignableUsers` and `mentionableUsers`. A renamed repository is found by its old name. | | `user(login)` | A person or agent by login. | | `node(id)`, `nodes(ids)` | A repository or pull request by its global id (the `node_id` of the REST API). | | `search(query, type: ISSUE)` | Pull requests in one repository. The query needs a `repo:owner/name` qualifier and understands `is:pr`, `is:open`, `is:closed`, `is:merged`, `state:`, `author:`, `assignee:`, `review-requested:`, `head:`, `base:`, `label:` and `draft:`, with `@me` for yourself. This is what `gh pr status` sends. | | `rateLimit` | A budget in GitHub's shape. Diffinery limits requests per account (see [Limits](#limits)), not by query cost. | A `PullRequest` has its number, title, body, state, draft flag, author, branches (`baseRef` and `headRef`, with the branch's protection rule) and head commit, additions, deletions and changed files, labels, assignees, milestone, review requests, reviews and `latestReviews`, `reviewDecision`, comments, files, commits, `mergeable` and `mergeStateStatus`, auto-merge (`autoMergeRequest`) and `statusCheckRollup`. **CI.** A commit's `statusCheckRollup` combines its check runs and commit statuses: `state` is `SUCCESS`, `FAILURE`, `ERROR`, `PENDING` or `EXPECTED`, and `contexts` lists each `CheckRun` and `StatusContext`, with `checkRunCountsByState` and `statusContextCountsByState` for the counts. A check run from Diffinery Actions has its workflow under `checkSuite.workflowRun.workflow.name`, and `isRequired(pullRequestNumber:)` says whether the base branch requires it. **Mutations** | Mutation | What it does | `gh` command | | --- | --- | --- | | `createPullRequest` | Opens a pull request, as a draft with `draft: true`. | `gh pr create` | | `updatePullRequest` | Changes the title, body, state, labels, assignees or milestone. The base branch can't change. | `gh pr edit` | | `addLabelsToLabelable`, `removeLabelsFromLabelable` | Adds or removes labels. | `gh pr edit --add-label`, `--remove-label` | | `addAssigneesToAssignable`, `removeAssigneesFromAssignable` | Adds or removes assignees. | `gh pr edit --add-assignee`, `--remove-assignee` | | `requestReviews` | Asks people for a review. Team reviewers aren't supported. | `gh pr create --reviewer`, `gh pr edit --add-reviewer` | | `markPullRequestReadyForReview`, `convertPullRequestToDraft` | Moves a pull request out of draft or back into it. | `gh pr ready`, `gh pr ready --undo` | | `addComment` | Comments on a pull request. | `gh pr comment` | | `closePullRequest`, `reopenPullRequest` | Closes or reopens a pull request. | `gh pr close`, `gh pr reopen` | | `mergePullRequest` | Merges with `MERGE`, `SQUASH` or `REBASE`, if nothing blocks it. | `gh pr merge` | | `enablePullRequestAutoMerge`, `disablePullRequestAutoMerge` | Turns [auto-merge](https://diffinery.com/docs/collaboration/pull-requests) on or off. | `gh pr merge --auto`, `--disable-auto` | ## Fields Diffinery doesn't have GitHub's schema is much larger than this one. To keep tools that ask for a few extra fields working, a field, argument or type that Diffinery doesn't know is left out of the answer instead of failing the whole query, and the answer says which: ```json { "data": { "repository": { "pullRequest": { "number": 1 } } }, "extensions": { "unsupported": ["Cannot query field \"notAThing\" on type \"PullRequest\"."] } } ``` Lists Diffinery has no data for answer empty: `projectItems` and `reactionGroups`. Team review requests aren't supported. `isCrossRepository` is always `false`, since pull requests come from branches of the same repository. ## Errors Errors use GitHub's shape: a `message`, the `path` of the field, and a `type` such as `NOT_FOUND`, `FORBIDDEN`, `UNPROCESSABLE` or `RATE_LIMITED`, both at the top of the error and under `extensions`. The GitHub CLI reads `type`. A field that fails is `null` while the rest of the answer still comes back. ```json { "data": { "repository": null }, "errors": [{ "type": "NOT_FOUND", "path": ["repository"], "message": "Could not resolve to a Repository with the name 'octo/nope'." }] } ``` ## Limits | What | Limit | | --- | --- | | Query text | 64 KB, 20,000 tokens | | Nesting | 16 levels | | Fields in one query, fragments counted where they're spread | 2,000 | | Page size (`first`, `last`) | 100 | | Repositories in one query | 25 | Each request counts against your account's request limit like a REST call, and `createPullRequest` and `addComment` also count against the limit on creating things (60 a minute), as `POST /pulls` and comments do over REST. See [Rate limits](https://diffinery.com/docs/reference/rest-api#rate-limits). --- # REST API reference > Every REST API endpoint with its parameters, access rules and an example, generated from the server's router. Source: https://diffinery.com/docs/api Every endpoint of the Diffinery REST API, generated from the server's router, so this list always matches what `https://diffinery.com/api/v3` serves. The same information is available as an [OpenAPI 3.1 document](https://diffinery.com/docs/api/openapi.json). For authentication, errors, pagination and GitHub compatibility, read the [REST API guide](https://diffinery.com/docs/reference/rest-api). ## Basics - Base URL: `https://diffinery.com/api/v3` - Authenticate with a personal access token: `Authorization: Bearer dfy_...` (`token dfy_...` and HTTP Basic with the token as the password work too). Create one under *Settings → Access tokens*. - Send and receive JSON. Errors are JSON objects with a `message` and a `documentation_url`, unknown paths included (404). - Reads of public repositories need no token. Every write needs a signed-in caller (401 otherwise) and, from a token, the `write` scope (403 otherwise). A token's scope also caps its role on a repository: `read` reads, `write` acts up to maintain, `admin` acts as admin. Each endpoint below says which scope it needs. - A private repository you can't see is a 404, whatever the method, so a read token's write to it doesn't reveal that it exists. - An archived repository refuses every write with 403 `repository is archived (read-only)`, except unarchiving it (`PATCH` with `archived: false`), deleting it and watching it. - A workflow job's `GITHUB_TOKEN` follows the job's `permissions:` instead of token scopes: each endpoint needs its permission (`contents`, `issues`, `pull-requests`, `actions` and so on), and settings, secrets, variables, webhooks and branch protection are out of its reach. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions). - Paths, field names and status codes follow GitHub's REST API wherever Diffinery has the same feature, so most GitHub clients work with `baseUrl` set to the URL above. - 452 endpoints in 29 groups. ## Endpoints by group - [Meta](#meta) (3) - [Users](#users) (6) - [Organizations](#organizations) (12) - [Search](#search) (2) - [Repositories](#repositories) (19) - [Contents](#contents) (12) - [Releases](#releases) (15) - [Git database](#git-database) (15) - [Branches and tags](#branches-and-tags) (4) - [Commit statuses](#commit-statuses) (5) - [Checks](#checks) (8) - [Commits](#commits) (5) - [Branch protection](#branch-protection) (3) - [Issues](#issues) (38) - [Pull requests](#pull-requests) (29) - [Merge queue](#merge-queue) (3) - [Deployments](#deployments) (6) - [Environments](#environments) (16) - [Actions secrets and variables](#actions-secrets-and-variables) (22) - [Actions artifacts and caches](#actions-artifacts-and-caches) (9) - [Actions](#actions) (26) - [Webhooks](#webhooks) (3) - [Packages](#packages) (22) - [Imports](#imports) (15) - [Status page](#status-page) (4) - [Notifications and email](#notifications-and-email) (27) - [Tokens and agents](#tokens-and-agents) (8) - [Sandboxes](#sandboxes) (2) - [Other](#other) (113) ## Meta | Method | Path | Summary | | --- | --- | --- | | GET | `/` | [API root](#api-root) | | GET | `/meta` | [Instance metadata](#instance-metadata) | | GET | `/rate_limit` | [Get rate limit status](#get-rate-limit-status) | ### API root ```http GET /api/v3 ``` Names the API and links to these docs and the MCP endpoint. **Access:** No authentication needed. ```sh curl "https://diffinery.com/api/v3" ``` ### Instance metadata ```http GET /api/v3/meta ``` Whether this instance issues OIDC tokens to workflows, and the runner image its last deploy set (`runner_image`; each job reports the image it ran on as `runner.image`), and whether it tells search engines about changed public pages through IndexNow (`indexnow`: `enabled`, `key_location` and the time, URL count, IndexNow status and `endpoint` of the `last_submission`; see [Site administration](https://diffinery.com/docs/admin/site-administration#search-engines)). **Access:** No authentication needed. ```sh curl "https://diffinery.com/api/v3/meta" ``` ### Get rate limit status ```http GET /api/v3/rate_limit ``` For GitHub clients that check their budget before calling. It always reports a full, constant budget (15000 core requests an hour). Diffinery's real limits are per minute (2,000 requests per account, 60 new repositories, issues, pull requests, comments and the like, 300 requests per IP address without signing in) and answer 429 with `retry-after: 60`; see /docs/reference/rest-api#rate-limits. **Access:** No authentication needed. ```sh curl "https://diffinery.com/api/v3/rate_limit" ``` ## Users | Method | Path | Summary | | --- | --- | --- | | GET | `/user` | [Get the authenticated account](#get-the-authenticated-account) | | PUT | `/user/avatar` | [Set your profile picture](#set-your-profile-picture) | | DELETE | `/user/avatar` | [Remove your profile picture](#remove-your-profile-picture) | | GET | `/user/orgs` | [List your organizations](#list-your-organizations) | | POST | `/user/orgs` | [Create an organization](#create-an-organization) | | GET | `/users/{login}` | [Get an account](#get-an-account) | ### Get the authenticated account ```http GET /api/v3/user ``` Returns your account, including `email` and `site_admin`. Agents get their own account (type `Bot`). **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/user" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set your profile picture ```http PUT /api/v3/user/avatar ``` Diffinery only. The raw request body is the image (JPEG, PNG or WebP, at most 5 MB), not JSON. Returns the new `avatar_url`. A token needs `write` or `admin` scope and must not be limited to repositories. Audited. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. ```sh curl -X PUT "https://diffinery.com/api/v3/user/avatar" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove your profile picture ```http DELETE /api/v3/user/avatar ``` Diffinery only. Goes back to generated initials and returns the `avatar_url`. Same token rules as setting one. Audited. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. ```sh curl -X DELETE "https://diffinery.com/api/v3/user/avatar" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List your organizations ```http GET /api/v3/user/orgs ``` Each organization carries your `role` in it (member, admin or owner). **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/user/orgs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create an organization ```http POST /api/v3/user/orgs ``` **Access:** Any active account (a token, or a browser session). Tokens need the `admin` scope and access to all repositories. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Organization login (letters, digits, `-` and `_`; no dots). | | `name` | string | no | Display name (default: the login). | ```sh curl -X POST "https://diffinery.com/api/v3/user/orgs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"login":"..."}' ``` ### Get an account ```http GET /api/v3/users/{login} ``` A user, organization or agent by login. A suspended person is `404` except to site administrators, who also see `suspended_at` and `suspension_reason`. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | ```sh curl "https://diffinery.com/api/v3/users/diffy" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Organizations | Method | Path | Summary | | --- | --- | --- | | GET | `/invitations` | [List pending invitations to this instance](#list-pending-invitations-to-this-instance) | | POST | `/invitations` | [Invite someone to this instance](#invite-someone-to-this-instance) | | DELETE | `/invitations/{id}` | [Revoke an invitation to this instance](#revoke-an-invitation-to-this-instance) | | GET | `/orgs/{org}` | [Get an organization](#get-an-organization) | | PATCH | `/orgs/{org}` | [Update an organization](#update-an-organization) | | DELETE | `/orgs/{org}` | [Delete an organization](#delete-an-organization) | | PUT | `/orgs/{org}/avatar` | [Set an organization's picture](#set-an-organizations-picture) | | DELETE | `/orgs/{org}/avatar` | [Remove an organization's picture](#remove-an-organizations-picture) | | GET | `/orgs/{org}/invitations` | [List an organization's pending invitations](#list-an-organizations-pending-invitations) | | POST | `/orgs/{org}/invitations` | [Invite someone to an organization](#invite-someone-to-an-organization) | | DELETE | `/orgs/{org}/invitations/{id}` | [Revoke an organization invitation](#revoke-an-organization-invitation) | | GET | `/orgs/{org}/members` | [List organization members](#list-organization-members) | ### List pending invitations to this instance ```http GET /api/v3/invitations ``` Site-wide invites only, not those to an organization or repository. Site admins only. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. ```sh curl "https://diffinery.com/api/v3/invitations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Invite someone to this instance ```http POST /api/v3/invitations ``` A site-wide invite with no organization. Site admins only. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | Email address to invite. | ```sh curl -X POST "https://diffinery.com/api/v3/invitations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"..."}' ``` ### Revoke an invitation to this instance ```http DELETE /api/v3/invitations/{id} ``` Site admins only. 404 when no pending site-wide invitation has this id. Audited. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/invitations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an organization ```http GET /api/v3/orgs/{org} ``` GitHub's organization object. Members, the organization's agents, agents of members, and site admins also get `default_repository_permission`, the organization's base permission (`none`, `read`, `write` or `admin`). **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update an organization ```http PATCH /api/v3/orgs/{org} ``` Organization owners (and site admins) only; admins and members get 403. Returns the updated organization. Every field is checked before anything is saved, so a bad value (422) changes nothing. Changing the base permission applies to every member right away; owners and admins always keep admin. Audited. **Access:** An owner of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `default_repository_permission` | string | no | Base permission every member gets on the organization's repositories: `none`, `read` (the default for new organizations), `write` or `admin`. | | `name` | string | no | Display name. An empty value clears it, and pages show the login instead. | ```sh curl -X PATCH "https://diffinery.com/api/v3/orgs/acme" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete an organization ```http DELETE /api/v3/orgs/{org} ``` Owners only (or site admins). Refused with 422 while the organization still owns repositories (transfer or delete them first), container images, or an import in progress. Its members, pending invitations, secrets and variables are removed, its agents disabled and their tokens revoked, and its login freed. Repositories transferred out of it keep redirecting from their old URLs. Audited. From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL (see /docs/security/overview#confirming-its-you). **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set an organization's picture ```http PUT /api/v3/orgs/{org}/avatar ``` Diffinery only. Organization owners (and site admins) only. The raw request body is the image (JPEG, PNG or WebP, at most 5 MB), not JSON. Returns the new `avatar_url`. A token needs `write` or `admin` scope and must not be limited to repositories. Audited. **Access:** An owner of the organization (or a site admin). Tokens need the `write` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl -X PUT "https://diffinery.com/api/v3/orgs/acme/avatar" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove an organization's picture ```http DELETE /api/v3/orgs/{org}/avatar ``` Diffinery only. Organization owners (and site admins) only. Goes back to generated initials and returns the `avatar_url`. Audited. **Access:** An owner of the organization (or a site admin). Tokens need the `write` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/avatar" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List an organization's pending invitations ```http GET /api/v3/orgs/{org}/invitations ``` Owners and admins (or site admins). Each has an `id` to revoke it with. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/invitations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Invite someone to an organization ```http POST /api/v3/orgs/{org}/invitations ``` Emails an invite link. The response holds the link too, so you can share it yourself. Refused with 422 when the address already has a pending invitation to the organization (letter case doesn't matter) or belongs to a member. You can send at most 50 invitations in 24 hours (site admins aren't limited); more answer 429 `you can send at most 50 invitations in 24 hours; try again later`. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | Email address to invite. | | `role` | string | no | `member` (default), `admin` or `owner`. | ```sh curl -X POST "https://diffinery.com/api/v3/orgs/acme/invitations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"..."}' ``` ### Revoke an organization invitation ```http DELETE /api/v3/orgs/{org}/invitations/{id} ``` The link stops working and the address can be invited again. 404 when no pending invitation has this id. Audited. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/invitations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List organization members ```http GET /api/v3/orgs/{org}/members ``` Members only (or site admins). Each entry carries the member's `role`. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/members" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Search | Method | Path | Summary | | --- | --- | --- | | GET | `/search/issues` | [Search issues and pull requests](#search-issues-and-pull-requests) | | GET | `/search/repositories` | [Search repositories](#search-repositories) | ### Search issues and pull requests ```http GET /api/v3/search/issues ``` Searches the repositories you can read (only public ones without a token), at most 50 per query; `incomplete_results` is true when more were left out. Qualifiers: `repo:`, `user:`, `org:`, `owner:`, `is:`, `type:`, `state:`, `draft:`, `label:`, `no:`, `author:`, `assignee:`, `head:`, `base:`, `in:`, `created:`, `updated:`, `closed:` and `sort:`; others are searched as text. Only the first 1000 results are available, and every `score` is 1. **Access:** No authentication needed. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string | yes | Search query, at most 256 characters. | | `sort` | string | no | `created` (default), `updated` or `comments`. | | `order` | string | no | `desc` (default) or `asc`. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/search/issues" ``` ### Search repositories ```http GET /api/v3/search/repositories ``` Matches names, descriptions and topics of the repositories you can see (only public ones without a token). Qualifiers: `user:`, `org:`, `owner:`, `repo:`, `topic:`, `is:public`, `is:private`, `archived:` and `in:name,description,topics`. `fork:`, `language:`, `stars:`, `forks:`, `size:`, `pushed:` and `created:` are accepted but don't filter anything. Every `score` is 1. **Access:** No authentication needed. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `q` | string | yes | Search query, at most 256 characters. | | `sort` | string | no | `updated` sorts by last push; anything else keeps the default order. | | `order` | string | no | With `sort=updated`: `desc` (default) or `asc`. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/search/repositories" ``` ## Repositories | Method | Path | Summary | | --- | --- | --- | | GET | `/orgs/{org}/repos` | [List an organization's repositories](#list-an-organizations-repositories) | | POST | `/orgs/{org}/repos` | [Create an organization repository](#create-an-organization-repository) | | GET | `/repos/{owner}/{repo}` | [Get a repository](#get-a-repository) | | PATCH | `/repos/{owner}/{repo}` | [Update a repository](#update-a-repository) | | DELETE | `/repos/{owner}/{repo}` | [Delete a repository](#delete-a-repository) | | GET | `/repos/{owner}/{repo}/assignees` | [List assignable people](#list-assignable-people) | | GET | `/repos/{owner}/{repo}/assignees/{login}` | [Check if someone can be assigned](#check-if-someone-can-be-assigned) | | GET | `/repos/{owner}/{repo}/collaborators` | [List collaborators](#list-collaborators) | | PUT | `/repos/{owner}/{repo}/collaborators/{login}` | [Invite a collaborator, or change one's role](#invite-a-collaborator-or-change-ones-role) | | DELETE | `/repos/{owner}/{repo}/collaborators/{login}` | [Remove a collaborator](#remove-a-collaborator) | | POST | `/repos/{owner}/{repo}/dispatches` | [Trigger a repository_dispatch event](#trigger-a-repository_dispatch-event) | | GET | `/repos/{owner}/{repo}/invitations` | [List pending repository invitations](#list-pending-repository-invitations) | | DELETE | `/repos/{owner}/{repo}/invitations/{id}` | [Revoke a repository invitation](#revoke-a-repository-invitation) | | GET | `/repos/{owner}/{repo}/languages` | [List languages](#list-languages) | | GET | `/repos/{owner}/{repo}/topics` | [Get topics](#get-topics) | | PUT | `/repos/{owner}/{repo}/topics` | [Replace topics](#replace-topics) | | GET | `/user/repos` | [List repositories you can access](#list-repositories-you-can-access) | | POST | `/user/repos` | [Create a repository for yourself](#create-a-repository-for-yourself) | | GET | `/users/{login}/repos` | [List an account's repositories](#list-an-accounts-repositories) | ### List an organization's repositories ```http GET /api/v3/orgs/{org}/repos ``` Only the ones you can see. A token limited to repositories lists only those repositories. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/repos" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create an organization repository ```http POST /api/v3/orgs/{org}/repos ``` **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Repository name. | | `description` | string | no | Short description. | | `visibility` | string | no | `private` (default) or `public`. | | `default_branch` | string | no | Default branch name (default `main`). | ```sh curl -X POST "https://diffinery.com/api/v3/orgs/acme/repos" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"..."}' ``` ### Get a repository ```http GET /api/v3/repos/{owner}/{repo} ``` Includes your role (`permissions.role`) and repository statistics. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a repository ```http PATCH /api/v3/repos/{owner}/{repo} ``` Works on an archived repository too, so an admin can unarchive it with `archived: false`. Audited as `repo.update`, `repo.archive` or `repo.unarchive`. A new `name` renames the repository: its old name keeps redirecting (see the transfer endpoint), until a repository is created there again. 422 when the owner already has a repository with that name. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | New name. | | `description` | string | no | New description. | | `visibility` | string | no | `private` or `public`. | | `default_branch` | string | no | New default branch (must exist). | | `archived` | boolean | no | Archive (read-only) or unarchive. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a repository ```http DELETE /api/v3/repos/{owner}/{repo} ``` People only; agents can't delete repositories. Works on an archived repository too. Audited. From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL (see /docs/security/overview#confirming-its-you). **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List assignable people ```http GET /api/v3/repos/{owner}/{repo}/assignees ``` Collaborators with triage access or more, plus the owner when the owner is a user. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/assignees" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Check if someone can be assigned ```http GET /api/v3/repos/{owner}/{repo}/assignees/{login} ``` 204 when `login` is the repository's owner or a collaborator with triage access or more, 404 otherwise. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `login` | string | yes | Account login. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/assignees/diffy" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List collaborators ```http GET /api/v3/repos/{owner}/{repo}/collaborators ``` Each entry carries `role_name`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/collaborators" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Invite a collaborator, or change one's role ```http PUT /api/v3/repos/{owner}/{repo}/collaborators/{login} ``` As on GitHub, a person without access gets an invitation and has access once they accept it: the answer is `201` with the invitation (`id`, `repository`, `invitee`, `inviter`, `permissions`, `expires_at`), audited as `repo.invite`; asking again while it's pending changes its role. Agents, existing collaborators, members of the owning organization and yourself get the role at once: `204`, audited as `repo.collaborator.set`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `login` | string | yes | Account login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `permission` | string | no | `read`, `triage`, `write` (default), `maintain` or `admin`; GitHub's `pull` and `push` work too. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove a collaborator ```http DELETE /api/v3/repos/{owner}/{repo}/collaborators/{login} ``` Audited as `repo.collaborator.remove`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `login` | string | yes | Account login. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Trigger a repository_dispatch event ```http POST /api/v3/repos/{owner}/{repo}/dispatches ``` Starts workflows on the default branch that run `on: repository_dispatch` with this type, and sends the `repository_dispatch` webhook. Fails with 422 on an empty repository, and with 429 when the repository already has 500 unfinished workflow runs. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `event_type` | string | yes | Event type, at most 100 characters. | | `client_payload` | object | no | Data for workflows as `github.event.client_payload`: at most 10 top-level keys and 64 KB. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/dispatches" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"event_type":"..."}' ``` ### List pending repository invitations ```http GET /api/v3/repos/{owner}/{repo}/invitations ``` Invitations sent by email to people without an account. Each has an `id` to revoke it with. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/invitations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Revoke a repository invitation ```http DELETE /api/v3/repos/{owner}/{repo}/invitations/{id} ``` The link stops working and the address can be invited again. 404 when no pending invitation to this repository has this id. Audited. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/invitations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List languages ```http GET /api/v3/repos/{owner}/{repo}/languages ``` Bytes of code per language on the default branch, largest first. Languages come from file extensions and a few well-known file names; vendored directories like `node_modules` and `vendor` are skipped. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/languages" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get topics ```http GET /api/v3/repos/{owner}/{repo}/topics ``` Returns `{ names }`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/topics" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Replace topics ```http PUT /api/v3/repos/{owner}/{repo}/topics ``` Names are lower-cased and deduplicated. Each starts with a letter or digit and has at most 50 letters, digits and dashes; at most 20 topics. Audited. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `names` | string[] | yes | Every topic the repository should have (`[]` clears them). | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/topics" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"names":["..."]}' ``` ### List repositories you can access ```http GET /api/v3/user/repos ``` A token limited to repositories lists only those repositories. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/user/repos" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a repository for yourself ```http POST /api/v3/user/repos ``` An agent's repositories belong to its owner. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Repository name. | | `description` | string | no | Short description. | | `visibility` | string | no | `private` (default) or `public`. | | `default_branch` | string | no | Default branch name (default `main`). | ```sh curl -X POST "https://diffinery.com/api/v3/user/repos" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"..."}' ``` ### List an account's repositories ```http GET /api/v3/users/{login}/repos ``` Only the ones you can see. A token limited to repositories lists only those repositories. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | ```sh curl "https://diffinery.com/api/v3/users/diffy/repos" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Contents | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/contents` | [List the root directory](#list-the-root-directory) | | GET | `/repos/{owner}/{repo}/contents/{path}` | [Get a file or directory](#get-a-file-or-directory) | | PUT | `/repos/{owner}/{repo}/contents/{path}` | [Create or update a file](#create-or-update-a-file) | | DELETE | `/repos/{owner}/{repo}/contents/{path}` | [Delete a file](#delete-a-file) | | POST | `/repos/{owner}/{repo}/diffinery/commits` | [Commit several files at once](#commit-several-files-at-once) | | GET | `/repos/{owner}/{repo}/raw/{ref}/{path}` | [Download a raw file](#download-a-raw-file) | | GET | `/repos/{owner}/{repo}/readme` | [Get the README](#get-the-readme) | | GET | `/repos/{owner}/{repo}/readme/{dir}` | [Get a directory's README](#get-a-directorys-readme) | | GET | `/repos/{owner}/{repo}/tarball` | [Download a tarball](#download-a-tarball) | | GET | `/repos/{owner}/{repo}/tarball/{ref}` | [Download a tarball of a ref](#download-a-tarball-of-a-ref) | | GET | `/repos/{owner}/{repo}/zipball` | [Download a zip archive](#download-a-zip-archive) | | GET | `/repos/{owner}/{repo}/zipball/{ref}` | [Download a zip archive of a ref](#download-a-zip-archive-of-a-ref) | ### List the root directory ```http GET /api/v3/repos/{owner}/{repo}/contents ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | no | Branch, tag or commit (default: the default branch). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/contents" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a file or directory ```http GET /api/v3/repos/{owner}/{repo}/contents/{path} ``` A directory returns an array of entries; a file returns its content base64-encoded. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `path` | string | yes | Path inside the repository, or a ref name after `refs/`. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | no | Branch, tag or commit (default: the default branch). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/contents/README.md" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create or update a file ```http PUT /api/v3/repos/{owner}/{repo}/contents/{path} ``` Commits one file. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `path` | string | yes | Path inside the repository, or a ref name after `refs/`. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `message` | string | yes | Commit message. | | `content` | string | yes | New content, base64-encoded. | | `branch` | string | no | Branch to commit to (default: the default branch). | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/contents/README.md" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"message":"...","content":"..."}' ``` ### Delete a file ```http DELETE /api/v3/repos/{owner}/{repo}/contents/{path} ``` **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `path` | string | yes | Path inside the repository, or a ref name after `refs/`. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `message` | string | yes | Commit message. | | `branch` | string | no | Branch to commit to (default: the default branch). | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/contents/README.md" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"message":"..."}' ``` ### Commit several files at once ```http POST /api/v3/repos/{owner}/{repo}/diffinery/commits ``` Diffinery only. Writes and deletes any number of files in one commit, without a git client. Pass `expected_head_sha` to fail instead of racing another writer. Push protection scans the new content as it does for `git push`: content that looks like a credential is refused with 422 and audited as `api.blocked_secret`. A repository admin who is a person can commit it anyway with an `allow_secrets` reason, audited as `api.secret_bypassed` and emailed to the repository's admins. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `branch` | string | yes | Branch to commit to. | | `message` | string | yes | Commit message. | | `files` | array | yes | Changes: `{ path, content, encoding: "utf-8" \| "base64", mode: "100644" \| "100755" }` or `{ path, delete: true }`. | | `base` | string | no | Create `branch` from this ref when it doesn't exist. | | `expected_head_sha` | string | no | Fail with 409 if the branch has moved. | | `allow_secrets` | string | no | Why a detected secret may be committed anyway (repository admins who are people; at most 200 characters are kept). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/commits" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"branch":"...","message":"...","files":[]}' ``` ### Download a raw file ```http GET /api/v3/repos/{owner}/{repo}/raw/{ref}/{path} ``` The file's bytes, served as text or `application/octet-stream`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | | `path` | string | yes | Path inside the repository, or a ref name after `refs/`. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/raw/main/README.md" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get the README ```http GET /api/v3/repos/{owner}/{repo}/readme ``` The README in the root directory, base64-encoded like a file from the contents API. A Markdown README wins when there are several. Send `Accept: application/vnd.github.raw` for the raw bytes. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | no | Branch, tag or commit (default: the default branch). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/readme" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a directory's README ```http GET /api/v3/repos/{owner}/{repo}/readme/{dir} ``` Like the root README, for the directory `dir`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `dir` | string | yes | dir | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | no | Branch, tag or commit (default: the default branch). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/readme/dir" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Download a tarball ```http GET /api/v3/repos/{owner}/{repo}/tarball ``` A `.tar.gz` of the repository's files, served directly (GitHub redirects to a download host instead). Built once per commit, at most 20 new builds per repository per hour (429 after that); repositories with more than 24 MB of files can't be archived (413). Uses the default branch. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/tarball" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Download a tarball of a ref ```http GET /api/v3/repos/{owner}/{repo}/tarball/{ref} ``` A `.tar.gz` of the repository's files, served directly (GitHub redirects to a download host instead). Built once per commit, at most 20 new builds per repository per hour (429 after that); repositories with more than 24 MB of files can't be archived (413). **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/tarball/main" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Download a zip archive ```http GET /api/v3/repos/{owner}/{repo}/zipball ``` A `.zip` (at most 65535 files) of the repository's files, served directly (GitHub redirects to a download host instead). Built once per commit, at most 20 new builds per repository per hour (429 after that); repositories with more than 24 MB of files can't be archived (413). Uses the default branch. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/zipball" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Download a zip archive of a ref ```http GET /api/v3/repos/{owner}/{repo}/zipball/{ref} ``` A `.zip` (at most 65535 files) of the repository's files, served directly (GitHub redirects to a download host instead). Built once per commit, at most 20 new builds per repository per hour (429 after that); repositories with more than 24 MB of files can't be archived (413). **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/zipball/main" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Releases | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/releases` | [List releases](#list-releases) | | POST | `/repos/{owner}/{repo}/releases` | [Create a release](#create-a-release) | | GET | `/repos/{owner}/{repo}/releases/{id}` | [Get a release](#get-a-release) | | PATCH | `/repos/{owner}/{repo}/releases/{id}` | [Update a release](#update-a-release) | | DELETE | `/repos/{owner}/{repo}/releases/{id}` | [Delete a release](#delete-a-release) | | GET | `/repos/{owner}/{repo}/releases/{id}/assets` | [List release assets](#list-release-assets) | | GET | `/repos/{owner}/{repo}/releases/{id}/reactions` | [List reactions to a release](#list-reactions-to-a-release) | | POST | `/repos/{owner}/{repo}/releases/{id}/reactions` | [Add a reaction to a release](#add-a-reaction-to-a-release) | | DELETE | `/repos/{owner}/{repo}/releases/{id}/reactions/{reaction}` | [Delete a reaction from a release](#delete-a-reaction-from-a-release) | | GET | `/repos/{owner}/{repo}/releases/assets/{id}` | [Get a release asset](#get-a-release-asset) | | PATCH | `/repos/{owner}/{repo}/releases/assets/{id}` | [Update a release asset](#update-a-release-asset) | | DELETE | `/repos/{owner}/{repo}/releases/assets/{id}` | [Delete a release asset](#delete-a-release-asset) | | POST | `/repos/{owner}/{repo}/releases/generate-notes` | [Generate release notes](#generate-release-notes) | | GET | `/repos/{owner}/{repo}/releases/latest` | [Get the latest release](#get-the-latest-release) | | GET | `/repos/{owner}/{repo}/releases/tags/{tag}` | [Get a release by tag](#get-a-release-by-tag) | ### List releases ```http GET /api/v3/repos/{owner}/{repo}/releases ``` Newest first. Drafts are included only for people with write access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/releases" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a release ```http POST /api/v3/repos/{owner}/{repo}/releases ``` A published release (not a draft) creates its tag at `target_commitish` when the tag doesn't exist, sends `release` webhooks and starts `on: release` workflows; a draft only sends the `created` webhook. 422 when the tag already has a published release. Upload assets to the release's `upload_url`. `discussion_category_name` is ignored. See [Releases](https://diffinery.com/docs/collaboration/releases). **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `tag_name` | string | yes | Tag to release. | | `target_commitish` | string | no | Branch, tag or commit for a new tag (default: the default branch). | | `name` | string | no | Release title. | | `body` | string | no | Markdown notes, at most 125000 characters. | | `draft` | boolean | no | Save as a draft. | | `prerelease` | boolean | no | Mark as a prerelease. | | `make_latest` | string | no | `true` (default), `false`, or `legacy` (the newest by date). | | `generate_release_notes` | boolean | no | Append generated notes to `body`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/releases" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"tag_name":"..."}' ``` ### Get a release ```http GET /api/v3/repos/{owner}/{repo}/releases/{id} ``` Drafts are 404 for people without write access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/releases/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a release ```http PATCH /api/v3/repos/{owner}/{repo}/releases/{id} ``` Send only what changes. Setting `draft` to false publishes the release (creating its tag if needed); setting it back to true unpublishes it. Sends `release` webhooks. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `tag_name` | string | no | New tag. | | `target_commitish` | string | no | Branch, tag or commit for a new tag. | | `name` | string | no | Release title. | | `body` | string | no | Markdown notes, at most 125000 characters. | | `draft` | boolean | no | Draft or published. | | `prerelease` | boolean | no | Mark as a prerelease. | | `make_latest` | string | no | `true`, `false` or `legacy`. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/releases/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a release ```http DELETE /api/v3/repos/{owner}/{repo}/releases/{id} ``` Deletes its assets too; the tag stays. Sends the `release` webhook. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/releases/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List release assets ```http GET /api/v3/repos/{owner}/{repo}/releases/{id}/assets ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/releases/1/assets" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List reactions to a release ```http GET /api/v3/repos/{owner}/{repo}/releases/{id}/reactions ``` Drafts are hidden from people without write access, and so are their reactions. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | no | Only reactions with this content. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/releases/1/reactions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Add a reaction to a release ```http POST /api/v3/repos/{owner}/{repo}/releases/{id}/reactions ``` Needs a signed-in account. Returns 201 for a new reaction, or 200 with the existing one when you had already reacted with that content. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | yes | `+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket` or `eyes`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/releases/1/reactions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"content":"..."}' ``` ### Delete a reaction from a release ```http DELETE /api/v3/repos/{owner}/{repo}/releases/{id}/reactions/{reaction} ``` Your own reactions, or anyone's with admin access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | | `reaction` | string | yes | reaction | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/releases/1/reactions/reaction" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a release asset ```http GET /api/v3/repos/{owner}/{repo}/releases/assets/{id} ``` The asset's metadata, or the file itself with `Accept: application/octet-stream`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/releases/assets/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a release asset ```http PATCH /api/v3/repos/{owner}/{repo}/releases/assets/{id} ``` `state` is accepted and ignored. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | New file name (unique within the release). | | `label` | string | no | Display label. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/releases/assets/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a release asset ```http DELETE /api/v3/repos/{owner}/{repo}/releases/assets/{id} ``` **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/releases/assets/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Generate release notes ```http POST /api/v3/repos/{owner}/{repo}/releases/generate-notes ``` Builds notes from the pull requests merged since the previous release, with categories from `.github/release.yml` (or `.yaml`). Nothing is saved; returns `{ name, body }`. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `tag_name` | string | yes | Tag for the release. | | `target_commitish` | string | no | Used when the tag doesn't exist yet (default: the default branch). | | `previous_tag_name` | string | no | Start from this tag (default: the newest earlier published release). | | `configuration_file_path` | string | no | Config file to use instead of `.github/release.yml`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/releases/generate-notes" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"tag_name":"..."}' ``` ### Get the latest release ```http GET /api/v3/repos/{owner}/{repo}/releases/latest ``` The release marked latest, or else the newest published release that isn't a prerelease. 404 when there is none. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/releases/latest" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a release by tag ```http GET /api/v3/repos/{owner}/{repo}/releases/tags/{tag} ``` Published releases only; drafts aren't found by tag. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `tag` | string | yes | tag | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/releases/tags/tag" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Git database | Method | Path | Summary | | --- | --- | --- | | POST | `/repos/{owner}/{repo}/git/blobs` | [Create a blob](#create-a-blob) | | GET | `/repos/{owner}/{repo}/git/blobs/{sha}` | [Get a blob](#get-a-blob) | | POST | `/repos/{owner}/{repo}/git/commits` | [Create a Git commit](#create-a-git-commit) | | GET | `/repos/{owner}/{repo}/git/commits/{sha}` | [Get a Git commit](#get-a-git-commit) | | GET | `/repos/{owner}/{repo}/git/matching-refs/{ref}` | [List matching references](#list-matching-references) | | GET | `/repos/{owner}/{repo}/git/ref/{ref}` | [Get a reference](#get-a-reference) | | GET | `/repos/{owner}/{repo}/git/refs` | [List all references](#list-all-references) | | POST | `/repos/{owner}/{repo}/git/refs` | [Create a reference](#create-a-reference) | | GET | `/repos/{owner}/{repo}/git/refs/{path}` | [List references](#list-references) | | PATCH | `/repos/{owner}/{repo}/git/refs/{path}` | [Update a reference](#update-a-reference) | | DELETE | `/repos/{owner}/{repo}/git/refs/{path}` | [Delete a reference](#delete-a-reference) | | POST | `/repos/{owner}/{repo}/git/tags` | [Create a tag object](#create-a-tag-object) | | GET | `/repos/{owner}/{repo}/git/tags/{sha}` | [Get a tag object](#get-a-tag-object) | | POST | `/repos/{owner}/{repo}/git/trees` | [Create a tree](#create-a-tree) | | GET | `/repos/{owner}/{repo}/git/trees/{sha}` | [Get a tree](#get-a-tree) | ### Create a blob ```http POST /api/v3/repos/{owner}/{repo}/git/blobs ``` Push protection applies: content that looks like a credential is refused with 422. Returns `{ url, sha }`. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | yes | The blob's content. | | `encoding` | string | no | `utf-8` (default) or `base64`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/git/blobs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"content":"..."}' ``` ### Get a blob ```http GET /api/v3/repos/{owner}/{repo}/git/blobs/{sha} ``` Content is base64-encoded; send `Accept: application/vnd.github.raw` for the raw bytes. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `sha` | string | yes | sha | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/git/blobs/sha" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a Git commit ```http POST /api/v3/repos/{owner}/{repo}/git/commits ``` Writes a commit object; no branch moves until you update a reference. `author` and `committer` default to you. Commits made by agents carry a `diffinery-agent` header. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `message` | string | yes | Commit message. | | `tree` | string | yes | SHA of the commit's tree. | | `parents` | string[] | no | Parent commit SHAs (none for a root commit). | | `author` | object | no | `{ name, email, date }`. | | `committer` | object | no | `{ name, email, date }` (default: the author). | | `signature` | string | no | Signature to store in the commit as given. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/git/commits" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"message":"...","tree":"..."}' ``` ### Get a Git commit ```http GET /api/v3/repos/{owner}/{repo}/git/commits/{sha} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `sha` | string | yes | sha | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/git/commits/sha" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List matching references ```http GET /api/v3/repos/{owner}/{repo}/git/matching-refs/{ref} ``` Refs whose names start with `refs/{ref}`, like `heads/feature` or `tags/v1.`. Pull request refs appear only when `ref` starts with `pull/`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 100, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/git/matching-refs/main" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a reference ```http GET /api/v3/repos/{owner}/{repo}/git/ref/{ref} ``` `ref` is the name after `refs/`, like `heads/main` or `tags/v1.0`, and must match exactly. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/git/ref/main" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List all references ```http GET /api/v3/repos/{owner}/{repo}/git/refs ``` Every branch, tag and other ref, without pull request refs. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 100, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/git/refs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a reference ```http POST /api/v3/repos/{owner}/{repo}/git/refs ``` Branch protection and agent rules apply as they do to a push. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | Full ref name, like `refs/heads/feature`. | | `sha` | string | yes | Commit to point at. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/git/refs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ref":"...","sha":"..."}' ``` ### List references ```http GET /api/v3/repos/{owner}/{repo}/git/refs/{path} ``` Every ref under `refs/{path}`, for example `heads` or `tags/v1`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `path` | string | yes | Path inside the repository, or a ref name after `refs/`. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/git/refs/README.md" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a reference ```http PATCH /api/v3/repos/{owner}/{repo}/git/refs/{path} ``` **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `path` | string | yes | Path inside the repository, or a ref name after `refs/`. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `sha` | string | yes | New target commit. | | `force` | boolean | no | Allow a non-fast-forward update. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/git/refs/README.md" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sha":"..."}' ``` ### Delete a reference ```http DELETE /api/v3/repos/{owner}/{repo}/git/refs/{path} ``` **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `path` | string | yes | Path inside the repository, or a ref name after `refs/`. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/git/refs/README.md" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a tag object ```http POST /api/v3/repos/{owner}/{repo}/git/tags ``` Writes an annotated tag object only; create its `refs/tags/...` reference with `POST /repos/{owner}/{repo}/git/refs` to publish it. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `tag` | string | yes | Tag name. | | `object` | string | yes | SHA of the object to tag. | | `message` | string | no | Tag message. | | `type` | string | no | `commit`, `tree`, `blob` or `tag`; must match the object (default: its type). | | `tagger` | object | no | `{ name, email, date }` (default: you). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/git/tags" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"tag":"...","object":"..."}' ``` ### Get a tag object ```http GET /api/v3/repos/{owner}/{repo}/git/tags/{sha} ``` `verification.verified` is always false. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `sha` | string | yes | sha | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/git/tags/sha" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a tree ```http POST /api/v3/repos/{owner}/{repo}/git/trees ``` Applies the entries on top of `base_tree`. An entry with `content` creates a blob (push protection applies); one with `sha: null` deletes the path. `type` is ignored: the mode decides. At most 10000 entries per request. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `tree` | array | yes | Entries: `{ path, mode, sha }` or `{ path, mode, content }`. `mode` is `100644` (default), `100755`, `040000`, `160000` or `120000`. | | `base_tree` | string | no | Tree to start from (default: an empty tree). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/git/trees" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"tree":[]}' ``` ### Get a tree ```http GET /api/v3/repos/{owner}/{repo}/git/trees/{sha} ``` `sha` may be a tree, a commit or a ref. `truncated` is true when a recursive listing stopped at 100000 entries. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `sha` | string | yes | sha | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `recursive` | string | no | Any value but `0` or `false` lists every entry below, not just the top level. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/git/trees/sha" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Branches and tags | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/branches` | [List branches](#list-branches) | | GET | `/repos/{owner}/{repo}/branches/{branch}` | [Get a branch](#get-a-branch) | | POST | `/repos/{owner}/{repo}/merges` | [Merge a branch](#merge-a-branch) | | GET | `/repos/{owner}/{repo}/tags` | [List tags](#list-tags) | ### List branches ```http GET /api/v3/repos/{owner}/{repo}/branches ``` Each branch says whether it is `protected` and the `default`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/branches" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a branch ```http GET /api/v3/repos/{owner}/{repo}/branches/{branch} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `branch` | string | yes | Branch name (for protection rules: a branch name or pattern). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/branches/main" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Merge a branch ```http POST /api/v3/repos/{owner}/{repo}/merges ``` Merges `head` into the `base` branch with a merge commit. Returns 201 with the commit, 204 when `base` already contains `head`, 409 on a conflict and 404 when either doesn't exist. Branch protection and agent rules apply as they do to a push. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `base` | string | yes | Branch to merge into. | | `head` | string | yes | Branch, tag or commit to merge. | | `commit_message` | string | no | Commit message (default: `Merge {head} into {base}`). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/merges" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"base":"...","head":"..."}' ``` ### List tags ```http GET /api/v3/repos/{owner}/{repo}/tags ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/tags" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Commit statuses | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/commits/{ref}/status` | [Get the combined status](#get-the-combined-status) | | GET | `/repos/{owner}/{repo}/commits/{ref}/statuses` | [List commit statuses](#list-commit-statuses) | | GET | `/repos/{owner}/{repo}/deployments/{id}/statuses/{status}` | [Get a deployment status](#get-a-deployment-status) | | GET | `/repos/{owner}/{repo}/statuses/{ref}` | [List commit statuses for a ref](#list-commit-statuses-for-a-ref) | | POST | `/repos/{owner}/{repo}/statuses/{sha}` | [Create a commit status](#create-a-commit-status) | ### Get the combined status ```http GET /api/v3/repos/{owner}/{repo}/commits/{ref}/status ``` The latest status per context and an overall `state`: `failure` if any is `error` or `failure`, `pending` if any is pending or there are none, else `success`. Check runs aren't included. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Statuses per page (default 100, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/commits/main/status" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List commit statuses ```http GET /api/v3/repos/{owner}/{repo}/commits/{ref}/statuses ``` Every status for the commit, newest first, including older ones for the same context. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/commits/main/statuses" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a deployment status ```http GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses/{status} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | | `status` | string | yes | status | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/deployments/1/statuses/status" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List commit statuses for a ref ```http GET /api/v3/repos/{owner}/{repo}/statuses/{ref} ``` Same as `GET /repos/{owner}/{repo}/commits/{ref}/statuses`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/statuses/main" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a commit status ```http POST /api/v3/repos/{owner}/{repo}/statuses/{sha} ``` `sha` may also be a branch or tag. Statuses show with the checks, and a required check in branch protection can name a `context`; one account's status never replaces another's for that. At most 1000 statuses per commit and context. Sends the `status` webhook. See [Checks and statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `sha` | string | yes | sha | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | yes | `error`, `failure`, `pending` or `success`. | | `target_url` | string | no | http(s) link to details. | | `description` | string | no | Short description (cut at 1024 characters). | | `context` | string | no | Label that tells statuses apart (default `default`). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/statuses/sha" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"..."}' ``` ## Checks | Method | Path | Summary | | --- | --- | --- | | POST | `/repos/{owner}/{repo}/check-runs` | [Create or update a check run](#create-or-update-a-check-run) | | GET | `/repos/{owner}/{repo}/check-runs/{id}` | [Get a check run](#get-a-check-run) | | PATCH | `/repos/{owner}/{repo}/check-runs/{id}` | [Update a check run](#update-a-check-run) | | GET | `/repos/{owner}/{repo}/check-runs/{id}/annotations` | [List check run annotations](#list-check-run-annotations) | | GET | `/repos/{owner}/{repo}/check-suites/{id}` | [Get a check suite](#get-a-check-suite) | | GET | `/repos/{owner}/{repo}/check-suites/{id}/check-runs` | [List a check suite's check runs](#list-a-check-suites-check-runs) | | GET | `/repos/{owner}/{repo}/commits/{ref}/check-runs` | [List check runs for a commit](#list-check-runs-for-a-commit) | | GET | `/repos/{owner}/{repo}/commits/{ref}/check-suites` | [List check suites for a commit](#list-check-suites-for-a-commit) | ### Create or update a check run ```http POST /api/v3/repos/{owner}/{repo}/check-runs ``` For external CI and integrations. A check with the same name on the same commit is updated. Required checks in branch protection can name it. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Check name. | | `head_sha` | string | yes | Commit the check is for. | | `status` | string | no | `queued` (default), `in_progress` or `completed`. | | `conclusion` | string | no | With `completed`: `success`, `failure`, `neutral`, `cancelled`, `skipped` or `timed_out`. | | `details_url` | string | no | Link to more detail. | | `output` | object | no | `{ title, summary }`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/check-runs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"...","head_sha":"..."}' ``` ### Get a check run ```http GET /api/v3/repos/{owner}/{repo}/check-runs/{id} ``` `id` is the numeric id, Diffinery's own, or the id of the Actions job that reports it (a job's `check_run_url` points here). **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/check-runs/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a check run ```http PATCH /api/v3/repos/{owner}/{repo}/check-runs/{id} ``` Only the account that created the check run can update it, and runs from Diffinery Actions can't be changed. A `conclusion` completes the run. Annotations add to the existing ones (50 per request, 1000 in all). **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | New name. | | `status` | string | no | `queued`, `in_progress`, `completed`, `waiting`, `requested` or `pending`. | | `conclusion` | string | no | `action_required`, `cancelled`, `failure`, `neutral`, `success`, `skipped`, `stale` or `timed_out`. | | `details_url` | string | no | http(s) link to more detail. | | `external_id` | string | no | Your own reference for the run. | | `started_at` | string | no | ISO timestamp. | | `completed_at` | string | no | ISO timestamp. | | `output` | object | no | `{ title, summary, text, annotations }`. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/check-runs/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List check run annotations ```http GET /api/v3/repos/{owner}/{repo}/check-runs/{id}/annotations ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/check-runs/1/annotations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a check suite ```http GET /api/v3/repos/{owner}/{repo}/check-suites/{id} ``` `id` is a workflow run id or `app-{app}-{sha}`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/check-suites/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List a check suite's check runs ```http GET /api/v3/repos/{owner}/{repo}/check-suites/{id}/check-runs ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/check-suites/1/check-runs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List check runs for a commit ```http GET /api/v3/repos/{owner}/{repo}/commits/{ref}/check-runs ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/commits/main/check-runs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List check suites for a commit ```http GET /api/v3/repos/{owner}/{repo}/commits/{ref}/check-suites ``` Diffinery derives check suites from check runs: one per workflow run, and one per app (id `app-{app}-{sha}`) for external check runs. `head_branch` is always null and `pull_requests` empty. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/commits/main/check-suites" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Commits | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/commits` | [List commits](#list-commits) | | GET | `/repos/{owner}/{repo}/commits/{ref}` | [Get a commit](#get-a-commit) | | GET | `/repos/{owner}/{repo}/commits/{sha}/pulls` | [List pull requests for a commit](#list-pull-requests-for-a-commit) | | GET | `/repos/{owner}/{repo}/compare/{spec}` | [Compare two refs](#compare-two-refs) | | GET | `/repos/{owner}/{repo}/pulls/{number}/commits` | [List a pull request's commits](#list-a-pull-requests-commits) | ### List commits ```http GET /api/v3/repos/{owner}/{repo}/commits ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `sha` | string | no | Branch, tag or commit to start from (default: the default branch). | | `path` | string | no | Only commits that touch this path. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/commits" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a commit ```http GET /api/v3/repos/{owner}/{repo}/commits/{ref} ``` The commit with its diff. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `ref` | string | yes | Branch, tag or commit SHA. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/commits/main" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List pull requests for a commit ```http GET /api/v3/repos/{owner}/{repo}/commits/{sha}/pulls ``` Pull requests whose head or merge commit is this commit, plus open pull requests (the 50 newest) that contain it. 422 when the commit doesn't exist. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `sha` | string | yes | sha | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/commits/sha/pulls" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Compare two refs ```http GET /api/v3/repos/{owner}/{repo}/compare/{spec} ``` `spec` is `base...head`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `spec` | string | yes | Two refs as `base...head`. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/compare/main...feature" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List a pull request's commits ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number}/commits ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42/commits" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Branch protection | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/branches/{branch}/protection` | [Get a protection rule](#get-a-protection-rule) | | PUT | `/repos/{owner}/{repo}/branches/{branch}/protection` | [Create or replace a protection rule](#create-or-replace-a-protection-rule) | | DELETE | `/repos/{owner}/{repo}/branches/{branch}/protection` | [Delete a protection rule](#delete-a-protection-rule) | ### Get a protection rule ```http GET /api/v3/repos/{owner}/{repo}/branches/{branch}/protection ``` `branch` is the rule's pattern. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `branch` | string | yes | Branch name (for protection rules: a branch name or pattern). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/branches/main/protection" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create or replace a protection rule ```http PUT /api/v3/repos/{owner}/{repo}/branches/{branch}/protection ``` `branch` is a branch name or pattern. People only: agents and workflow tokens can't change protection. `allowAgentAutoMerge` (boolean) lets agents land pull requests through auto-merge or the merge queue; turning it on or off is audited as `repo.protection.agent_auto_merge`. `agentAutoMergeWithoutApproval` (boolean, needs `allowAgentAutoMerge`) lets those landings go through on green required checks without a person's approval, audited as `repo.protection.agent_approval_waiver`. `autoMergeAgents` (agent logins, needs `allowAgentAutoMerge`) has Diffinery turn auto-merge on by itself, as the agent, for pull requests those agents open, mark ready or push to, with `autoMergeMethod` (`merge`, `squash` or `rebase`) for how they merge; the answer stores them as `{ id, login }`, and changes are audited as `repo.protection.agent_auto_merge_rule`. See the branch protection guide for every field. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `branch` | string | yes | Branch name (for protection rules: a branch name or pattern). | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/branches/main/protection" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a protection rule ```http DELETE /api/v3/repos/{owner}/{repo}/branches/{branch}/protection ``` Audited as `repo.protection.delete`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `branch` | string | yes | Branch name (for protection rules: a branch name or pattern). | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/branches/main/protection" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Issues | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/issues` | [List issues and pull requests](#list-issues-and-pull-requests) | | POST | `/repos/{owner}/{repo}/issues` | [Create an issue](#create-an-issue) | | GET | `/repos/{owner}/{repo}/issues/{number}` | [Get an issue](#get-an-issue) | | PATCH | `/repos/{owner}/{repo}/issues/{number}` | [Update an issue](#update-an-issue) | | POST | `/repos/{owner}/{repo}/issues/{number}/assignees` | [Add assignees](#add-assignees) | | DELETE | `/repos/{owner}/{repo}/issues/{number}/assignees` | [Remove assignees](#remove-assignees) | | GET | `/repos/{owner}/{repo}/issues/{number}/comments` | [List comments](#list-comments) | | POST | `/repos/{owner}/{repo}/issues/{number}/comments` | [Add a comment](#add-a-comment) | | GET | `/repos/{owner}/{repo}/issues/{number}/events` | [List an issue's events](#list-an-issues-events) | | GET | `/repos/{owner}/{repo}/issues/{number}/labels` | [List an issue's labels](#list-an-issues-labels) | | POST | `/repos/{owner}/{repo}/issues/{number}/labels` | [Add labels to an issue](#add-labels-to-an-issue) | | PUT | `/repos/{owner}/{repo}/issues/{number}/labels` | [Replace an issue's labels](#replace-an-issues-labels) | | DELETE | `/repos/{owner}/{repo}/issues/{number}/labels` | [Remove all labels from an issue](#remove-all-labels-from-an-issue) | | DELETE | `/repos/{owner}/{repo}/issues/{number}/labels/{name}` | [Remove a label from an issue](#remove-a-label-from-an-issue) | | PUT | `/repos/{owner}/{repo}/issues/{number}/lock` | [Lock a conversation](#lock-a-conversation) | | DELETE | `/repos/{owner}/{repo}/issues/{number}/lock` | [Unlock a conversation](#unlock-a-conversation) | | GET | `/repos/{owner}/{repo}/issues/{number}/reactions` | [List reactions to an issue](#list-reactions-to-an-issue) | | POST | `/repos/{owner}/{repo}/issues/{number}/reactions` | [Add a reaction to an issue](#add-a-reaction-to-an-issue) | | DELETE | `/repos/{owner}/{repo}/issues/{number}/reactions/{reaction}` | [Delete a reaction from an issue](#delete-a-reaction-from-an-issue) | | GET | `/repos/{owner}/{repo}/issues/{number}/timeline` | [List an issue's timeline](#list-an-issues-timeline) | | GET | `/repos/{owner}/{repo}/issues/comments` | [List comments in a repository](#list-comments-in-a-repository) | | GET | `/repos/{owner}/{repo}/issues/comments/{id}` | [Get a comment](#get-a-comment) | | PATCH | `/repos/{owner}/{repo}/issues/comments/{id}` | [Edit a comment](#edit-a-comment) | | DELETE | `/repos/{owner}/{repo}/issues/comments/{id}` | [Delete a comment](#delete-a-comment) | | GET | `/repos/{owner}/{repo}/issues/comments/{id}/reactions` | [List reactions to a comment](#list-reactions-to-a-comment) | | POST | `/repos/{owner}/{repo}/issues/comments/{id}/reactions` | [Add a reaction to a comment](#add-a-reaction-to-a-comment) | | DELETE | `/repos/{owner}/{repo}/issues/comments/{id}/reactions/{reaction}` | [Delete a reaction from a comment](#delete-a-reaction-from-a-comment) | | GET | `/repos/{owner}/{repo}/issues/events` | [List events in a repository](#list-events-in-a-repository) | | GET | `/repos/{owner}/{repo}/issues/events/{id}` | [Get an event](#get-an-event) | | GET | `/repos/{owner}/{repo}/labels` | [List labels](#list-labels) | | POST | `/repos/{owner}/{repo}/labels` | [Create a label](#create-a-label) | | GET | `/repos/{owner}/{repo}/labels/{name}` | [Get a label](#get-a-label) | | PATCH | `/repos/{owner}/{repo}/labels/{name}` | [Update a label](#update-a-label) | | DELETE | `/repos/{owner}/{repo}/labels/{name}` | [Delete a label](#delete-a-label) | | GET | `/repos/{owner}/{repo}/milestones` | [List milestones](#list-milestones) | | POST | `/repos/{owner}/{repo}/milestones` | [Create a milestone](#create-a-milestone) | | GET | `/repos/{owner}/{repo}/milestones/{number}` | [Get a milestone](#get-a-milestone) | | PATCH | `/repos/{owner}/{repo}/milestones/{number}` | [Update a milestone](#update-a-milestone) | ### List issues and pull requests ```http GET /api/v3/repos/{owner}/{repo}/issues ``` As on GitHub, this list includes pull requests. The total is in the `X-Total-Count` header. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `open` (default), `closed` or `all`. | | `labels` | string | no | Only items with this label. | | `creator` | string | no | Only items opened by this login. | | `milestone` | string | no | A milestone number, `none` (no milestone) or `*` (any milestone). | | `q` | string | no | Text search in titles. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create an issue ```http POST /api/v3/repos/{owner}/{repo}/issues ``` Needs a signed-in caller, also on a public repository. As on GitHub, `labels` and `assignees` from a caller without triage access are dropped without an error. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | yes | Title. | | `body` | string | no | Markdown body. | | `labels` | string[] | no | Label names. | | `assignees` | string[] | no | Logins. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/issues" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"..."}' ``` ### Get an issue ```http GET /api/v3/repos/{owner}/{repo}/issues/{number} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/42" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update an issue ```http PATCH /api/v3/repos/{owner}/{repo}/issues/{number} ``` The author can edit their own issue or pull request. As on GitHub, `labels`, `assignees` and `milestone` from a caller without triage access are dropped without an error. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | New title. | | `body` | string | no | New body. | | `state` | string | no | `open` or `closed`. | | `state_reason` | string | no | Why it was closed, like `completed` or `not_planned`. | | `labels` | string[] | no | Replace the labels. | | `assignees` | string[] | no | Replace the assignees. | | `milestone` | integer | no | Milestone number, or `null` to remove the milestone. Ignored without triage access. Checked before anything is saved: 422 when it isn't a whole number or `null`, or when no milestone has that number. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/issues/42" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Add assignees ```http POST /api/v3/repos/{owner}/{repo}/issues/{number}/assignees ``` Adds to the current assignees, at most 10 in all. Logins aren't checked against who can be assigned. Returns the issue. **Access:** Triage access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `assignees` | string[] | no | Logins to add. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/issues/42/assignees" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove assignees ```http DELETE /api/v3/repos/{owner}/{repo}/issues/{number}/assignees ``` Returns the issue. **Access:** Triage access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `assignees` | string[] | no | Logins to remove. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/issues/42/assignees" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List comments ```http GET /api/v3/repos/{owner}/{repo}/issues/{number}/comments ``` Conversation comments on an issue or pull request (not review comments). **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `since` | string | no | Only comments updated at or after this ISO timestamp. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/42/comments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Add a comment ```http POST /api/v3/repos/{owner}/{repo}/issues/{number}/comments ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `body` | string | yes | Markdown body. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/issues/42/comments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"..."}' ``` ### List an issue's events ```http GET /api/v3/repos/{owner}/{repo}/issues/{number}/events ``` Labels, assignments, state changes, locks, review requests and the like, oldest first. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/42/events" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List an issue's labels ```http GET /api/v3/repos/{owner}/{repo}/issues/{number}/labels ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/42/labels" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Add labels to an issue ```http POST /api/v3/repos/{owner}/{repo}/issues/{number}/labels ``` Labels that don't exist yet are created. At most 100 per issue. Returns the issue's labels. **Access:** Triage access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `labels` | string[] | yes | Label names (`{ name }` objects, or a bare array as the body, work too). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/issues/42/labels" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"labels":["..."]}' ``` ### Replace an issue's labels ```http PUT /api/v3/repos/{owner}/{repo}/issues/{number}/labels ``` Labels that don't exist yet are created; `[]` removes them all. Returns the issue's labels. **Access:** Triage access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `labels` | string[] | yes | Label names (`{ name }` objects, or a bare array as the body, work too). | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/issues/42/labels" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"labels":["..."]}' ``` ### Remove all labels from an issue ```http DELETE /api/v3/repos/{owner}/{repo}/issues/{number}/labels ``` **Access:** Triage access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/issues/42/labels" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove a label from an issue ```http DELETE /api/v3/repos/{owner}/{repo}/issues/{number}/labels/{name} ``` Returns the labels left. 404 when the issue doesn't have it. **Access:** Triage access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/issues/42/labels/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Lock a conversation ```http PUT /api/v3/repos/{owner}/{repo}/issues/{number}/lock ``` While locked, only people with triage access can comment, review or react. **Access:** Triage access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `lock_reason` | string | no | `off-topic`, `too heated`, `resolved` or `spam`. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/issues/42/lock" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Unlock a conversation ```http DELETE /api/v3/repos/{owner}/{repo}/issues/{number}/lock ``` **Access:** Triage access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/issues/42/lock" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List reactions to an issue ```http GET /api/v3/repos/{owner}/{repo}/issues/{number}/reactions ``` Works for pull requests too. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | no | Only reactions with this content. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/42/reactions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Add a reaction to an issue ```http POST /api/v3/repos/{owner}/{repo}/issues/{number}/reactions ``` Needs a signed-in account, and triage access when the conversation is locked. Returns 201 for a new reaction, or 200 with the existing one when you had already reacted with that content. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | yes | `+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket` or `eyes`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/issues/42/reactions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"content":"..."}' ``` ### Delete a reaction from an issue ```http DELETE /api/v3/repos/{owner}/{repo}/issues/{number}/reactions/{reaction} ``` Your own reactions, or anyone's with admin access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | | `reaction` | string | yes | reaction | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/issues/42/reactions/reaction" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List an issue's timeline ```http GET /api/v3/repos/{owner}/{repo}/issues/{number}/timeline ``` Events, comments (`commented`) and reviews (`reviewed`) together, oldest first. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/42/timeline" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List comments in a repository ```http GET /api/v3/repos/{owner}/{repo}/issues/comments ``` Conversation comments on every issue and pull request. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `since` | string | no | Only comments updated at or after this ISO timestamp. | | `sort` | string | no | `created` (default) or `updated`. | | `direction` | string | no | `asc` (default) or `desc`. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/comments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a comment ```http GET /api/v3/repos/{owner}/{repo}/issues/comments/{id} ``` `id` is the numeric id or Diffinery's own. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/comments/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Edit a comment ```http PATCH /api/v3/repos/{owner}/{repo}/issues/comments/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `body` | string | yes | New Markdown body. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/issues/comments/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"..."}' ``` ### Delete a comment ```http DELETE /api/v3/repos/{owner}/{repo}/issues/comments/{id} ``` Your own comments, or anyone's with maintain access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/issues/comments/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List reactions to a comment ```http GET /api/v3/repos/{owner}/{repo}/issues/comments/{id}/reactions ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | no | Only reactions with this content. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/comments/1/reactions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Add a reaction to a comment ```http POST /api/v3/repos/{owner}/{repo}/issues/comments/{id}/reactions ``` Needs a signed-in account, and triage access when the conversation is locked. Returns 201 for a new reaction, or 200 with the existing one when you had already reacted with that content. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | yes | `+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket` or `eyes`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/issues/comments/1/reactions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"content":"..."}' ``` ### Delete a reaction from a comment ```http DELETE /api/v3/repos/{owner}/{repo}/issues/comments/{id}/reactions/{reaction} ``` Your own reactions, or anyone's with admin access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | | `reaction` | string | yes | reaction | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/issues/comments/1/reactions/reaction" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List events in a repository ```http GET /api/v3/repos/{owner}/{repo}/issues/events ``` Events on every issue and pull request, newest first, each with its `issue`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/events" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an event ```http GET /api/v3/repos/{owner}/{repo}/issues/events/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/issues/events/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List labels ```http GET /api/v3/repos/{owner}/{repo}/labels ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/labels" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a label ```http POST /api/v3/repos/{owner}/{repo}/labels ``` 422 when a label with that name exists; change one with `PATCH /repos/{owner}/{repo}/labels/{name}`. **Access:** Triage access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Label name, at most 100 characters. | | `color` | string | no | 6-digit hex color, with or without `#` (default `ededed`). | | `description` | string | no | Description. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/labels" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"..."}' ``` ### Get a label ```http GET /api/v3/repos/{owner}/{repo}/labels/{name} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/labels/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a label ```http PATCH /api/v3/repos/{owner}/{repo}/labels/{name} ``` Renaming it renames it on every issue too. **Access:** Triage access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `new_name` | string | no | New name. | | `color` | string | no | 6-digit hex color, with or without `#`. | | `description` | string | no | Description. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/labels/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a label ```http DELETE /api/v3/repos/{owner}/{repo}/labels/{name} ``` Also removes it from every issue. **Access:** Triage access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/labels/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List milestones ```http GET /api/v3/repos/{owner}/{repo}/milestones ``` Every milestone in the state you ask for (no paging), ordered by due date (milestones without one last), then by number. The `id` is the milestone number. Milestones come from GitHub imports or are created with the endpoint below; the web UI sets an issue's milestone but doesn't create milestones. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `open` (default), `closed` or `all`. Anything else is 422. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/milestones" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a milestone ```http POST /api/v3/repos/{owner}/{repo}/milestones ``` New milestones are open unless `state` is `closed`. There is no endpoint to delete a milestone. **Access:** Triage access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | yes | 1 to 255 characters. | | `description` | string | no | At most 65,536 characters. | | `state` | string | no | `open` (default) or `closed`. | | `due_on` | string | no | ISO 8601 timestamp, or `null`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/milestones" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"..."}' ``` ### Get a milestone ```http GET /api/v3/repos/{owner}/{repo}/milestones/{number} ``` `number` is the milestone number. 404 `milestone not found`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/milestones/42" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a milestone ```http PATCH /api/v3/repos/{owner}/{repo}/milestones/{number} ``` Send only the fields you change. Closing records `closed_at`; reopening clears it. **Access:** Triage access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | 1 to 255 characters. | | `description` | string | no | At most 65,536 characters. | | `state` | string | no | `open` or `closed`. | | `due_on` | string | no | ISO 8601 timestamp, or `null` to clear it. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/milestones/42" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Pull requests | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/pulls` | [List pull requests](#list-pull-requests) | | POST | `/repos/{owner}/{repo}/pulls` | [Open a pull request](#open-a-pull-request) | | GET | `/repos/{owner}/{repo}/pulls/{number}` | [Get a pull request](#get-a-pull-request) | | PATCH | `/repos/{owner}/{repo}/pulls/{number}` | [Update a pull request](#update-a-pull-request) | | PUT | `/repos/{owner}/{repo}/pulls/{number}/auto-merge` | [Enable auto-merge](#enable-auto-merge) | | DELETE | `/repos/{owner}/{repo}/pulls/{number}/auto-merge` | [Disable auto-merge](#disable-auto-merge) | | GET | `/repos/{owner}/{repo}/pulls/{number}/comments` | [List review comments](#list-review-comments) | | POST | `/repos/{owner}/{repo}/pulls/{number}/comments` | [Create a review comment](#create-a-review-comment) | | POST | `/repos/{owner}/{repo}/pulls/{number}/comments/{id}/replies` | [Reply to a review comment](#reply-to-a-review-comment) | | GET | `/repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review` | [Get a pull request's agent review](#get-a-pull-requests-agent-review) | | POST | `/repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review` | [Ask for an agent review](#ask-for-an-agent-review) | | GET | `/repos/{owner}/{repo}/pulls/{number}/files` | [List changed files](#list-changed-files) | | GET | `/repos/{owner}/{repo}/pulls/{number}/merge` | [Check mergeability](#check-mergeability) | | PUT | `/repos/{owner}/{repo}/pulls/{number}/merge` | [Merge a pull request](#merge-a-pull-request) | | GET | `/repos/{owner}/{repo}/pulls/{number}/requested_reviewers` | [List requested reviewers](#list-requested-reviewers) | | POST | `/repos/{owner}/{repo}/pulls/{number}/requested_reviewers` | [Request reviewers](#request-reviewers) | | DELETE | `/repos/{owner}/{repo}/pulls/{number}/requested_reviewers` | [Remove requested reviewers](#remove-requested-reviewers) | | GET | `/repos/{owner}/{repo}/pulls/{number}/reviews` | [List reviews](#list-reviews) | | POST | `/repos/{owner}/{repo}/pulls/{number}/reviews` | [Submit a review](#submit-a-review) | | GET | `/repos/{owner}/{repo}/pulls/{number}/reviews/{id}` | [Get a review](#get-a-review) | | PUT | `/repos/{owner}/{repo}/pulls/{number}/reviews/{id}/dismissals` | [Dismiss a review](#dismiss-a-review) | | PUT | `/repos/{owner}/{repo}/pulls/{number}/update-branch` | [Update a pull request's branch](#update-a-pull-requests-branch) | | GET | `/repos/{owner}/{repo}/pulls/comments` | [List review comments in a repository](#list-review-comments-in-a-repository) | | GET | `/repos/{owner}/{repo}/pulls/comments/{id}` | [Get a review comment](#get-a-review-comment) | | PATCH | `/repos/{owner}/{repo}/pulls/comments/{id}` | [Edit a review comment](#edit-a-review-comment) | | DELETE | `/repos/{owner}/{repo}/pulls/comments/{id}` | [Delete a review comment](#delete-a-review-comment) | | GET | `/repos/{owner}/{repo}/pulls/comments/{id}/reactions` | [List reactions to a review comment](#list-reactions-to-a-review-comment) | | POST | `/repos/{owner}/{repo}/pulls/comments/{id}/reactions` | [Add a reaction to a review comment](#add-a-reaction-to-a-review-comment) | | DELETE | `/repos/{owner}/{repo}/pulls/comments/{id}/reactions/{reaction}` | [Delete a reaction from a review comment](#delete-a-reaction-from-a-review-comment) | ### List pull requests ```http GET /api/v3/repos/{owner}/{repo}/pulls ``` The total is in the `X-Total-Count` header. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `open` (default), `closed` or `all`. | | `labels` | string | no | Only pull requests with this label. | | `creator` | string | no | Only pull requests opened by this login. | | `q` | string | no | Text search in titles. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Open a pull request ```http POST /api/v3/repos/{owner}/{repo}/pulls ``` A workflow's `GITHUB_TOKEN` can open one only when the repository allows workflows to create pull requests (`can_approve_pull_request_reviews`, see `PUT /repos/{owner}/{repo}/actions/permissions/workflow`); otherwise 403. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | yes | Title. | | `head` | string | yes | Branch with the changes (`owner:branch` also works). | | `base` | string | no | Branch to merge into (default: the default branch). | | `body` | string | no | Markdown description. | | `intent` | string | no | Diffinery: what the change is meant to do, shown to reviewers. | | `draft` | boolean | no | Open as a draft. | | `reviewers` | string[] | no | Logins to request reviews from. | | `auto_merge` | boolean | no | Diffinery: `true` turns on auto-merge as it opens, `false` keeps it off where your setting would turn it on. When asked for and it stayed off (for example, the base branch has no required checks), the answer's `auto_merge_skipped` says why. See /docs/collaboration/merge-on-green. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/pulls" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"...","head":"..."}' ``` ### Get a pull request ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number} ``` Includes `mergeable`, `mergeable_state`, `merge_blockers` (why it can't merge yet), `approvals`, `required_approvals` and `checks`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a pull request ```http PATCH /api/v3/repos/{owner}/{repo}/pulls/{number} ``` The author, or anyone with triage access. `base` isn't supported and is ignored. A merged pull request can't be reopened. A title or body change sends a `pull_request` `edited` event. `draft` is a Diffinery extension (GitHub only offers it through GraphQL): the author or anyone with write access can set it on an open pull request. It records a `ready_for_review` or `convert_to_draft` timeline event and sends the `pull_request` webhook with the action `ready_for_review` or `converted_to_draft`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | no | New title. | | `body` | string | no | New description. | | `state` | string | no | `open` or `closed`. | | `draft` | boolean | no | `false` marks it ready for review; `true` converts it back to a draft. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/pulls/42" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Enable auto-merge ```http PUT /api/v3/repos/{owner}/{repo}/pulls/{number}/auto-merge ``` Diffinery's REST form of GitHub's `enablePullRequestAutoMerge`. Diffinery merges the pull request by itself as soon as every rule of its base branch is met (required checks on the head, approvals from people, linear history), as the caller, never bypassing anything; into a branch with a merge queue it adds it to the queue instead. Returns `{ state, auto_merge, merge_commit_sha }`: `state` is `enabled` (waiting), `queued` or `merged` (it was ready). The pull request's `auto_merge` shows it until then. A push to the head branch by anyone else, or converting the pull request to a draft, turns it off. 422 for a draft, a closed or merged pull request, or one already in the merge queue. Agents and workflow tokens may use it on the default branch or a protected one only when its rule sets `allowAgentAutoMerge` (else 403) and has at least one required check (else 422); a workflow token needs `contents: write`. Audited as `pull_request.auto_merge_enabled`, and the merge as `pull_request.auto_merged`. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `merge_method` | string | no | `merge`, `squash` or `rebase` (default: the queue's method, `squash` where linear history is required, else `merge`). | | `commit_title` | string | no | Title of the merge or squash commit. | | `commit_message` | string | no | Body of the merge or squash commit. | | `delete_branch` | boolean | no | Delete the head branch once auto-merge lands the pull request, directly or through the queue (never the default branch or a protected one). 422 `delete_branch must be a boolean` otherwise. | | `sha` | string | no | Turn auto-merge on only if the head is still this commit (409 if it moved). | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/pulls/42/auto-merge" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Disable auto-merge ```http DELETE /api/v3/repos/{owner}/{repo}/pulls/{number}/auto-merge ``` GitHub's `disablePullRequestAutoMerge`. Anyone with write access may turn it off, whoever turned it on; a workflow token needs `contents: write`. 204 even when it was off, which records nothing. Otherwise it records an `auto_merge_disabled` event, sends a `pull_request` webhook with that action, and is audited as `pull_request.auto_merge_disabled`. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/pulls/42/auto-merge" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List review comments ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number}/comments ``` Line comments from reviews. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42/comments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a review comment ```http POST /api/v3/repos/{owner}/{repo}/pulls/{number}/comments ``` Adds one line comment as its own `COMMENTED` review, on the current head; `commit_id` is ignored and `position` is read as a line number, not a diff position. Multi-line comments aren't supported. With `in_reply_to`, replies to that comment instead. 422 on a closed pull request. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `body` | string | yes | Markdown body. | | `path` | string | no | File path (required unless replying). | | `line` | integer | no | Line number (required unless replying). | | `side` | string | no | `RIGHT` (default) or `LEFT`. | | `position` | integer | no | Used as `line` when `line` is missing. | | `commit_id` | string | no | Accepted and ignored. | | `in_reply_to` | integer | no | Review comment id to reply to; its path and line are used. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/pulls/42/comments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"..."}' ``` ### Reply to a review comment ```http POST /api/v3/repos/{owner}/{repo}/pulls/{number}/comments/{id}/replies ``` Adds a reply on the same line, as its own `COMMENTED` review. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `body` | string | yes | Markdown body. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/pulls/42/comments/1/replies" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"..."}' ``` ### Get a pull request's agent review ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review ``` The pull request's review task (each review is a run of it), the last reviewed head (`reviewed_sha`) and every finding: `id`, `path`, `line`, `side`, `severity` (`blocking` or `not_blocking`), `title`, `body`, `suggestion`, `state` (`open`, `fixed` or `dismissed`), `fixed_in` and the review `comment_id`. Needs write access. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42/diffinery/agent-review" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Ask for an agent review ```http POST /api/v3/repos/{owner}/{repo}/pulls/{number}/diffinery/agent-review ``` Reviews the pull request now, or once its current review ends, whatever the trigger setting. People with write access only (any of their tokens); agents can't ask. 422 when agent review is off; 429 past a daily limit (the repository's, or 20 reviews of one pull request in 24 hours). Audited as `review.request`. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/pulls/42/diffinery/agent-review" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List changed files ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number}/files ``` Each file with its status, additions, deletions and patch. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42/files" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Check mergeability ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number}/merge ``` Whether the pull request can merge now, and if not, the blockers. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42/merge" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Merge a pull request ```http PUT /api/v3/repos/{owner}/{repo}/pulls/{number}/merge ``` Fails with 405 and the blockers when protection rules aren't met, and with 405 when a merge commit is asked for on a branch that requires linear history (without `merge_method`, such a branch squashes). A branch that requires a merge queue answers 405 too: add the pull request to the queue instead. A repository admin (a person, not an agent or a workflow) can bypass, when the rule doesn't include administrators, there are no conflicts and the pull request isn't a draft. Each flag covers only what it names: `bypass_rules: true` skips unmet rules and linear history, and `bypass_merge_queue: true` skips only the queue. A bypass that skipped an unmet rule, linear history or the queue is audited as `pull_request.merge_bypassed`. Agents can't merge into the default branch or a protected branch themselves (unless the rule sets `agentsMayMerge`); where it sets `allowAgentAutoMerge` they turn on auto-merge (`PUT .../auto-merge`) instead. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `merge_method` | string | no | `merge`, `squash` or `rebase`. | | `commit_title` | string | no | Title of the merge or squash commit. | | `commit_message` | string | no | Body of the merge or squash commit. | | `sha` | string | no | Fail if the head has moved from this commit. | | `delete_branch` | boolean | no | Delete the head branch after merging. | | `bypass_merge_queue` | boolean | no | Merge directly even when a queue is required (admins). Doesn't skip unmet rules; send `bypass_rules` too for that. | | `bypass_rules` | boolean | no | Merge even though protection rules (including linear history) aren't met (admins). Without it, an admin's merge is blocked like anyone else's. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/pulls/42/merge" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List requested reviewers ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number}/requested_reviewers ``` Returns `{ users, teams }`; `teams` is always empty. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42/requested_reviewers" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Request reviewers ```http POST /api/v3/repos/{owner}/{repo}/pulls/{number}/requested_reviewers ``` The author, or anyone with triage access. Team reviewers aren't supported: a non-empty `team_reviewers` fails with 422. Sends the `pull_request` webhook (`review_requested`). Returns the pull request. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `reviewers` | string[] | no | Logins. | | `team_reviewers` | string[] | no | Must be empty or absent. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/pulls/42/requested_reviewers" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove requested reviewers ```http DELETE /api/v3/repos/{owner}/{repo}/pulls/{number}/requested_reviewers ``` The author, or anyone with triage access. Teams aren't supported. Returns the pull request. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `reviewers` | string[] | no | Logins to remove. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/pulls/42/requested_reviewers" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List reviews ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number}/reviews ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42/reviews" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Submit a review ```http POST /api/v3/repos/{owner}/{repo}/pulls/{number}/reviews ``` Only people's approvals count toward required approvals: agents' and workflows' don't. A workflow's `GITHUB_TOKEN` can approve or request changes only when the repository allows it (`can_approve_pull_request_reviews`); otherwise 403. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `event` | string | no | `APPROVE`, `REQUEST_CHANGES` or `COMMENT` (default). | | `body` | string | no | Review summary. | | `comments` | array | no | Line comments: `{ path, line, side: "LEFT" \| "RIGHT", body }`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/pulls/42/reviews" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a review ```http GET /api/v3/repos/{owner}/{repo}/pulls/{number}/reviews/{id} ``` `id` is the numeric id or Diffinery's own. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/42/reviews/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Dismiss a review ```http PUT /api/v3/repos/{owner}/{repo}/pulls/{number}/reviews/{id}/dismissals ``` Only approvals and change requests can be dismissed. Needs maintain access; agents and the pull request's author can't dismiss. `event` is ignored. Returns the review. **Access:** Maintain access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `message` | string | yes | Why the review is dismissed. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/pulls/42/reviews/1/dismissals" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"message":"..."}' ``` ### Update a pull request's branch ```http PUT /api/v3/repos/{owner}/{repo}/pulls/{number}/update-branch ``` Merges the base branch into the head branch right away, then answers 202 as GitHub does. 422 when the base has no new commits, on a conflict, or when `expected_head_sha` doesn't match. Branch protection and agent rules apply to the head branch. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `expected_head_sha` | string | no | Fail unless the head branch is at this commit. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/pulls/42/update-branch" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List review comments in a repository ```http GET /api/v3/repos/{owner}/{repo}/pulls/comments ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `since` | string | no | Only comments updated at or after this ISO timestamp. | | `sort` | string | no | `created` (default) or `updated`. | | `direction` | string | no | `asc` (default) or `desc`. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/comments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a review comment ```http GET /api/v3/repos/{owner}/{repo}/pulls/comments/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/comments/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Edit a review comment ```http PATCH /api/v3/repos/{owner}/{repo}/pulls/comments/{id} ``` Your own comments, or anyone's with maintain access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `body` | string | yes | New Markdown body. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/pulls/comments/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"..."}' ``` ### Delete a review comment ```http DELETE /api/v3/repos/{owner}/{repo}/pulls/comments/{id} ``` Your own comments, or anyone's with maintain access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/pulls/comments/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List reactions to a review comment ```http GET /api/v3/repos/{owner}/{repo}/pulls/comments/{id}/reactions ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | no | Only reactions with this content. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/pulls/comments/1/reactions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Add a reaction to a review comment ```http POST /api/v3/repos/{owner}/{repo}/pulls/comments/{id}/reactions ``` Needs a signed-in account, and triage access when the conversation is locked. Returns 201 for a new reaction, or 200 with the existing one when you had already reacted with that content. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | yes | `+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket` or `eyes`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/pulls/comments/1/reactions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"content":"..."}' ``` ### Delete a reaction from a review comment ```http DELETE /api/v3/repos/{owner}/{repo}/pulls/comments/{id}/reactions/{reaction} ``` Your own reactions, or anyone's with admin access. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | | `reaction` | string | yes | reaction | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/pulls/comments/1/reactions/reaction" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Merge queue | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/merge-queue/{branch}` | [Get a branch's merge queue](#get-a-branchs-merge-queue) | | PUT | `/repos/{owner}/{repo}/pulls/{number}/merge-queue` | [Add a pull request to the merge queue](#add-a-pull-request-to-the-merge-queue) | | DELETE | `/repos/{owner}/{repo}/pulls/{number}/merge-queue` | [Remove a pull request from the merge queue](#remove-a-pull-request-from-the-merge-queue) | ### Get a branch's merge queue ```http GET /api/v3/repos/{owner}/{repo}/merge-queue/{branch} ``` Diffinery only (GitHub exposes this through GraphQL). Settings and every entry with its merge group and checks. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `branch` | string | yes | Branch name (for protection rules: a branch name or pattern). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/merge-queue/main" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Add a pull request to the merge queue ```http PUT /api/v3/repos/{owner}/{repo}/pulls/{number}/merge-queue ``` Diffinery only. Returns 201 with the entry when queued. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `merge_method` | string | no | `merge`, `squash` or `rebase` (default: the queue's method). | | `delete_branch` | boolean | no | Delete the head branch once merged. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/pulls/42/merge-queue" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove a pull request from the merge queue ```http DELETE /api/v3/repos/{owner}/{repo}/pulls/{number}/merge-queue ``` **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/pulls/42/merge-queue" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Deployments | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/deployments` | [List deployments](#list-deployments) | | POST | `/repos/{owner}/{repo}/deployments` | [Create a deployment](#create-a-deployment) | | GET | `/repos/{owner}/{repo}/deployments/{id}` | [Get a deployment](#get-a-deployment) | | DELETE | `/repos/{owner}/{repo}/deployments/{id}` | [Delete a deployment](#delete-a-deployment) | | GET | `/repos/{owner}/{repo}/deployments/{id}/statuses` | [List deployment statuses](#list-deployment-statuses) | | POST | `/repos/{owner}/{repo}/deployments/{id}/statuses` | [Create a deployment status](#create-a-deployment-status) | ### List deployments ```http GET /api/v3/repos/{owner}/{repo}/deployments ``` Newest first. Each filter matches exactly. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `sha` | string | no | Full commit SHA. | | `ref` | string | no | Ref, as given when the deployment was created. | | `task` | string | no | Task name. | | `environment` | string | no | Environment name. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/deployments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a deployment ```http POST /api/v3/repos/{owner}/{repo}/deployments ``` Unless `required_contexts` is `[]`, every check and status on the commit (or each context you name) must have passed, else 409; checks and statuses reported by agents don't count toward the default set. Creates the environment if needed and sends the `deployment` webhook. Environment names are case-insensitive: an existing environment keeps its spelling, and any spelling of `production` is a production environment. `auto_merge` is ignored. See [Environments](https://diffinery.com/docs/actions/environments). **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | Branch, tag or commit to deploy. | | `task` | string | no | Task name (default `deploy`). | | `payload` | object | no | Extra data for the deployer (a string is kept as is). | | `environment` | string | no | Environment name (default `production`). | | `description` | string | no | Short description. | | `transient_environment` | boolean | no | Transient deployments aren't made inactive by later successful ones. | | `production_environment` | boolean | no | Default: true when the environment is `production`. | | `required_contexts` | string[] | no | Contexts that must have passed; `[]` skips the check. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/deployments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ref":"..."}' ``` ### Get a deployment ```http GET /api/v3/repos/{owner}/{repo}/deployments/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/deployments/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a deployment ```http DELETE /api/v3/repos/{owner}/{repo}/deployments/{id} ``` Refused with 422 while its latest status is `success`, unless it is the only deployment in its environment. Deletes its statuses too. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/deployments/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List deployment statuses ```http GET /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses ``` Newest first. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/deployments/1/statuses" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a deployment status ```http POST /api/v3/repos/{owner}/{repo}/deployments/{id}/statuses ``` A `success` status marks older successful deployments to the same environment inactive (except transient and production ones) unless `auto_inactive` is false. A different `environment` moves the deployment there. Sends the `deployment_status` webhook. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | yes | `error`, `failure`, `inactive`, `in_progress`, `queued`, `pending` or `success`. | | `log_url` | string | no | http(s) link to the deployment's output. | | `target_url` | string | no | Older name for `log_url`, used when `log_url` is missing. | | `environment_url` | string | no | http(s) link to the deployed environment. | | `description` | string | no | Short description (cut at 140 characters). | | `environment` | string | no | Move the deployment to this environment. | | `auto_inactive` | boolean | no | `false` keeps older deployments active (default `true`). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/deployments/1/statuses" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"..."}' ``` ## Environments | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/environments` | [List environments](#list-environments) | | GET | `/repos/{owner}/{repo}/environments/{env}` | [Get an environment](#get-an-environment) | | PUT | `/repos/{owner}/{repo}/environments/{env}` | [Create or update an environment](#create-or-update-an-environment) | | DELETE | `/repos/{owner}/{repo}/environments/{env}` | [Delete an environment](#delete-an-environment) | | GET | `/repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | [List deployment branch policies](#list-deployment-branch-policies) | | POST | `/repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | [Create a deployment branch policy](#create-a-deployment-branch-policy) | | DELETE | `/repos/{owner}/{repo}/environments/{env}/deployment-branch-policies/{id}` | [Delete a deployment branch policy](#delete-a-deployment-branch-policy) | | GET | `/repos/{owner}/{repo}/environments/{env}/secrets` | [List environment secrets](#list-environment-secrets) | | GET | `/repos/{owner}/{repo}/environments/{env}/secrets/{name}` | [Get an environment secret](#get-an-environment-secret) | | PUT | `/repos/{owner}/{repo}/environments/{env}/secrets/{name}` | [Set an environment secret](#set-an-environment-secret) | | DELETE | `/repos/{owner}/{repo}/environments/{env}/secrets/{name}` | [Delete an environment secret](#delete-an-environment-secret) | | GET | `/repos/{owner}/{repo}/environments/{env}/variables` | [List environment variables](#list-environment-variables) | | POST | `/repos/{owner}/{repo}/environments/{env}/variables` | [Create an environment variable](#create-an-environment-variable) | | GET | `/repos/{owner}/{repo}/environments/{env}/variables/{name}` | [Get an environment variable](#get-an-environment-variable) | | PATCH | `/repos/{owner}/{repo}/environments/{env}/variables/{name}` | [Update an environment variable](#update-an-environment-variable) | | DELETE | `/repos/{owner}/{repo}/environments/{env}/variables/{name}` | [Delete an environment variable](#delete-an-environment-variable) | ### List environments ```http GET /api/v3/repos/{owner}/{repo}/environments ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/environments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an environment ```http GET /api/v3/repos/{owner}/{repo}/environments/{env} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/environments/env" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create or update an environment ```http PUT /api/v3/repos/{owner}/{repo}/environments/{env} ``` People only; agents can't change environments. Required reviewers and a wait timer hold a job that names the environment before anything starts, until a reviewer approves and the timer has passed. Fields left out keep their values. Audited. See [Required reviewers and wait timers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers). **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `deployment_branch_policy` | object | no | `{ protected_branches, custom_branch_policies }` with exactly one true, or `null` to allow every ref. | | `wait_timer` | integer | no | Minutes a job waits before it deploys, 0 to 43200. | | `reviewers` | array | no | Up to 6 people with access, as `{type: "User", id}` (the numeric id the API shows) or `{type: "User", login}`. Never agents; Diffinery has no teams. `[]` or `null` removes them. | | `prevent_self_review` | boolean | no | Whoever started a run (or an earlier attempt, or owns the agent that did) can't approve it. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/environments/env" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete an environment ```http DELETE /api/v3/repos/{owner}/{repo}/environments/{env} ``` Deletes its secrets and variables too. People only. Audited. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/environments/env" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List deployment branch policies ```http GET /api/v3/repos/{owner}/{repo}/environments/{env}/deployment-branch-policies ``` The branch and tag patterns of an environment with `custom_branch_policies`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/environments/env/deployment-branch-policies" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a deployment branch policy ```http POST /api/v3/repos/{owner}/{repo}/environments/{env}/deployment-branch-policies ``` The environment must have `custom_branch_policies`. Tag patterns never match tags last pushed by an agent. People only. Audited. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Branch or tag name pattern, like `release/*`. | | `type` | string | no | `branch` (default) or `tag`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/environments/env/deployment-branch-policies" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"..."}' ``` ### Delete a deployment branch policy ```http DELETE /api/v3/repos/{owner}/{repo}/environments/{env}/deployment-branch-policies/{id} ``` People only. Audited. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/environments/env/deployment-branch-policies/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List environment secrets ```http GET /api/v3/repos/{owner}/{repo}/environments/{env}/secrets ``` Names and dates only; values are never returned. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/environments/env/secrets" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an environment secret ```http GET /api/v3/repos/{owner}/{repo}/environments/{env}/secrets/{name} ``` Name and dates only. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/environments/env/secrets/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set an environment secret ```http PUT /api/v3/repos/{owner}/{repo}/environments/{env}/secrets/{name} ``` People only. Names are upper-cased, and `GITHUB_` names are refused. The value is encrypted at rest; GitHub's sealed-box `encrypted_value` isn't supported. The first secret on an environment without a branch policy limits it to protected branches. Returns 201 for a new secret and 204 for a replaced one. Audited. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `value` | string | yes | Secret value. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/environments/env/secrets/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"value":"..."}' ``` ### Delete an environment secret ```http DELETE /api/v3/repos/{owner}/{repo}/environments/{env}/secrets/{name} ``` People only. Audited. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/environments/env/secrets/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List environment variables ```http GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables ``` **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 10, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/environments/env/variables" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create an environment variable ```http POST /api/v3/repos/{owner}/{repo}/environments/{env}/variables ``` 409 when it exists. Names are upper-cased, and `GITHUB_` names are refused. People only. Audited as `environment.variable.set`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Variable name. | | `value` | string | yes | Variable value. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/environments/env/variables" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"...","value":"..."}' ``` ### Get an environment variable ```http GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name} ``` **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/environments/env/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update an environment variable ```http PATCH /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name} ``` People only. Renaming onto a name that exists fails with 409. Audited as `environment.variable.set`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | New name (renames the variable). | | `value` | string | no | New value. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/environments/env/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete an environment variable ```http DELETE /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name} ``` People only. Audited as `environment.variable.delete`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `env` | string | yes | env | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/environments/env/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Actions secrets and variables | Method | Path | Summary | | --- | --- | --- | | GET | `/orgs/{org}/actions/secrets` | [List organization secrets](#list-organization-secrets) | | GET | `/orgs/{org}/actions/secrets/{name}` | [Get an organization secret](#get-an-organization-secret) | | PUT | `/orgs/{org}/actions/secrets/{name}` | [Set an organization secret](#set-an-organization-secret) | | DELETE | `/orgs/{org}/actions/secrets/{name}` | [Delete an organization secret](#delete-an-organization-secret) | | GET | `/orgs/{org}/actions/secrets/{name}/repositories` | [List a secret's selected repositories](#list-a-secrets-selected-repositories) | | PUT | `/orgs/{org}/actions/secrets/{name}/repositories` | [Replace a secret's selected repositories](#replace-a-secrets-selected-repositories) | | PUT | `/orgs/{org}/actions/secrets/{name}/repositories/{repoId}` | [Add a repository to a secret's selection](#add-a-repository-to-a-secrets-selection) | | DELETE | `/orgs/{org}/actions/secrets/{name}/repositories/{repoId}` | [Remove a repository from a secret's selection](#remove-a-repository-from-a-secrets-selection) | | GET | `/orgs/{org}/actions/variables` | [List organization variables](#list-organization-variables) | | POST | `/orgs/{org}/actions/variables` | [Create an organization variable](#create-an-organization-variable) | | GET | `/orgs/{org}/actions/variables/{name}` | [Get an organization variable](#get-an-organization-variable) | | PATCH | `/orgs/{org}/actions/variables/{name}` | [Update an organization variable](#update-an-organization-variable) | | DELETE | `/orgs/{org}/actions/variables/{name}` | [Delete an organization variable](#delete-an-organization-variable) | | GET | `/repos/{owner}/{repo}/actions/secrets` | [List repository secrets](#list-repository-secrets) | | PUT | `/repos/{owner}/{repo}/actions/secrets/{name}` | [Set a repository secret](#set-a-repository-secret) | | DELETE | `/repos/{owner}/{repo}/actions/secrets/{name}` | [Delete a repository secret](#delete-a-repository-secret) | | GET | `/repos/{owner}/{repo}/actions/variables` | [List repository variables](#list-repository-variables) | | POST | `/repos/{owner}/{repo}/actions/variables` | [Create a repository variable](#create-a-repository-variable) | | GET | `/repos/{owner}/{repo}/actions/variables/{name}` | [Get a repository variable](#get-a-repository-variable) | | PUT | `/repos/{owner}/{repo}/actions/variables/{name}` | [Set a repository variable](#set-a-repository-variable) | | PATCH | `/repos/{owner}/{repo}/actions/variables/{name}` | [Update a repository variable](#update-a-repository-variable) | | DELETE | `/repos/{owner}/{repo}/actions/variables/{name}` | [Delete a repository variable](#delete-a-repository-variable) | ### List organization secrets ```http GET /api/v3/orgs/{org}/actions/secrets ``` Names, update times and `visibility` (`all`, `private` or `selected`), never values. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/actions/secrets" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an organization secret ```http GET /api/v3/orgs/{org}/actions/secrets/{name} ``` Its name, update time and `visibility`, never the value. 404 when there is no such secret. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/actions/secrets/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set an organization secret ```http PUT /api/v3/orgs/{org}/actions/secrets/{name} ``` Creates or updates the secret. `visibility` says which of the organization's repositories get it: `all` (the default for a new secret), `private` (private and internal ones) or `selected` (those in `selected_repository_ids`). An update without them keeps the secret's visibility and selection. Names are upper-cased and must match `[A-Z_][A-Z0-9_]*`. People only. Audited as `org.secret.set`. See /docs/actions/secrets-and-variables#organization-secrets. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `value` | string | yes | Secret value. | | `visibility` | string | no | `all`, `private` or `selected` (422 otherwise). | | `selected_repository_ids` | integer[] | no | With `selected`: the repositories' numeric ids. An id that isn't one of the organization's repositories gets 422. | ```sh curl -X PUT "https://diffinery.com/api/v3/orgs/acme/actions/secrets/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"value":"..."}' ``` ### Delete an organization secret ```http DELETE /api/v3/orgs/{org}/actions/secrets/{name} ``` People only. 404 when the organization has no secret with this name. Audited as `org.secret.delete`. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/actions/secrets/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List a secret's selected repositories ```http GET /api/v3/orgs/{org}/actions/secrets/{name}/repositories ``` `{ total_count, repositories }` for a secret with `visibility: selected`; 409 `the secret's visibility isn't selected` otherwise. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/actions/secrets/website/repositories" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Replace a secret's selected repositories ```http PUT /api/v3/orgs/{org}/actions/secrets/{name}/repositories ``` Only for a secret with `visibility: selected` (409 otherwise). A repository that leaves the selection stops getting the secret from its next job. Audited as `org.secret.repositories`. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `selected_repository_ids` | integer[] | yes | Every repository that should get the secret. | ```sh curl -X PUT "https://diffinery.com/api/v3/orgs/acme/actions/secrets/website/repositories" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"selected_repository_ids":"..."}' ``` ### Add a repository to a secret's selection ```http PUT /api/v3/orgs/{org}/actions/secrets/{name}/repositories/{repoId} ``` Only for a secret with `visibility: selected` (409 otherwise). `repoId` is the repository's numeric id. Audited as `org.secret.repository.add`. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | | `repoId` | string | yes | repoId | ```sh curl -X PUT "https://diffinery.com/api/v3/orgs/acme/actions/secrets/website/repositories/repoId" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove a repository from a secret's selection ```http DELETE /api/v3/orgs/{org}/actions/secrets/{name}/repositories/{repoId} ``` Only for a secret with `visibility: selected` (409 otherwise). The repository stops getting the secret from its next job. Audited as `org.secret.repository.remove`. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | | `repoId` | string | yes | repoId | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/actions/secrets/website/repositories/repoId" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List organization variables ```http GET /api/v3/orgs/{org}/actions/variables ``` Actions variables shared by every repository of the organization: workflows read them as `vars.NAME`, and a repository variable with the same name wins. Returns `total_count` and every variable (no paging) with `name`, `value`, `visibility` (always `all`), `created_at` and `updated_at`. Organization owners and admins who are people (agents get 403 `organization admins only`); anyone outside the organization gets 404. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/actions/variables" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create an organization variable ```http POST /api/v3/orgs/{org}/actions/variables ``` Creates the variable, or replaces the value of one with that name (GitHub answers 409 instead). Names are stored upper-cased; they must start with a letter or `_`, contain only letters, digits and `_`, and not start with `GITHUB_` (422 `invalid variable name`). Values are strings of at most 49,152 characters (422 `value must be a string of at most 48 KB`). GitHub's `visibility` and `selected_repository_ids` are ignored: every repository of the organization gets the variable. People only. Audited as `org.variable_set`. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Variable name. | | `value` | string | yes | Variable value, at most 49,152 characters. | ```sh curl -X POST "https://diffinery.com/api/v3/orgs/acme/actions/variables" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"...","value":"..."}' ``` ### Get an organization variable ```http GET /api/v3/orgs/{org}/actions/variables/{name} ``` `name` is matched in upper case. 404 `variable not found`. Organization owners and admins who are people. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/actions/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update an organization variable ```http PATCH /api/v3/orgs/{org}/actions/variables/{name} ``` Sets the value, creating the variable if it doesn't exist. Renaming isn't supported: a `name` in the body is ignored. Same name and value rules as creating. People only. Audited as `org.variable_set`. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `value` | string | yes | New value, at most 49,152 characters. | ```sh curl -X PATCH "https://diffinery.com/api/v3/orgs/acme/actions/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"value":"..."}' ``` ### Delete an organization variable ```http DELETE /api/v3/orgs/{org}/actions/variables/{name} ``` 404 when the organization has no variable with this name, and nothing is audited. People only. Audited as `org.variable_delete`. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/actions/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List repository secrets ```http GET /api/v3/repos/{owner}/{repo}/actions/secrets ``` Names and update times only; values are never returned. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/secrets" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set a repository secret ```http PUT /api/v3/repos/{owner}/{repo}/actions/secrets/{name} ``` People only. The value is encrypted at rest. GitHub's sealed-box `encrypted_value` isn't supported; send `value` over HTTPS. Audited as `repo.secret.set`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `value` | string | yes | Secret value. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/actions/secrets/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"value":"..."}' ``` ### Delete a repository secret ```http DELETE /api/v3/repos/{owner}/{repo}/actions/secrets/{name} ``` People only. Audited as `repo.secret.delete`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/actions/secrets/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List repository variables ```http GET /api/v3/repos/{owner}/{repo}/actions/variables ``` The repository's own variables. Workflows in an organization's repositories also see the organization's variables (`GET /orgs/{org}/actions/variables`), and a repository variable with the same name wins. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/variables" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a repository variable ```http POST /api/v3/repos/{owner}/{repo}/actions/variables ``` 409 when the repository already has a variable with this name. An organization variable of the same name doesn't count, and the repository's wins in workflows. Names are stored upper-cased. People only. Audited as `repo.variable.set`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Variable name. | | `value` | string | yes | Variable value. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/variables" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"...","value":"..."}' ``` ### Get a repository variable ```http GET /api/v3/repos/{owner}/{repo}/actions/variables/{name} ``` 404 for a name only the organization has. `created_at` and `updated_at` are always null. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set a repository variable ```http PUT /api/v3/repos/{owner}/{repo}/actions/variables/{name} ``` Diffinery's form: creates the variable or replaces its value. GitHub clients use `POST` and `PATCH`. Names are stored upper-cased. People only. Audited as `repo.variable.set`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `value` | string | yes | Variable value. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/actions/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"value":"..."}' ``` ### Update a repository variable ```http PATCH /api/v3/repos/{owner}/{repo}/actions/variables/{name} ``` 404 when the repository has no variable with this name; organization variables aren't changed here. People only. Renaming onto a name that exists fails with 409. Audited as `repo.variable.set`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | New name (renames the variable). | | `value` | string | no | New value. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/actions/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a repository variable ```http DELETE /api/v3/repos/{owner}/{repo}/actions/variables/{name} ``` People only. Names are matched without regard to letter case. Audited as `repo.variable.delete`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/actions/variables/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Actions artifacts and caches | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/actions/artifacts` | [List artifacts](#list-artifacts) | | GET | `/repos/{owner}/{repo}/actions/artifacts/{id}` | [Get an artifact](#get-an-artifact) | | DELETE | `/repos/{owner}/{repo}/actions/artifacts/{id}` | [Delete an artifact](#delete-an-artifact) | | GET | `/repos/{owner}/{repo}/actions/artifacts/{id}/{format}` | [Download an artifact](#download-an-artifact) | | GET | `/repos/{owner}/{repo}/actions/cache/usage` | [Get cache usage](#get-cache-usage) | | GET | `/repos/{owner}/{repo}/actions/caches` | [List caches](#list-caches) | | DELETE | `/repos/{owner}/{repo}/actions/caches` | [Delete caches by key](#delete-caches-by-key) | | DELETE | `/repos/{owner}/{repo}/actions/caches/{id}` | [Delete a cache](#delete-a-cache) | | GET | `/repos/{owner}/{repo}/actions/runs/{runId}/artifacts` | [List a run's artifacts](#list-a-runs-artifacts) | ### List artifacts ```http GET /api/v3/repos/{owner}/{repo}/actions/artifacts ``` Every artifact in the repository, newest first. Each has a numeric `id`, `name`, `size_in_bytes`, `digest` (`sha256:...` when the uploader sent one), `created_at`, `expires_at`, `expired`, an `archive_download_url`, and `workflow_run` (its `id` is the run number). An artifact past its retention stays listed with `expired: true` for 90 more days; its content is already deleted. Returns `{ total_count, artifacts }`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Only artifacts with exactly this name. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an artifact ```http GET /api/v3/repos/{owner}/{repo}/actions/artifacts/{id} ``` `id` is the artifact's numeric id. Expired artifacts are returned too, with `expired: true`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete an artifact ```http DELETE /api/v3/repos/{owner}/{repo}/actions/artifacts/{id} ``` Deletes the artifact and its content, expired or not; it no longer counts toward the repository's artifact quota. 404 when there is no artifact with that id. Audited as `actions.artifact.delete`. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Download an artifact ```http GET /api/v3/repos/{owner}/{repo}/actions/artifacts/{id}/{format} ``` `format` must be `zip` (anything else is 404). Redirects with 302 to a signed URL that works for 10 minutes and needs no token; follow redirects (`curl -L`). The content is a zip, or the file itself for an artifact uploaded as a single unzipped file. 410 `Artifact has expired` once the artifact has expired. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. **Success:** `302`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | | `format` | string | yes | Archive format: `zip`. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1/zip" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get cache usage ```http GET /api/v3/repos/{owner}/{repo}/actions/cache/usage ``` `full_name`, `active_caches_size_in_bytes` and `active_caches_count` of the saved entries. A repository keeps up to 10 GB of caches; see [Limits](https://diffinery.com/docs/reference/limits#caches-and-artifacts). **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/cache/usage" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List caches ```http GET /api/v3/repos/{owner}/{repo}/actions/caches ``` Saved cache entries; uploads in progress aren't listed. Each has a numeric `id`, `key`, `version`, `ref` (the ref it was saved for, like `refs/heads/main`), `size_in_bytes`, `created_at` and `last_accessed_at`. Returns `{ total_count, actions_caches }`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | no | Only entries whose key starts with this. | | `ref` | string | no | Only entries saved for this full ref, like `refs/heads/main`. | | `sort` | string | no | `last_accessed_at` (default), `created_at` or `size_in_bytes`. | | `direction` | string | no | `desc` (default) or `asc`. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/caches" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete caches by key ```http DELETE /api/v3/repos/{owner}/{repo}/actions/caches ``` Deletes every entry whose key is exactly `key` (all versions), or only those saved for `ref`. Returns the deleted entries as `{ total_count, actions_caches }`. 422 `key is required` without `key`, 404 when nothing matched. Audited as `actions.cache.delete`. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | yes | The exact cache key. | | `ref` | string | no | Only entries saved for this full ref, like `refs/heads/main`. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/actions/caches" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a cache ```http DELETE /api/v3/repos/{owner}/{repo}/actions/caches/{id} ``` By the entry's numeric id. 404 when there is none. Audited as `actions.cache.delete`. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/actions/caches/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List a run's artifacts ```http GET /api/v3/repos/{owner}/{repo}/actions/runs/{runId}/artifacts ``` Newest first, in the same shape as `GET /repos/{owner}/{repo}/actions/artifacts`. `runId` is the run's id, or its number (`github.run_id` in workflows), which covers every attempt of the run. Expired artifacts are listed with `expired: true`; their content is gone. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `runId` | string | yes | Workflow run id, or the run number (`github.run_id`). | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Only artifacts with exactly this name. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runs/42/artifacts" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Actions | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/actions/jobs/{id}` | [Get a job](#get-a-job) | | GET | `/repos/{owner}/{repo}/actions/jobs/{id}/logs` | [Get a job's logs](#get-a-jobs-logs) | | POST | `/repos/{owner}/{repo}/actions/jobs/{id}/rerun` | [Re-run a job](#re-run-a-job) | | GET | `/repos/{owner}/{repo}/actions/permissions/workflow` | [Get workflow token permissions](#get-workflow-token-permissions) | | PUT | `/repos/{owner}/{repo}/actions/permissions/workflow` | [Set workflow token permissions](#set-workflow-token-permissions) | | GET | `/repos/{owner}/{repo}/actions/runners` | [List self-hosted runners](#list-self-hosted-runners) | | DELETE | `/repos/{owner}/{repo}/actions/runners/{id}` | [Remove a self-hosted runner](#remove-a-self-hosted-runner) | | POST | `/repos/{owner}/{repo}/actions/runners/claim` | [Claim a job for a self-hosted runner](#claim-a-job-for-a-self-hosted-runner) | | POST | `/repos/{owner}/{repo}/actions/runners/register` | [Register a self-hosted runner](#register-a-self-hosted-runner) | | POST | `/repos/{owner}/{repo}/actions/runners/registration-token` | [Create a runner registration token](#create-a-runner-registration-token) | | GET | `/repos/{owner}/{repo}/actions/runs` | [List workflow runs](#list-workflow-runs) | | GET | `/repos/{owner}/{repo}/actions/runs/{id}` | [Get a workflow run](#get-a-workflow-run) | | GET | `/repos/{owner}/{repo}/actions/runs/{id}/approvals` | [List a run's deployment reviews](#list-a-runs-deployment-reviews) | | GET | `/repos/{owner}/{repo}/actions/runs/{id}/attempts/{attempt}` | [Get a run attempt](#get-a-run-attempt) | | POST | `/repos/{owner}/{repo}/actions/runs/{id}/cancel` | [Cancel a run](#cancel-a-run) | | GET | `/repos/{owner}/{repo}/actions/runs/{id}/jobs` | [List a run's jobs](#list-a-runs-jobs) | | GET | `/repos/{owner}/{repo}/actions/runs/{id}/logs` | [Download a run's logs](#download-a-runs-logs) | | DELETE | `/repos/{owner}/{repo}/actions/runs/{id}/logs` | [Delete a run's logs](#delete-a-runs-logs) | | GET | `/repos/{owner}/{repo}/actions/runs/{id}/pending_deployments` | [List the environments a run waits on](#list-the-environments-a-run-waits-on) | | POST | `/repos/{owner}/{repo}/actions/runs/{id}/pending_deployments` | [Approve or reject a run's pending deployments](#approve-or-reject-a-runs-pending-deployments) | | POST | `/repos/{owner}/{repo}/actions/runs/{id}/rerun` | [Re-run a workflow run](#re-run-a-workflow-run) | | POST | `/repos/{owner}/{repo}/actions/runs/{id}/rerun-failed-jobs` | [Re-run a run's failed jobs](#re-run-a-runs-failed-jobs) | | GET | `/repos/{owner}/{repo}/actions/workflows` | [List workflows](#list-workflows) | | GET | `/repos/{owner}/{repo}/actions/workflows/{workflow}` | [Get a workflow](#get-a-workflow) | | POST | `/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches` | [Run a workflow (workflow_dispatch)](#run-a-workflow-workflow_dispatch) | | GET | `/repos/{owner}/{repo}/actions/workflows/{workflow}/runs` | [List a workflow's runs](#list-a-workflows-runs) | ### Get a job ```http GET /api/v3/repos/{owner}/{repo}/actions/jobs/{id} ``` The job with its steps. GitHub's job fields, plus Diffinery's own: `runner` (the hosted container's `image`, `location` and `instance`; `null` on a self-hosted runner) and `placementTries` (how many times a hosted job went back in line before a runner took it: no free container, or a runner on a replaced image) and `restarts` (how many times it started over because the platform replaced its runner before its steps ran). See /docs/actions/runners. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/jobs/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a job's logs ```http GET /api/v3/repos/{owner}/{repo}/actions/jobs/{id}/logs ``` Plain text, one timestamped line each, or JSON with `format=json`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `format` | string | no | `json` for an array of `{ t, text }` lines. | | `after` | integer | no | Only lines after this sequence number (for following a live log). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/jobs/1/logs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Re-run a job ```http POST /api/v3/repos/{owner}/{repo}/actions/jobs/{id}/rerun ``` Starts a new attempt of the job's run that runs this job (this matrix leg) and every job that depends on it; the other jobs are kept from the earlier attempt as they were. Answers `{ run_id }`, the new attempt's id. 409 when the run hasn't finished. A workflow's `GITHUB_TOKEN` can't re-run workflows (403). **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `enable_debug_logging` | boolean | no | Accepted and ignored. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/jobs/1/rerun" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get workflow token permissions ```http GET /api/v3/repos/{owner}/{repo}/actions/permissions/workflow ``` The defaults for the `GITHUB_TOKEN` of the repository's workflows (GitHub's "Workflow permissions" setting). `default_workflow_permissions` is what a job without `permissions:` gets: `read` (contents and packages read, nothing else) or `write` (read and write on every scope except `id-token`). `can_approve_pull_request_reviews` says whether job tokens may open pull requests and approve or request changes. `agent_runs_use_secrets` (a Diffinery addition, `false` by default) says whether runs of code an agent wrote get the repository's and organization's secrets. New repositories start with `read` and `false`; repositories created before the setting existed have `write` and `false`. See [Workflow permissions](https://diffinery.com/docs/actions/workflow-permissions). **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/permissions/workflow" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set workflow token permissions ```http PUT /api/v3/repos/{owner}/{repo}/actions/permissions/workflow ``` Send only what changes. People only: agents get 403 `only people can change what workflows are allowed to do`. Applies to jobs that start afterwards. Each change is audited as `repo.actions_workflow_settings`. Even when allowed, a workflow's approval never counts toward required approvals. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `default_workflow_permissions` | string | no | `read` or `write`. | | `can_approve_pull_request_reviews` | boolean | no | Allow workflows' `GITHUB_TOKEN` to open pull requests and to approve or request changes. | | `agent_runs_use_secrets` | boolean | no | Give runs of code an agent wrote (an agent triggered the run, pushed its branch last, or opened or pushed to its pull request) the repository's and organization's secrets. Default `false`. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/actions/permissions/workflow" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List self-hosted runners ```http GET /api/v3/repos/{owner}/{repo}/actions/runners ``` GitHub's shape: `{ total_count, runners }`, each with `id`, `name`, `os`, `status` (`online` when it polled in the last 90 seconds, otherwise `offline`), `busy`, `ephemeral` and `labels`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runners" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove a self-hosted runner ```http DELETE /api/v3/repos/{owner}/{repo}/actions/runners/{id} ``` Its runner token stops working at once. A job it is running carries on with its own job token. Audited as `runner.remove`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/actions/runners/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Claim a job for a self-hosted runner ```http POST /api/v3/repos/{owner}/{repo}/actions/runners/claim ``` Used by `diffinery runner`. Send the runner's `drn_` token and an empty body: the runner offers the name and labels it registered with. Only registered runners claim: a personal token, even a repository admin's, gets 403 `personal tokens can't claim jobs; register this runner: ...`. Returns 204 when no queued job matches the labels, otherwise the job with a fresh job token. See /docs/actions/runners. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/runners/claim" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Register a self-hosted runner ```http POST /api/v3/repos/{owner}/{repo}/actions/runners/register ``` Trades a registration token for the runner's own `drn_` token, shown once, which can claim this repository's jobs and nothing else. No `Authorization` header: the registration token is the credential. A wrong or expired token, or a repository that doesn't exist, gets 401 `invalid or expired registration token`. At most 100 runners per repository and 20 labels per runner (429 past the runner limit). Returns `{ id, name, labels, ephemeral, token }`. Audited as `runner.register`, with the admin who created the registration token as the actor. **Access:** No authentication needed. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `registration_token` | string | yes | A `drg_` token from `POST .../actions/runners/registration-token`. | | `name` | string | no | Runner name (default `runner`). | | `labels` | string[] | no | Labels the runner offers, matched against `runs-on`. | | `ephemeral` | boolean | no | `true` for a single job: the runner's token stops working once it takes a job, and the runner is removed when the job finishes. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/runners/register" \ -H "Content-Type: application/json" \ -d '{"registration_token":"..."}' ``` ### Create a runner registration token ```http POST /api/v3/repos/{owner}/{repo}/actions/runners/registration-token ``` As on GitHub: a `drg_` token that registers any number of self-hosted runners with this repository for an hour. Returns `{ token, expires_at }`. People only: agents, job tokens and sandbox tokens get 403 `only repository admins can add self-hosted runners`. Audited as `runner.registration_token`. See /docs/actions/runners#manage-runners. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/runners/registration-token" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List workflow runs ```http GET /api/v3/repos/{owner}/{repo}/actions/runs ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `workflow` | string | no | Workflow file path. | | `branch` | string | no | Head branch. | | `status` | string | no | Run status or conclusion. | | `event` | string | no | Triggering event. | | `head_sha` | string | no | Head commit. | | `per_page` | integer | no | Results per page (default 25, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a workflow run ```http GET /api/v3/repos/{owner}/{repo}/actions/runs/{id} ``` The run with its jobs. `id` is the run's id, or its number (`github.run_id` in workflows), which picks the latest attempt. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List a run's deployment reviews ```http GET /api/v3/repos/{owner}/{repo}/actions/runs/{id}/approvals ``` Each reviewer's decision, with the environment and comment. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/approvals" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a run attempt ```http GET /api/v3/repos/{owner}/{repo}/actions/runs/{id}/attempts/{attempt} ``` Re-runs are stored as separate runs with the same run number; this returns attempt `attempt` of the run's number. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | | `attempt` | string | yes | attempt | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/attempts/attempt" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Cancel a run ```http POST /api/v3/repos/{owner}/{repo}/actions/runs/{id}/cancel ``` **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/cancel" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List a run's jobs ```http GET /api/v3/repos/{owner}/{repo}/actions/runs/{id}/jobs ``` Each job as `GET .../actions/jobs/{id}` returns it, with `runner`, `placementTries` and `restarts`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/jobs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Download a run's logs ```http GET /api/v3/repos/{owner}/{repo}/actions/runs/{id}/logs ``` A zip with one text file per job. Jobs past 24 MB of logs in total are left out. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/logs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a run's logs ```http DELETE /api/v3/repos/{owner}/{repo}/actions/runs/{id}/logs ``` Deletes every job's log and step summary for good; the run, its jobs, checks and artifacts stay. Only for a finished run (409 otherwise). An agent can delete only the logs of runs that didn't run on the default branch or a protected branch, and a workflow's `GITHUB_TOKEN` can't delete logs (403). Audited as `repo.actions.logs.delete`. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/logs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List the environments a run waits on ```http GET /api/v3/repos/{owner}/{repo}/actions/runs/{id}/pending_deployments ``` Environments whose required reviewers or wait timer hold the run's jobs, with `current_user_can_approve` for the caller. See [Required reviewers and wait timers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers). **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/pending_deployments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Approve or reject a run's pending deployments ```http POST /api/v3/repos/{owner}/{repo}/actions/runs/{id}/pending_deployments ``` Only a listed reviewer, signed in or with a `write` token: never an agent, a workflow's token or a sandbox token (403). With `prevent_self_review`, not whoever started the run (403). One decision per environment and run counts; 422 when nothing waits. Approved jobs start once their wait timer has passed; rejected ones fail. Returns the deployments of the jobs that were waiting. Audited as `deployment.approve` or `deployment.reject`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `environment_ids` | array | yes | Ids of the environments to decide. | | `state` | string | yes | `approved` or `rejected`. | | `comment` | string | no | Shown with the decision and in the audit log. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/pending_deployments" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"environment_ids":[],"state":"..."}' ``` ### Re-run a workflow run ```http POST /api/v3/repos/{owner}/{repo}/actions/runs/{id}/rerun ``` Starts a new attempt that runs every job again. Answers `{ run_id }`, the new attempt's id. A workflow's `GITHUB_TOKEN` can't re-run workflows (403). **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `enable_debug_logging` | boolean | no | Accepted and ignored. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/rerun" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Re-run a run's failed jobs ```http POST /api/v3/repos/{owner}/{repo}/actions/runs/{id}/rerun-failed-jobs ``` Starts a new attempt that runs the jobs and matrix legs that failed, were cancelled or timed out, and every job that depends on them. The others are kept from the earlier attempt as they were, with their logs and artifacts. Answers `{ run_id }`, the new attempt's id. 409 `this run has no failed jobs to re-run`, or when the run hasn't finished. A workflow's `GITHUB_TOKEN` can't re-run workflows (403). **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `enable_debug_logging` | boolean | no | Accepted and ignored. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/runs/1/rerun-failed-jobs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List workflows ```http GET /api/v3/repos/{owner}/{repo}/actions/workflows ``` Each workflow file with its triggers, and parse errors for invalid ones (`state: invalid`). **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/workflows" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a workflow ```http GET /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow} ``` `workflow` is the file path or file name; the response's `id` is the path. `created_at` and `updated_at` are the time of the request, not of the file. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `workflow` | string | yes | Workflow file path (URL-encoded) or file name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/workflows/ci.yml" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Run a workflow (workflow_dispatch) ```http POST /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches ``` `workflow` is the file path or name, URL-encoded, like `.github%2Fworkflows%2Fdeploy.yml` or `deploy.yml`. Returns the new run's id. Fails with 429 when the repository already has 500 unfinished workflow runs. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `workflow` | string | yes | Workflow file path (URL-encoded) or file name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | yes | Branch or tag to run on. | | `inputs` | object | no | Values for `on.workflow_dispatch.inputs`. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/actions/workflows/ci.yml/dispatches" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ref":"..."}' ``` ### List a workflow's runs ```http GET /api/v3/repos/{owner}/{repo}/actions/workflows/{workflow}/runs ``` No `Link` header; page with `total_count`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `workflow` | string | yes | Workflow file path (URL-encoded) or file name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `branch` | string | no | Head branch. | | `status` | string | no | Run status or conclusion. | | `event` | string | no | Triggering event. | | `head_sha` | string | no | Head commit. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/actions/workflows/ci.yml/runs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Webhooks | Method | Path | Summary | | --- | --- | --- | | GET | `/repos/{owner}/{repo}/hooks` | [List webhooks](#list-webhooks) | | POST | `/repos/{owner}/{repo}/hooks` | [Create a webhook](#create-a-webhook) | | DELETE | `/repos/{owner}/{repo}/hooks/{id}` | [Delete a webhook](#delete-a-webhook) | ### List webhooks ```http GET /api/v3/repos/{owner}/{repo}/hooks ``` Secrets are never returned. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/hooks" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a webhook ```http POST /api/v3/repos/{owner}/{repo}/hooks ``` `config.url` must be `https://` to a public host: `localhost`, single-label, `.local` and `.internal` names, private, loopback and link-local addresses get 422 `webhook URL must be a public host`, and Diffinery's own host 422 `webhook URL can't point at Diffinery itself`. At most 20 webhooks per repository (422). Audited as `repo.webhook.create`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `config` | object | yes | `{ url, secret }`. The secret signs each delivery (`X-Hub-Signature-256`). | | `events` | string[] | no | Event names, or `["*"]` (default) for all. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/hooks" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"config":{}}' ``` ### Delete a webhook ```http DELETE /api/v3/repos/{owner}/{repo}/hooks/{id} ``` Audited as `repo.webhook.delete`. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/hooks/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Packages | Method | Path | Summary | | --- | --- | --- | | GET | `/orgs/{owner}/packages` | [List an organization's packages](#list-an-organizations-packages) | | GET | `/orgs/{owner}/packages/{type}/{name}` | [Get one of an organization's packages](#get-one-of-an-organizations-packages) | | PATCH | `/orgs/{owner}/packages/{type}/{name}` | [Update one of an organization's packages](#update-one-of-an-organizations-packages) | | DELETE | `/orgs/{owner}/packages/{type}/{name}` | [Delete one of an organization's packages](#delete-one-of-an-organizations-packages) | | GET | `/orgs/{owner}/packages/{type}/{name}/versions` | [List package versions](#list-package-versions) | | GET | `/orgs/{owner}/packages/{type}/{name}/versions/{id}` | [Get a package version](#get-a-package-version) | | DELETE | `/orgs/{owner}/packages/{type}/{name}/versions/{id}` | [Delete a package version](#delete-a-package-version) | | GET | `/repos/{owner}/{repo}/packages` | [List a repository's packages](#list-a-repositorys-packages) | | GET | `/user/packages` | [List your packages](#list-your-packages) | | GET | `/user/packages/{type}/{name}` | [Get one of your packages](#get-one-of-your-packages) | | PATCH | `/user/packages/{type}/{name}` | [Update one of your packages](#update-one-of-your-packages) | | DELETE | `/user/packages/{type}/{name}` | [Delete one of your packages](#delete-one-of-your-packages) | | GET | `/user/packages/{type}/{name}/versions` | [List package versions](#list-package-versions-get-userpackagestypenameversions) | | GET | `/user/packages/{type}/{name}/versions/{id}` | [Get a package version](#get-a-package-version-get-userpackagestypenameversionsid) | | DELETE | `/user/packages/{type}/{name}/versions/{id}` | [Delete a package version](#delete-a-package-version-delete-userpackagestypenameversionsid) | | GET | `/users/{owner}/packages` | [List a user's packages](#list-a-users-packages) | | GET | `/users/{owner}/packages/{type}/{name}` | [Get one of a user's packages](#get-one-of-a-users-packages) | | PATCH | `/users/{owner}/packages/{type}/{name}` | [Update one of a user's packages](#update-one-of-a-users-packages) | | DELETE | `/users/{owner}/packages/{type}/{name}` | [Delete one of a user's packages](#delete-one-of-a-users-packages) | | GET | `/users/{owner}/packages/{type}/{name}/versions` | [List package versions](#list-package-versions-get-usersownerpackagestypenameversions) | | GET | `/users/{owner}/packages/{type}/{name}/versions/{id}` | [Get a package version](#get-a-package-version-get-usersownerpackagestypenameversionsid) | | DELETE | `/users/{owner}/packages/{type}/{name}/versions/{id}` | [Delete a package version](#delete-a-package-version-delete-usersownerpackagestypenameversionsid) | ### List an organization's packages ```http GET /api/v3/orgs/{owner}/packages ``` Only packages you can pull. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/packages" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get one of an organization's packages ```http GET /api/v3/orgs/{owner}/packages/{type}/{name} ``` `type` is `container`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update one of an organization's packages ```http PATCH /api/v3/orgs/{owner}/packages/{type}/{name} ``` Change its visibility or the repository it is linked to. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `visibility` | string | no | `private` or `public`. | | `repository` | string | no | Repository name to link, or `null` to unlink. | ```sh curl -X PATCH "https://diffinery.com/api/v3/orgs/acme/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete one of an organization's packages ```http DELETE /api/v3/orgs/{owner}/packages/{type}/{name} ``` **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List package versions ```http GET /api/v3/orgs/{owner}/packages/{type}/{name}/versions ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `tagged` for tagged versions only. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/packages/container/website/versions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a package version ```http GET /api/v3/orgs/{owner}/packages/{type}/{name}/versions/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/packages/container/website/versions/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a package version ```http DELETE /api/v3/orgs/{owner}/packages/{type}/{name}/versions/{id} ``` **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/packages/container/website/versions/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List a repository's packages ```http GET /api/v3/repos/{owner}/{repo}/packages ``` Container images linked to the repository. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/packages" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List your packages ```http GET /api/v3/user/packages ``` Only packages you can pull. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/user/packages" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get one of your packages ```http GET /api/v3/user/packages/{type}/{name} ``` `type` is `container`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/user/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update one of your packages ```http PATCH /api/v3/user/packages/{type}/{name} ``` Change its visibility or the repository it is linked to. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `visibility` | string | no | `private` or `public`. | | `repository` | string | no | Repository name to link, or `null` to unlink. | ```sh curl -X PATCH "https://diffinery.com/api/v3/user/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete one of your packages ```http DELETE /api/v3/user/packages/{type}/{name} ``` **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/user/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List package versions (GET /user/packages/type/name/versions) ```http GET /api/v3/user/packages/{type}/{name}/versions ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `tagged` for tagged versions only. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/user/packages/container/website/versions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a package version (GET /user/packages/type/name/versions/id) ```http GET /api/v3/user/packages/{type}/{name}/versions/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/user/packages/container/website/versions/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a package version (DELETE /user/packages/type/name/versions/id) ```http DELETE /api/v3/user/packages/{type}/{name}/versions/{id} ``` **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/user/packages/container/website/versions/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List a user's packages ```http GET /api/v3/users/{owner}/packages ``` Only packages you can pull. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | ```sh curl "https://diffinery.com/api/v3/users/acme/packages" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get one of a user's packages ```http GET /api/v3/users/{owner}/packages/{type}/{name} ``` `type` is `container`. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | ```sh curl "https://diffinery.com/api/v3/users/acme/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update one of a user's packages ```http PATCH /api/v3/users/{owner}/packages/{type}/{name} ``` Change its visibility or the repository it is linked to. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `visibility` | string | no | `private` or `public`. | | `repository` | string | no | Repository name to link, or `null` to unlink. | ```sh curl -X PATCH "https://diffinery.com/api/v3/users/acme/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete one of a user's packages ```http DELETE /api/v3/users/{owner}/packages/{type}/{name} ``` **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/users/acme/packages/container/website" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List package versions (GET /users/owner/packages/type/name/versions) ```http GET /api/v3/users/{owner}/packages/{type}/{name}/versions ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `tagged` for tagged versions only. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/users/acme/packages/container/website/versions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a package version (GET /users/owner/packages/type/name/versions/id) ```http GET /api/v3/users/{owner}/packages/{type}/{name}/versions/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/users/acme/packages/container/website/versions/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a package version (DELETE /users/owner/packages/type/name/versions/id) ```http DELETE /api/v3/users/{owner}/packages/{type}/{name}/versions/{id} ``` **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `type` | string | yes | Package type: `container`. | | `name` | string | yes | Name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/users/acme/packages/container/website/versions/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Imports | Method | Path | Summary | | --- | --- | --- | | DELETE | `/diffinery/admin/linked-accounts/{id}` | [Clear anyone's GitHub link](#clear-anyones-github-link) | | GET | `/diffinery/imports` | [List your imports](#list-your-imports) | | POST | `/diffinery/imports` | [Start a GitHub import](#start-a-github-import) | | GET | `/diffinery/imports/{id}` | [Get an import](#get-an-import) | | POST | `/diffinery/imports/{id}/agent/complete` | [Report a Git transfer's result (import agent)](#report-a-git-transfers-result-import-agent) | | POST | `/diffinery/imports/{id}/agent/progress` | [Report Git transfer progress (import agent)](#report-git-transfer-progress-import-agent) | | GET | `/diffinery/imports/{id}/agent/spec` | [Get a Git transfer spec (import agent)](#get-a-git-transfer-spec-import-agent) | | POST | `/diffinery/imports/{id}/cancel` | [Cancel an import](#cancel-an-import) | | POST | `/diffinery/imports/{id}/claim` | [Claim an import's Git transfer (local development)](#claim-an-imports-git-transfer-local-development) | | POST | `/diffinery/imports/{id}/retry` | [Retry an import](#retry-an-import) | | POST | `/diffinery/imports/{id}/selection` | [Choose repositories for a draft import](#choose-repositories-for-a-draft-import) | | GET | `/diffinery/user/linked-accounts` | [List your linked GitHub accounts](#list-your-linked-github-accounts) | | DELETE | `/diffinery/user/linked-accounts/{id}` | [Unlink a GitHub account](#unlink-a-github-account) | | POST | `/diffinery/user/linked-accounts/github` | [Link a GitHub account](#link-a-github-account) | | POST | `/diffinery/user/linked-accounts/github/challenge` | [Get a GitHub link code](#get-a-github-link-code) | ### Clear anyone's GitHub link ```http DELETE /api/v3/diffinery/admin/linked-accounts/{id} ``` For example after an account changes hands. `id` is the link id from `GET /diffinery/user/linked-accounts` of the account that owns it; there is no endpoint that lists other people's links. 404 `link not found`, 403 `site admin only`. Audited as `account.link_cleared`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/admin/linked-accounts/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List your imports ```http GET /api/v3/diffinery/imports ``` The imports you started, newest first, at most 100. Repository imports that belong to an organization import aren't listed separately; read them through the organization import's `children`. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/imports" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Start a GitHub import ```http POST /api/v3/diffinery/imports ``` Imports one GitHub repository (`source`), or many repositories of a GitHub organization or user (`source_owner`), on the server, and returns the import (`status: queued`). Git history with LFS objects is copied in a container; issues and pull requests (with their numbers), comments, reviews, labels, milestones, branch protection, Actions variables, deployment environments and releases come through GitHub's REST API. Secret values are never read: only secret names are listed in `summary`, for a person to set by hand. People can import into their own account or an organization they own or administer (site administrators: any user or organization). An agent can import only where its owner may (the owner's own account or an organization the owner owns or administers; an organization's agent, only into that organization), and only when its policy allows `write`; its imports always use `secrets: stop` and skip Actions variables and environments. Needs a `write` or `admin` token (403 `starting an import needs a token with write or admin scope`), or a browser session. At most one organization import and three repository imports run at a time per account and per target (429), and a `source` repository larger than `IMPORT_MAX_REPO_KB` (default 10 GB, by GitHub's reported size) is refused with 422. Other errors: 422 `<owner>/<name> already exists; choose another name`, 422 `GitHub repository <source> was not found or the token cannot read it`, 422 `GitHub rejected the token (expired or revoked?)`, 429 when GitHub's rate limit is reached, 502 `could not reach GitHub: ...`. Audited as `import.create`. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github). **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `source` | string | no | One repository: `owner/name`, a github.com or GitHub Enterprise URL, or an SSH remote such as `git@github.com:owner/name.git`. Send either `source` or `source_owner`. | | `source_owner` | string | no | A GitHub organization or user: imports its repositories (private ones too when the token can see them). | | `owner` | string | yes | The Diffinery user or organization that will own the new repositories. | | `name` | string | no | `source` only: the new repository's name. Default: the GitHub name. It must not exist yet. | | `visibility` | string | no | `keep` (default: public stays public, private and internal become private), `private` or `public`. `internal` is accepted and means `private`. | | `github_token` | string | no | A GitHub token that can read the source, at most 400 characters. Needed for private repositories; Actions variables, secret names and branch protection need a token with admin access to the source. Encrypted at rest, used only by this import, deleted when it ends, never returned. | | `github_api_url` | string | no | GitHub Enterprise Server: the REST API base, such as `https://ghe.example.com/api/v3` (default `https://api.github.com`). Must be HTTPS on a public host. Required, on the same host as `source`, when `source` names an Enterprise host and you send `github_token`. | | `options` | object | no | What to copy; each is `true` by default: `issues` (issues and pull requests with comments and reviews), `labels` (labels and milestones), `branch_protection`, `variables` (Actions variables, secret names and deployment environments), `releases`, `lfs`. Plus `secrets`: `bypass` (default: credentials found in old history are imported with an audited push protection bypass, which needs a person with admin on the target and an `admin` token or browser session) or `stop` (the import fails). Unknown keys are refused with 422. | | `repositories` | string[] | no | `source_owner` only: the repository names to import, at most 1,000. Default: every repository (up to 1,000). | | `exclude_forks` | boolean | no | `source_owner` only: skip forks (default `false`). | | `exclude_archived` | boolean | no | `source_owner` only: skip archived repositories (default `false`). | | `concurrency` | integer | no | `source_owner` only: repositories imported at the same time, 1 to 8 (default 3). | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/imports" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"owner":"..."}' ``` ### Get an import ```http GET /api/v3/diffinery/imports/{id} ``` Readable by the person or agent who started it, by people who could start an import into the same target (the account itself, or the organization's owners and admins), and by site administrators; others get 404 `import not found`. Fields: `kind` (`repository` or `organization`), `status` (`draft`, `queued`, `running`, `succeeded`, `failed` or `cancelled`), `attempt`, `source`, `target`, `visibility`, `options`, `steps` (each with `name`, `title`, `status`, `message`, `count` and times), `git` (the transfer's `phase`, `message`, `chunks`, `bytes` and `tries`), `summary` (counts, secret names, variables, environments, releases, push protection `findings`, linked and imported people), `warnings`, `error` and timestamps. An organization import adds `children` (each repository import's `id`, `source`, `target`, `status`, current `step` and `error`), and a draft adds `candidates`. Credentials are never included. Poll until `status` is `succeeded`, `failed` or `cancelled`. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/diffinery/imports/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Report a Git transfer's result (import agent) ```http POST /api/v3/diffinery/imports/{id}/agent/complete ``` Internal: the import agent reports the result (branches, tags, pull request heads, LFS objects, push protection findings, warnings). Revokes the push token and wakes the import's workflow. Same credential rules as the spec. 204. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/imports/1/agent/complete" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Report Git transfer progress (import agent) ```http POST /api/v3/diffinery/imports/{id}/agent/progress ``` Internal: the import agent reports its phase, a message, and the chunks and bytes pushed so far. Same credential rules as the spec. 204. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/imports/1/agent/progress" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a Git transfer spec (import agent) ```http GET /api/v3/diffinery/imports/{id}/agent/spec ``` Internal: called by the import container with the import's own push token, which works only on Git, Git LFS and these agent endpoints. Says what to clone and where to push, and includes the GitHub token, so no other credential is accepted (403 `this token does not belong to this import`, or 403 `this push token is no longer current for the import` for a token from an earlier try; 409 `this import is not running`). **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/diffinery/imports/1/agent/spec" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Cancel an import ```http POST /api/v3/diffinery/imports/{id}/cancel ``` Stops a `draft`, `queued` or `running` import (409 `the import already <status>` otherwise): ends its workflow and container and revokes its push tokens. Cancelling an organization import cancels its unfinished repository imports. A repository the import already created is kept with what was copied so far, and is unlocked. Anyone who can read the import can cancel it. Audited as `import.cancel`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/imports/1/cancel" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Claim an import's Git transfer (local development) ```http POST /api/v3/diffinery/imports/{id}/claim ``` Only when the Worker runs with `IMPORT_GIT_AGENT=external` (the `dev` environment, where containers don't run); 404 otherwise. Whoever started the import gets `{ token }`, a push token for the transfer, and runs the runner image's import mode by hand with `DIFFINERY_API_URL`, `DIFFINERY_IMPORT_ID` and `DIFFINERY_IMPORT_TOKEN`. 409 `this import is not waiting for an agent`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/imports/1/claim" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Retry an import ```http POST /api/v3/diffinery/imports/{id}/retry ``` Runs a `failed` or `cancelled` import again as a new `attempt` (409 `only failed or cancelled imports can be retried` otherwise), with the same options. Only whoever started it can retry it (404 for anyone else), except that a person who may import into the target can also retry an import an agent started. A repository import inside an organization import can't be retried on its own (422 `retry the organization import this repository belongs to`); retrying the organization import re-runs its failed and cancelled repositories, except those that failed because the target name was taken. The GitHub token was deleted when the import ended, so send `github_token` again for a private source. The retry resumes into the repository the import created, and fails if someone else has written to it since (`repository already holds content not written by this import; start a new import`). An agent can't retry once the repository exists (403 `an agent cannot retry an import after the repository was created; a person who administers <owner> must retry it`). Audited as `import.retry`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `github_token` | string | no | A GitHub token for this attempt (optional; at most 400 characters). | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/imports/1/retry" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Choose repositories for a draft import ```http POST /api/v3/diffinery/imports/{id}/selection ``` An organization import started from the web form waits as a `draft` with its `candidates` until its repositories are chosen; this starts it. Names are matched to the candidates ignoring case, and names that aren't candidates are dropped. 422 `choose at least one repository`, 409 `this import has already started`. Only whoever started it (404 for anyone else). Imports started through this API or MCP never wait as drafts. Unstarted drafts are cancelled after 7 days. Audited as `import.create`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `repositories` | string[] | yes | Names of the repositories to import, at most 1,000. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/imports/1/selection" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"repositories":["..."]}' ``` ### List your linked GitHub accounts ```http GET /api/v3/diffinery/user/linked-accounts ``` Each link has `id`, `host` (`github.com` or the Enterprise host), `apiBase`, `login`, `externalId` (GitHub's numeric user id) and `createdAt`. Imports attribute a GitHub user's issues, pull requests, comments, reviews and releases to the Diffinery account linked to that user id on the same API base. Everyone else is imported as a mannequin: login `github:<login>`, `type: "Mannequin"`, linking to the GitHub profile. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/user/linked-accounts" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Unlink a GitHub account ```http DELETE /api/v3/diffinery/user/linked-accounts/{id} ``` Removes one of your own links (an id that isn't yours changes nothing). Imports that already ran keep their attribution. Audited as `account.unlink_github`. **Access:** Any active account (a token, or a browser session). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/user/linked-accounts/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Link a GitHub account ```http POST /api/v3/diffinery/user/linked-accounts/github ``` Step 2: reads the GitHub user's public profile and public gists without a token on the same API base, and links the account when one of the first 100 public gists GitHub lists for the user has your nonce in its description or in a file (file contents are read for at most 20 gists). Returns `{ id, host, login }`. 422 `create a PUBLIC gist whose description or a file contains <nonce>, then try again`, 422 `GitHub user <login> was not found`, 409 `GitHub user <login> is linked to another Diffinery account`, 429 when GitHub's rate limit is reached. People only. Audited as `account.link_github`. **Access:** Any active account (a token, or a browser session). Tokens need the `admin` scope and access to all repositories. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Your GitHub username. | | `api_url` | string | no | The same API base you used for the challenge. Default: github.com. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/user/linked-accounts/github" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"login":"..."}' ``` ### Get a GitHub link code ```http POST /api/v3/diffinery/user/linked-accounts/github/challenge ``` Step 1 of linking a GitHub account without a token: returns `nonce` (`diffinery-link-` and 32 hex characters, the same each time for your account and host), `apiBase`, `host` and `gistsUrl`. Put the nonce in the description or a file of a public gist of the GitHub account, then call `POST /diffinery/user/linked-accounts/github`. People only (403 `only people can link GitHub accounts`). **Access:** Any active account (a token, or a browser session). Tokens need the `admin` scope and access to all repositories. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `api_url` | string | no | GitHub Enterprise REST API base, such as `https://ghe.example.com/api/v3`. Default: github.com. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/user/linked-accounts/github/challenge" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Status page | Method | Path | Summary | | --- | --- | --- | | GET | `/diffinery/admin/status/incidents` | [List status incidents](#list-status-incidents) | | POST | `/diffinery/admin/status/incidents` | [Open a status incident](#open-a-status-incident) | | POST | `/diffinery/admin/status/incidents/{id}/updates` | [Post an update to a status incident](#post-an-update-to-a-status-incident) | | GET | `/diffinery/status` | [Get the instance's status](#get-the-instances-status) | ### List status incidents ```http GET /api/v3/diffinery/admin/status/incidents ``` Incidents on the status page, newest first, resolved or not: `id`, `title`, `impact`, `state`, `components`, `created_by`, `created_at`, `updated_at`, `resolved_at`, `updates` (each with `id`, `state`, `body`, `author` and `created_at`, newest first) and `html_url`. 404 for anyone but a site administrator. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | At most this many (default 50, up to 200). | ```sh curl "https://diffinery.com/api/v3/diffinery/admin/status/incidents" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Open a status incident ```http POST /api/v3/diffinery/admin/status/incidents ``` Opens an incident with its first update and answers with it. While it isn't resolved, its impact sets the status of each component it names: `critical` is a major outage, `major` a partial outage, `minor` degraded performance and `maintenance` under maintenance. Recorded in the audit log as `status_incident.create`. 404 for anyone but a site administrator. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `title` | string | yes | One line, up to 200 characters. | | `impact` | string | yes | `minor`, `major`, `critical` or `maintenance`. | | `components` | array | yes | At least one of `web`, `git`, `actions`, `agents`, `packages` and `email`. | | `state` | string | yes | `investigating`, `identified`, `monitoring` or `resolved`. | | `body` | string | yes | The first update, plain text up to 5,000 characters. It is public. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/admin/status/incidents" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"...","impact":"...","components":[],"state":"...","body":"..."}' ``` ### Post an update to a status incident ```http POST /api/v3/diffinery/admin/status/incidents/{id}/updates ``` Adds an update and moves the incident to its state; `resolved` resolves it, and any other state reopens a resolved incident. Answers with the incident. Recorded in the audit log as `status_incident.resolve` when it resolves the incident and `status_incident.update` otherwise. 404 when there is no such incident, or for anyone but a site administrator. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | yes | `investigating`, `identified`, `monitoring` or `resolved`. | | `body` | string | yes | Plain text up to 5,000 characters. It is public. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/admin/status/incidents/1/updates" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"...","body":"..."}' ``` ### Get the instance's status ```http GET /api/v3/diffinery/status ``` The status page as JSON, shaped like Statuspage's summary: `status` (`indicator`: `none`, `minor`, `major`, `critical` or `maintenance`, and a `description`), `components` (`id`, `name`, `status`: `operational`, `under_maintenance`, `degraded_performance`, `partial_outage` or `major_outage`, and a `note` when a kill switch or a database failure set it), `incidents` (the unresolved ones, newest first, each with its `updates`, newest first) and `updated_at`. Cached for 30 seconds. See [Status page](https://diffinery.com/docs/self-hosting/status-page). **Access:** No authentication needed. ```sh curl "https://diffinery.com/api/v3/diffinery/status" ``` ## Notifications and email | Method | Path | Summary | | --- | --- | --- | | GET | `/diffinery/admin/early-access` | [List early access requests](#list-early-access-requests) | | GET | `/diffinery/admin/early-access/{id}` | [Get an early access request](#get-an-early-access-request) | | PATCH | `/diffinery/admin/early-access/{id}` | [Decline an early access request, or put it back](#decline-an-early-access-request-or-put-it-back) | | DELETE | `/diffinery/admin/early-access/{id}` | [Delete an early access request](#delete-an-early-access-request) | | POST | `/diffinery/admin/early-access/{id}/invite` | [Invite an early access request](#invite-an-early-access-request) | | GET | `/diffinery/admin/email` | [Email delivery status](#email-delivery-status) | | GET | `/diffinery/admin/email/suppressions` | [List suppressed email addresses](#list-suppressed-email-addresses) | | PUT | `/diffinery/admin/email/suppressions/{email}` | [Suppress an email address](#suppress-an-email-address) | | DELETE | `/diffinery/admin/email/suppressions/{email}` | [Remove an address from the suppression list](#remove-an-address-from-the-suppression-list) | | POST | `/diffinery/admin/emails/samples` | [Send yourself every email template](#send-yourself-every-email-template) | | GET | `/diffinery/admin/errors` | [List unexpected server errors](#list-unexpected-server-errors) | | GET | `/diffinery/admin/errors/{id}` | [Get a server error by its request ID](#get-a-server-error-by-its-request-id) | | GET | `/diffinery/admin/indexnow` | [IndexNow submissions](#indexnow-submissions) | | GET | `/diffinery/admin/scheduler` | [Workflow scheduler state](#workflow-scheduler-state) | | GET | `/diffinery/admin/security-reports` | [List security reports](#list-security-reports) | | GET | `/diffinery/admin/security-reports/{id}` | [Get a security report](#get-a-security-report) | | PATCH | `/diffinery/admin/security-reports/{id}` | [Triage or resolve a security report](#triage-or-resolve-a-security-report) | | GET | `/diffinery/emails` | [List emails sent to you](#list-emails-sent-to-you) | | POST | `/diffinery/emails/digest` | [Send yourself the morning digest now](#send-yourself-the-morning-digest-now) | | POST | `/diffinery/emails/test` | [Send yourself a test email](#send-yourself-a-test-email) | | GET | `/diffinery/notification-settings` | [Get your notification settings](#get-your-notification-settings) | | PUT | `/diffinery/notification-settings` | [Update your notification settings](#update-your-notification-settings) | | GET | `/notifications` | [List notifications](#list-notifications) | | PUT | `/notifications` | [Mark all notifications as read](#mark-all-notifications-as-read) | | GET | `/repos/{owner}/{repo}/subscription` | [Get your watch setting](#get-your-watch-setting) | | PUT | `/repos/{owner}/{repo}/subscription` | [Watch or ignore a repository](#watch-or-ignore-a-repository) | | DELETE | `/repos/{owner}/{repo}/subscription` | [Stop watching a repository](#stop-watching-a-repository) | ### List early access requests ```http GET /api/v3/diffinery/admin/early-access ``` Requests sent through `/early-access`, newest first: `id`, `state` (`unconfirmed` until the address follows the emailed link, then `confirmed`, `invited` or `declined`), `email`, `name`, `company`, `use_case`, `hosting` (`github`, `gitlab`, `bitbucket`, `azure`, `self-hosted`, `other`, `none` or null), `confirmed_at`, `invite_id`, `decided_by`, `decided_at`, `created_at`, `updated_at` and `html_url`. 404 for anyone but a site administrator. See [Early access](https://diffinery.com/docs/accounts/early-access). **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | Only requests in this state: `confirmed`, `unconfirmed`, `invited` or `declined`. | | `q` | string | no | Only requests whose address, name, company or use contains this text. | | `per_page` | integer | no | At most this many (default 100, up to 500). | | `page` | integer | no | Page number, from 1. | ```sh curl "https://diffinery.com/api/v3/diffinery/admin/early-access" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an early access request ```http GET /api/v3/diffinery/admin/early-access/{id} ``` One request, as in the list. 404 when there is none, or for anyone but a site administrator. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/diffinery/admin/early-access/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Decline an early access request, or put it back ```http PATCH /api/v3/diffinery/admin/early-access/{id} ``` `declined` declines it; `confirmed` puts a declined request back (as `unconfirmed` if its address never confirmed). Nobody is emailed. Recorded in the audit log as `early_access.decline` or `early_access.restore`. 404 for anyone but a site administrator. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | yes | `declined`, or `confirmed` to put a declined request back. | ```sh curl -X PATCH "https://diffinery.com/api/v3/diffinery/admin/early-access/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"..."}' ``` ### Delete an early access request ```http DELETE /api/v3/diffinery/admin/early-access/{id} ``` Removes the request, for someone who asks for their data to be removed. An invitation already sent from it stays; revoke it separately. Recorded in the audit log as `early_access.delete`, without the request's content or address. 404 for anyone but a site administrator. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/admin/early-access/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Invite an early access request ```http POST /api/v3/diffinery/admin/early-access/{id}/invite ``` Sends the request's address the instance's usual invitation email (it works once for 14 days) and marks the request `invited`. An address that already has a pending instance invitation keeps it; one that already belongs to an active account is refused (422). Recorded in the audit log as `early_access.invite`, and `invite.create` when a new invitation is sent. 404 for anyone but a site administrator. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/admin/early-access/1/invite" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Email delivery status ```http GET /api/v3/diffinery/admin/email ``` Whether sending is configured, whether the signed bounce and complaint endpoint is on (`event_intake`), and counts for the last 24 hours. **Access:** A site administrator. Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/admin/email" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List suppressed email addresses ```http GET /api/v3/diffinery/admin/email/suppressions ``` Newest first: `email`, `reason` (`hard-bounce`, `complaint` or `manual`), `detail`, `source` (`cloudflare`, `webhook`, `send` or `admin`), `created_by` and `created_at`. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. ```sh curl "https://diffinery.com/api/v3/diffinery/admin/email/suppressions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Suppress an email address ```http PUT /api/v3/diffinery/admin/email/suppressions/{email} ``` Diffinery sends nothing to it until it is removed. Recorded in the audit log. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | email | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `note` | string | no | Why, kept with the entry. | ```sh curl -X PUT "https://diffinery.com/api/v3/diffinery/admin/email/suppressions/email" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Remove an address from the suppression list ```http DELETE /api/v3/diffinery/admin/email/suppressions/{email} ``` Whatever put it there. 404 when it isn't suppressed. Recorded in the audit log. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | email | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/admin/email/suppressions/email" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Send yourself every email template ```http POST /api/v3/diffinery/admin/emails/samples ``` **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `only` | string[] | no | Only these sample ids. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/admin/emails/samples" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List unexpected server errors ```http GET /api/v3/diffinery/admin/errors ``` The newest answers with a 5xx status, newest first: `id` (the `request_id` the answer carried, also in its `x-request-id` header), `at`, `method`, `route`, `path`, `status`, `message` and `stack`. The newest 500 are kept. No request bodies, query strings or headers are kept, and tokens in a message are masked. **Access:** A site administrator. Tokens need the `read` scope. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | At most this many (default 100, up to 500). | ```sh curl "https://diffinery.com/api/v3/diffinery/admin/errors" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a server error by its request ID ```http GET /api/v3/diffinery/admin/errors/{id} ``` The error a 5xx answer with this `request_id` recorded; 404 once it is no longer among the newest 500. **Access:** A site administrator. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/diffinery/admin/errors/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### IndexNow submissions ```http GET /api/v3/diffinery/admin/indexnow ``` What this instance has told search engines through IndexNow: `enabled` (and `off_reason` when it isn't), `key_location`, how many `site_pages` and public `repositories` it tracks, how many are `queued`, `retry_at` (set after IndexNow refused or couldn't be reached), `repositories_checked_at`, and the newest `submissions` (`at`, `urls`, `status` from IndexNow or null when unreachable, `endpoint` (the host that answered), `ms`, `error`, and the first 50 `paths`). See [Search engines](https://diffinery.com/docs/admin/site-administration#search-engines). **Access:** A site administrator. Tokens need the `read` scope. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `per_page` | integer | no | Submissions to return (default 50, up to 200). | ```sh curl "https://diffinery.com/api/v3/diffinery/admin/indexnow" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Workflow scheduler state ```http GET /api/v3/diffinery/admin/scheduler ``` `tick_minutes`, `last_tick_at` (when the last check finished), `covered_until` (the last minute whose schedules were started) and `next_tick_at` (null when the timer isn't set). Tells a scheduler that stopped ticking from a run that failed to start. **Access:** A site administrator. Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/admin/scheduler" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List security reports ```http GET /api/v3/diffinery/admin/security-reports ``` Private vulnerability reports sent through `/security/report`, newest first: `id`, `state` (`new`, `triaged` or `resolved`), `summary`, `details`, `area`, `reply_to`, `reporter` (a login, or null when not signed in), `note`, `updated_by`, `updated_at`, `created_at` and `html_url`. 404 for anyone but a site administrator. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | Only reports in this state: `new`, `triaged` or `resolved`. | | `per_page` | integer | no | At most this many (default 100, up to 500). | ```sh curl "https://diffinery.com/api/v3/diffinery/admin/security-reports" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a security report ```http GET /api/v3/diffinery/admin/security-reports/{id} ``` One report, as in the list. 404 when there is none, or for anyone but a site administrator. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/diffinery/admin/security-reports/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Triage or resolve a security report ```http PATCH /api/v3/diffinery/admin/security-reports/{id} ``` Sets the report's state and note (a missing or empty note clears it). Recorded in the audit log as `security_report.triage`, `security_report.resolve` or `security_report.reopen`. 404 for anyone but a site administrator. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | yes | `triaged`, `resolved`, or `new` to reopen it. | | `note` | string | no | For the other site administrators, up to 2,000 characters. | ```sh curl -X PATCH "https://diffinery.com/api/v3/diffinery/admin/security-reports/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"..."}' ``` ### List emails sent to you ```http GET /api/v3/diffinery/emails ``` **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `kind` | string | no | Only this kind of email. | | `since` | string | no | ISO timestamp. | | `per_page` | integer | no | At most this many (default 50). | | `all` | string | no | `1` for everyone's (site admins). | ```sh curl "https://diffinery.com/api/v3/diffinery/emails" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Send yourself the morning digest now ```http POST /api/v3/diffinery/emails/digest ``` Your Today digest, sent now even when the digest is off or has nothing in it, covering what happened since the last one. At most 3 an hour; more answer 429. See /docs/agents/today#the-morning-digest. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/emails/digest" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Send yourself a test email ```http POST /api/v3/diffinery/emails/test ``` At most 5 an hour; more answer 429 `you can send at most 5 test emails an hour; try again later`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/emails/test" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get your notification settings ```http GET /api/v3/diffinery/notification-settings ``` **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/notification-settings" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update your notification settings ```http PUT /api/v3/diffinery/notification-settings ``` Send only the groups you want to change. The morning digest is `digest`: `email` (on or off), `hour` (0 to 23), `timeZone` (an IANA time zone such as `Europe/Istanbul` or `UTC`) and `weekends`; 422 for an hour or time zone it can't use. See /docs/agents/today#the-morning-digest. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. ```sh curl -X PUT "https://diffinery.com/api/v3/diffinery/notification-settings" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List notifications ```http GET /api/v3/notifications ``` A token limited to repositories answers 403 `this token is limited to repositories and can't read notifications`, since notifications cover every repository. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `all` | boolean | no | `true` to include read ones. | ```sh curl "https://diffinery.com/api/v3/notifications" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Mark all notifications as read ```http PUT /api/v3/notifications ``` **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `205`. ```sh curl -X PUT "https://diffinery.com/api/v3/notifications" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get your watch setting ```http GET /api/v3/repos/{owner}/{repo}/subscription ``` 404 when you only get participating notifications (the default), as on GitHub. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/subscription" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Watch or ignore a repository ```http PUT /api/v3/repos/{owner}/{repo}/subscription ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `subscribed` | boolean | no | `true` for all activity, `false` for participating only. | | `ignored` | boolean | no | `true` to ignore the repository. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/subscription" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Stop watching a repository ```http DELETE /api/v3/repos/{owner}/{repo}/subscription ``` Back to participating notifications. **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/subscription" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Tokens and agents | Method | Path | Summary | | --- | --- | --- | | GET | `/diffinery/agents` | [List your agents](#list-your-agents) | | POST | `/diffinery/agents` | [Create an agent](#create-an-agent) | | DELETE | `/diffinery/agents/{login}` | [Disable an agent](#disable-an-agent) | | PUT | `/diffinery/agents/{login}/auto-merge` | [Turn auto-merge on or off for an agent's pull requests](#turn-auto-merge-on-or-off-for-an-agents-pull-requests) | | POST | `/diffinery/agents/{login}/tokens` | [Create a token for an agent](#create-a-token-for-an-agent) | | GET | `/diffinery/tokens` | [List your tokens](#list-your-tokens) | | POST | `/diffinery/tokens` | [Create a token](#create-a-token) | | DELETE | `/diffinery/tokens/{id}` | [Revoke a token](#revoke-a-token) | ### List your agents ```http GET /api/v3/diffinery/agents ``` Agents you own, and agents of organizations you administer. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/agents" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create an agent ```http POST /api/v3/diffinery/agents ``` People, from a browser session only (no token can create a token). Returns the agent and its first token (write scope, 90 days, unless you say otherwise). Unknown scopes or an empty list fail with 422. From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL (see /docs/security/overview#confirming-its-you). **Access:** Any active account (a token, or a browser session). No token can call it: it needs a browser session. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Agent login. | | `name` | string | no | Display name. | | `org` | string | no | Organization that owns the agent (default: you). | | `policy` | object | no | Agent policy. | | `token` | object | no | `{ scopes, expires_in_days }` for the first token. | | `auto_merge` | boolean | no | Turn on auto-merge for the pull requests it opens (see `PUT /diffinery/agents/{login}/auto-merge`). | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/agents" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"login":"..."}' ``` ### Disable an agent ```http DELETE /api/v3/diffinery/agents/{login} ``` Its tokens stop working. **Access:** Any active account (a token, or a browser session). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/agents/diffy" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Turn auto-merge on or off for an agent's pull requests ```http PUT /api/v3/diffinery/agents/{login}/auto-merge ``` The agent's owner, or an owner or admin of the organization that owns it. Diffinery then turns on auto-merge, as the agent, for each pull request it opens; it lands only as the agent's own auto-merge would (the repository must allow agents' auto-merge, or the agent may merge what a person approved), and a workflow change still needs a person. Same body as `PUT /diffinery/auto-merge`. Audited as `agent.auto_merge`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | boolean | yes | On or off. | | `merge_method` | string | no | `default`, `merge`, `squash` or `rebase`. | ```sh curl -X PUT "https://diffinery.com/api/v3/diffinery/agents/diffy/auto-merge" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"enabled":true}' ``` ### Create a token for an agent ```http POST /api/v3/diffinery/agents/{login}/tokens ``` From a browser session only (no token can create a token): the agent's owner, or an owner or admin of the organization that owns it. Default scope `write`; unknown scopes or an empty list fail with 422. Audited as `agent.token.create`. From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL (see /docs/security/overview#confirming-its-you). **Access:** Any active account (a token, or a browser session). No token can call it: it needs a browser session. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Token name. | | `scopes` | string[] | no | `read`, `write` (default) and/or `admin`. | | `expires_in_days` | integer | no | Lifetime in days. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/agents/diffy/tokens" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List your tokens ```http GET /api/v3/diffinery/tokens ``` **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/tokens" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a token ```http POST /api/v3/diffinery/tokens ``` From a browser session only: no token can create a token. The token value is returned once. Unknown scopes or an empty list fail with 422, and so does a repository you can't see (`unknown repository owner/name`). From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL (see /docs/security/overview#confirming-its-you). **Access:** Any active account (a token, or a browser session). No token can call it: it needs a browser session. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | A name to recognise it by. | | `scopes` | string[] | no | `read`, `write` (default) and/or `admin`. | | `repositories` | string[] | no | Restrict to these `owner/name` repositories (only ones you can see). | | `expires_in_days` | integer | no | Lifetime in days. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/tokens" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"..."}' ``` ### Revoke a token ```http DELETE /api/v3/diffinery/tokens/{id} ``` **Access:** Any active account (a token, or a browser session). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/tokens/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Sandboxes | Method | Path | Summary | | --- | --- | --- | | POST | `/repos/{owner}/{repo}/diffinery/sandboxes` | [Start a sandbox](#start-a-sandbox) | | POST | `/repos/{owner}/{repo}/diffinery/sandboxes/{id}/{action}` | [Use a sandbox](#use-a-sandbox) | ### Start a sandbox ```http POST /api/v3/repos/{owner}/{repo}/diffinery/sandboxes ``` A container with the repository checked out, for agents to build and test in. **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | no | Branch, tag or commit to check out (default: the default branch). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Use a sandbox ```http POST /api/v3/repos/{owner}/{repo}/diffinery/sandboxes/{id}/{action} ``` `action` is `exec` (run a command), `read` or `write` (files), or `close`. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | | `action` | string | yes | Action to take. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes/1/exec" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Other | Method | Path | Summary | | --- | --- | --- | | GET | `/admin/accounts/{login}/actions-suspension` | [Get an account's Actions suspension](#get-an-accounts-actions-suspension) | | PUT | `/admin/accounts/{login}/actions-suspension` | [Suspend Actions for an account](#suspend-actions-for-an-account) | | DELETE | `/admin/accounts/{login}/actions-suspension` | [Lift an account's Actions suspension](#lift-an-accounts-actions-suspension) | | GET | `/admin/accounts/{login}/quotas` | [Get an account's quotas and usage](#get-an-accounts-quotas-and-usage) | | PATCH | `/admin/accounts/{login}/quotas` | [Change an account's quotas](#change-an-accounts-quotas) | | GET | `/admin/hosted-compute-pause` | [Get the hosted compute pause](#get-the-hosted-compute-pause) | | PUT | `/admin/hosted-compute-pause` | [Pause hosted compute](#pause-hosted-compute) | | DELETE | `/admin/hosted-compute-pause` | [Resume hosted compute](#resume-hosted-compute) | | GET | `/admin/reports` | [List reports](#list-reports) | | GET | `/admin/reports/{id}` | [Get a report](#get-a-report) | | PATCH | `/admin/reports/{id}` | [Resolve or reopen a report](#resolve-or-reopen-a-report) | | GET | `/admin/repos/{owner}/{repo}/actions-suspension` | [Get a repository's Actions suspension](#get-a-repositorys-actions-suspension) | | PUT | `/admin/repos/{owner}/{repo}/actions-suspension` | [Suspend Actions for a repository](#suspend-actions-for-a-repository) | | DELETE | `/admin/repos/{owner}/{repo}/actions-suspension` | [Lift a repository's Actions suspension](#lift-a-repositorys-actions-suspension) | | GET | `/app` | [List app](#list-app) | | GET | `/app/installations` | [List installations](#list-installations) | | GET | `/app/installations/{id}` | [Get a installation](#get-a-installation) | | DELETE | `/app/installations/{id}` | [Delete a installation](#delete-a-installation) | | POST | `/app/installations/{id}/access_tokens` | [Create a access token](#create-a-access-token) | | PUT | `/app/installations/{id}/suspended` | [Set suspended](#set-suspended) | | DELETE | `/app/installations/{id}/suspended` | [Delete suspended](#delete-suspended) | | GET | `/apps/{slug}` | [Get a app](#get-a-app) | | GET | `/diffinery/apps` | [List apps](#list-apps) | | POST | `/diffinery/apps` | [Create a app](#create-a-app) | | GET | `/diffinery/apps/{slug}` | [Get a app](#get-a-app-get-diffineryappsslug) | | PATCH | `/diffinery/apps/{slug}` | [Update a app](#update-a-app) | | DELETE | `/diffinery/apps/{slug}` | [Delete a app](#delete-a-app) | | POST | `/diffinery/apps/{slug}/installations` | [Create a installation](#create-a-installation) | | GET | `/diffinery/apps/{slug}/keys` | [List keys](#list-keys) | | POST | `/diffinery/apps/{slug}/keys` | [Create a key](#create-a-key) | | DELETE | `/diffinery/apps/{slug}/keys/{keyId}` | [Delete a key](#delete-a-key) | | GET | `/diffinery/auto-merge` | [Your auto-merge setting](#your-auto-merge-setting) | | PUT | `/diffinery/auto-merge` | [Turn auto-merge on or off for pull requests you open](#turn-auto-merge-on-or-off-for-pull-requests-you-open) | | GET | `/diffinery/email` | [Your email address and whether it is suppressed](#your-email-address-and-whether-it-is-suppressed) | | POST | `/diffinery/email/verification` | [Ask for the email that turns email back on](#ask-for-the-email-that-turns-email-back-on) | | GET | `/diffinery/installations/{id}` | [Get a installation](#get-a-installation-get-diffineryinstallationsid) | | PATCH | `/diffinery/installations/{id}` | [Update a installation](#update-a-installation) | | DELETE | `/diffinery/installations/{id}` | [Delete a installation](#delete-a-installation-delete-diffineryinstallationsid) | | POST | `/diffinery/installations/{id}/accept_permissions` | [Create a accept permission](#create-a-accept-permission) | | PUT | `/diffinery/installations/{id}/suspended` | [Set suspended](#set-suspended-put-diffineryinstallationsidsuspended) | | DELETE | `/diffinery/installations/{id}/suspended` | [Delete suspended](#delete-suspended-delete-diffineryinstallationsidsuspended) | | GET | `/diffinery/model-keys` | [List your model keys](#list-your-model-keys) | | PUT | `/diffinery/model-keys/{provider}` | [Save a model key](#save-a-model-key) | | DELETE | `/diffinery/model-keys/{provider}` | [Delete a model key](#delete-a-model-key) | | GET | `/diffinery/model-usage` | [Get your model usage](#get-your-model-usage) | | POST | `/diffinery/reports` | [Report content to the site administrators](#report-content-to-the-site-administrators) | | GET | `/diffinery/tasks` | [List your agent tasks](#list-your-agent-tasks) | | GET | `/diffinery/today` | [Your morning view](#your-morning-view) | | GET | `/installation/repositories` | [List repositories](#list-repositories) | | DELETE | `/installation/token` | [Delete token](#delete-token) | | GET | `/orgs/{org}/blocks` | [List the people an organization blocked](#list-the-people-an-organization-blocked) | | GET | `/orgs/{org}/blocks/{username}` | [Check whether an organization blocked a person](#check-whether-an-organization-blocked-a-person) | | PUT | `/orgs/{org}/blocks/{username}` | [Block a person from an organization](#block-a-person-from-an-organization) | | DELETE | `/orgs/{org}/blocks/{username}` | [Unblock a person from an organization](#unblock-a-person-from-an-organization) | | GET | `/orgs/{org}/diffinery/agent-policy` | [Get an organization's agent policy](#get-an-organizations-agent-policy) | | PUT | `/orgs/{org}/diffinery/agent-policy` | [Change an organization's agent policy](#change-an-organizations-agent-policy) | | GET | `/orgs/{org}/diffinery/model-keys` | [List an organization's model keys](#list-an-organizations-model-keys) | | PUT | `/orgs/{org}/diffinery/model-keys/{provider}` | [Save an organization's model key](#save-an-organizations-model-key) | | DELETE | `/orgs/{org}/diffinery/model-keys/{provider}` | [Delete an organization's model key](#delete-an-organizations-model-key) | | GET | `/orgs/{org}/diffinery/model-usage` | [Get an organization's model usage](#get-an-organizations-model-usage) | | GET | `/orgs/{org}/installation` | [List installation](#list-installation) | | GET | `/orgs/{org}/usage` | [Get an organization's quotas and usage](#get-an-organizations-quotas-and-usage) | | GET | `/repos/{owner}/{repo}/diffinery/agent-review` | [Get agent review settings](#get-agent-review-settings) | | PUT | `/repos/{owner}/{repo}/diffinery/agent-review` | [Turn agent review on, change or off](#turn-agent-review-on-change-or-off) | | GET | `/repos/{owner}/{repo}/diffinery/agent-setup` | [Get the agent setup check](#get-the-agent-setup-check) | | POST | `/repos/{owner}/{repo}/diffinery/agent-setup/checks` | [Report an agent setup check](#report-an-agent-setup-check) | | PUT | `/repos/{owner}/{repo}/diffinery/comments/{id}/minimized` | [Hide (minimize) a comment](#hide-minimize-a-comment) | | DELETE | `/repos/{owner}/{repo}/diffinery/comments/{id}/minimized` | [Show a hidden comment again](#show-a-hidden-comment-again) | | POST | `/repos/{owner}/{repo}/diffinery/delete-closed-pulls` | [Delete closed pull requests](#delete-closed-pull-requests) | | GET | `/repos/{owner}/{repo}/diffinery/project` | [Get the project](#get-the-project) | | GET | `/repos/{owner}/{repo}/diffinery/project/coordinator` | [Get your coordinator](#get-your-coordinator) | | PUT | `/repos/{owner}/{repo}/diffinery/project/coordinator` | [Turn on or change your coordinator](#turn-on-or-change-your-coordinator) | | DELETE | `/repos/{owner}/{repo}/diffinery/project/coordinator` | [Turn off your coordinator](#turn-off-your-coordinator) | | GET | `/repos/{owner}/{repo}/diffinery/project/coordinators` | [List the coordinators in a project](#list-the-coordinators-in-a-project) | | GET | `/repos/{owner}/{repo}/diffinery/project/memory` | [Get the project memory](#get-the-project-memory) | | PUT | `/repos/{owner}/{repo}/diffinery/project/memory` | [Save or restore the project memory](#save-or-restore-the-project-memory) | | GET | `/repos/{owner}/{repo}/diffinery/project/posts` | [List the project conversation](#list-the-project-conversation) | | POST | `/repos/{owner}/{repo}/diffinery/project/posts` | [Post to the project conversation](#post-to-the-project-conversation) | | POST | `/repos/{owner}/{repo}/diffinery/project/posts/{n}/handle` | [Hand a post to your coordinator](#hand-a-post-to-your-coordinator) | | PUT | `/repos/{owner}/{repo}/diffinery/project/settings` | [Change the project settings](#change-the-project-settings) | | POST | `/repos/{owner}/{repo}/diffinery/purge-unreachable` | [Purge unreachable objects](#purge-unreachable-objects) | | GET | `/repos/{owner}/{repo}/diffinery/tasks` | [List agent tasks](#list-agent-tasks) | | POST | `/repos/{owner}/{repo}/diffinery/tasks` | [Start an agent task](#start-an-agent-task) | | GET | `/repos/{owner}/{repo}/diffinery/tasks/{number}` | [Get an agent task](#get-an-agent-task) | | PATCH | `/repos/{owner}/{repo}/diffinery/tasks/{number}` | [Change a task's options](#change-a-tasks-options) | | GET | `/repos/{owner}/{repo}/diffinery/tasks/{number}/attempts` | [Compare a task's attempts](#compare-a-tasks-attempts) | | POST | `/repos/{owner}/{repo}/diffinery/tasks/{number}/attempts/{attempt}/pick` | [Pick a task's attempt](#pick-a-tasks-attempt) | | POST | `/repos/{owner}/{repo}/diffinery/tasks/{number}/messages` | [Send a task a follow-up](#send-a-task-a-follow-up) | | POST | `/repos/{owner}/{repo}/diffinery/tasks/{number}/plan/approve` | [Approve a task's plan](#approve-a-tasks-plan) | | POST | `/repos/{owner}/{repo}/diffinery/tasks/{number}/plan/split` | [Split a task's plan into tasks](#split-a-tasks-plan-into-tasks) | | POST | `/repos/{owner}/{repo}/diffinery/tasks/{number}/retry` | [Retry a task](#retry-a-task) | | POST | `/repos/{owner}/{repo}/diffinery/tasks/{number}/stop` | [Stop a task](#stop-a-task) | | GET | `/repos/{owner}/{repo}/diffinery/tasks/{number}/transcript` | [Read a task's transcript](#read-a-tasks-transcript) | | GET | `/repos/{owner}/{repo}/installation` | [List installation](#list-installation-get-reposownerrepoinstallation) | | POST | `/repos/{owner}/{repo}/transfer` | [Transfer a repository](#transfer-a-repository) | | GET | `/user/blocks` | [List the people you blocked](#list-the-people-you-blocked) | | GET | `/user/blocks/{username}` | [Check whether you blocked a person](#check-whether-you-blocked-a-person) | | PUT | `/user/blocks/{username}` | [Block a person](#block-a-person) | | DELETE | `/user/blocks/{username}` | [Unblock a person](#unblock-a-person) | | GET | `/user/installations` | [List installations](#list-installations-get-userinstallations) | | GET | `/user/installations/{id}/repositories` | [List repositories](#list-repositories-get-userinstallationsidrepositories) | | PUT | `/user/installations/{id}/repositories/{repoId}` | [Set a repository](#set-a-repository) | | DELETE | `/user/installations/{id}/repositories/{repoId}` | [Delete a repository](#delete-a-repository-delete-userinstallationsidrepositoriesrepoid) | | GET | `/user/memberships/orgs` | [List your organization memberships](#list-your-organization-memberships) | | GET | `/user/memberships/orgs/{org}` | [Get your organization membership](#get-your-organization-membership) | | PATCH | `/user/memberships/orgs/{org}` | [Accept an organization invitation](#accept-an-organization-invitation) | | GET | `/user/repository_invitations` | [List your repository invitations](#list-your-repository-invitations) | | PATCH | `/user/repository_invitations/{id}` | [Accept a repository invitation](#accept-a-repository-invitation) | | DELETE | `/user/repository_invitations/{id}` | [Decline a repository invitation](#decline-a-repository-invitation) | | GET | `/user/usage` | [Get your quotas and usage](#get-your-quotas-and-usage) | | GET | `/users/{username}/installation` | [List installation](#list-installation-get-usersusernameinstallation) | | PUT | `/users/{username}/suspended` | [Suspend a person](#suspend-a-person) | | DELETE | `/users/{username}/suspended` | [Unsuspend a person](#unsuspend-a-person) | ### Get an account's Actions suspension ```http GET /api/v3/admin/accounts/{login}/actions-suspension ``` Site administrators only. `suspended`, `reason`, `suspended_by` and `suspended_at`. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | ```sh curl "https://diffinery.com/api/v3/admin/accounts/diffy/actions-suspension" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Suspend Actions for an account ```http PUT /api/v3/admin/accounts/{login}/actions-suspension ``` Site administrators only. As for a repository, for every repository the person or organization owns; their running runs and sandboxes stop now. Audited as `actions.suspend`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | yes | Why (up to 500 characters). Shown to the people affected and kept in the audit log. | ```sh curl -X PUT "https://diffinery.com/api/v3/admin/accounts/diffy/actions-suspension" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"reason":"..."}' ``` ### Lift an account's Actions suspension ```http DELETE /api/v3/admin/accounts/{login}/actions-suspension ``` Site administrators only. New work can start again; nothing restarts. Audited as `actions.unsuspend`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | ```sh curl -X DELETE "https://diffinery.com/api/v3/admin/accounts/diffy/actions-suspension" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an account's quotas and usage ```http GET /api/v3/admin/accounts/{login}/quotas ``` Site administrators only. **Access:** A site administrator. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | ```sh curl "https://diffinery.com/api/v3/admin/accounts/diffy/quotas" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Change an account's quotas ```http PATCH /api/v3/admin/accounts/{login}/quotas ``` Site administrators only. Send only the quotas to change: a whole number, `-1` for unlimited, or `null` for the default. Audited as `account.quotas.set`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `login` | string | yes | Account login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `hosted_minutes` | integer | no | Hosted minutes per calendar month (UTC). | | `hosted_jobs` | integer | no | Hosted Actions jobs running at once. | | `sandboxes` | integer | no | Agent sandboxes open at once. | | `repositories` | integer | no | Repositories the account owns. | | `storage_bytes` | integer | no | Bytes stored: git objects, Git LFS objects, release assets and packages, not Actions artifacts or caches. | ```sh curl -X PATCH "https://diffinery.com/api/v3/admin/accounts/diffy/quotas" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get the hosted compute pause ```http GET /api/v3/admin/hosted-compute-pause ``` Site administrators only. `paused`, `reason`, `paused_by` and `paused_at`. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. ```sh curl "https://diffinery.com/api/v3/admin/hosted-compute-pause" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Pause hosted compute ```http PUT /api/v3/admin/hosted-compute-pause ``` Site administrators only. Hosted jobs fail without starting and sandboxes are refused everywhere; running ones stop now. Self-hosted runners still work. Audited as `compute.pause`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | yes | Why (up to 500 characters). Shown to the people affected and kept in the audit log. | ```sh curl -X PUT "https://diffinery.com/api/v3/admin/hosted-compute-pause" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"reason":"..."}' ``` ### Resume hosted compute ```http DELETE /api/v3/admin/hosted-compute-pause ``` Site administrators only. New hosted jobs and sandboxes can start again; nothing restarts. Audited as `compute.unpause`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. ```sh curl -X DELETE "https://diffinery.com/api/v3/admin/hosted-compute-pause" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List reports ```http GET /api/v3/admin/reports ``` Site administrators only. Newest first, with the reporter, the account reported, the excerpt kept when it was reported and the resolution. The total is in `x-total-count`. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `open` (default), `resolved` or `all`. | | `per_page` | integer | no | Results per page (default 30, at most 100). | | `page` | integer | no | Page number, starting at 1. | ```sh curl "https://diffinery.com/api/v3/admin/reports" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a report ```http GET /api/v3/admin/reports/{id} ``` Site administrators only. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/admin/reports/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Resolve or reopen a report ```http PATCH /api/v3/admin/reports/{id} ``` Site administrators only. Audited as `report.resolve` or `report.reopen`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | yes | `resolved` or `open`. | | `resolution` | string | no | What you did, for `resolved` (up to 2,000 characters). | ```sh curl -X PATCH "https://diffinery.com/api/v3/admin/reports/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"..."}' ``` ### Get a repository's Actions suspension ```http GET /api/v3/admin/repos/{owner}/{repo}/actions-suspension ``` Site administrators only. `suspended`, `reason`, `suspended_by`, `suspended_at`, and `owner_suspended` when its owner's Actions are suspended. **Access:** A site administrator. Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/admin/repos/acme/website/actions-suspension" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Suspend Actions for a repository ```http PUT /api/v3/admin/repos/{owner}/{repo}/actions-suspension ``` Site administrators only. No runs start, queued jobs fail and no sandboxes open; what runs now stops (`stopped`). Audited as `actions.suspend`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | yes | Why (up to 500 characters). Shown to the people affected and kept in the audit log. | ```sh curl -X PUT "https://diffinery.com/api/v3/admin/repos/acme/website/actions-suspension" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"reason":"..."}' ``` ### Lift a repository's Actions suspension ```http DELETE /api/v3/admin/repos/{owner}/{repo}/actions-suspension ``` Site administrators only. New work can start again; nothing restarts. Audited as `actions.unsuspend`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/admin/repos/acme/website/actions-suspension" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List app ```http GET /api/v3/app ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/app" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List installations ```http GET /api/v3/app/installations ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/app/installations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a installation ```http GET /api/v3/app/installations/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/app/installations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a installation ```http DELETE /api/v3/app/installations/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/app/installations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a access token ```http POST /api/v3/app/installations/{id}/access_tokens ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X POST "https://diffinery.com/api/v3/app/installations/1/access_tokens" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set suspended ```http PUT /api/v3/app/installations/{id}/suspended ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X PUT "https://diffinery.com/api/v3/app/installations/1/suspended" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete suspended ```http DELETE /api/v3/app/installations/{id}/suspended ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/app/installations/1/suspended" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a app ```http GET /api/v3/apps/{slug} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | slug | ```sh curl "https://diffinery.com/api/v3/apps/slug" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List apps ```http GET /api/v3/diffinery/apps ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/apps" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a app ```http POST /api/v3/diffinery/apps ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/apps" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a app (GET /diffinery/apps/slug) ```http GET /api/v3/diffinery/apps/{slug} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | slug | ```sh curl "https://diffinery.com/api/v3/diffinery/apps/slug" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a app ```http PATCH /api/v3/diffinery/apps/{slug} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | slug | ```sh curl -X PATCH "https://diffinery.com/api/v3/diffinery/apps/slug" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a app ```http DELETE /api/v3/diffinery/apps/{slug} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | slug | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/apps/slug" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a installation ```http POST /api/v3/diffinery/apps/{slug}/installations ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | slug | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/apps/slug/installations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List keys ```http GET /api/v3/diffinery/apps/{slug}/keys ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | slug | ```sh curl "https://diffinery.com/api/v3/diffinery/apps/slug/keys" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a key ```http POST /api/v3/diffinery/apps/{slug}/keys ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | slug | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/apps/slug/keys" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a key ```http DELETE /api/v3/diffinery/apps/{slug}/keys/{keyId} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `slug` | string | yes | slug | | `keyId` | string | yes | keyId | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/apps/slug/keys/keyId" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Your auto-merge setting ```http GET /api/v3/diffinery/auto-merge ``` Whether Diffinery turns on auto-merge for the pull requests you open: `{ enabled, merge_method }`, where `merge_method` is `default` (as the branch merges), `merge`, `squash`, `rebase`, or null when off. An agent's token reads the agent's own. See /docs/collaboration/merge-on-green. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope and access to all repositories. ```sh curl "https://diffinery.com/api/v3/diffinery/auto-merge" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Turn auto-merge on or off for pull requests you open ```http PUT /api/v3/diffinery/auto-merge ``` People only. When on, Diffinery turns on auto-merge for you as you open a pull request (or mark your draft ready), wherever its base branch has at least one required check; it merges once every rule of the branch is met, approvals included. The body may also be just `true` or `false`. Returns the setting. Audited as `account.auto_merge`. See /docs/collaboration/merge-on-green. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | boolean | yes | On or off. | | `merge_method` | string | no | `default` (as the branch merges, the default), `merge`, `squash` or `rebase`. | ```sh curl -X PUT "https://diffinery.com/api/v3/diffinery/auto-merge" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"enabled":true}' ``` ### Your email address and whether it is suppressed ```http GET /api/v3/diffinery/email ``` `suppressed` is null, or why Diffinery sends nothing to your address: `reason` (`hard-bounce`, `complaint` or `manual`), `detail`, `source`, `created_at` and `owner_can_lift`. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/email" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Ask for the email that turns email back on ```http POST /api/v3/diffinery/email/verification ``` When your address is suppressed by a bounce or complaint, sends one email to it, past the suppression list; following its link while signed in lifts the suppression. 409 when it isn't suppressed, 403 for a suppression a site administrator added, at most 3 an hour. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/email/verification" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get a installation (GET /diffinery/installations/id) ```http GET /api/v3/diffinery/installations/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/diffinery/installations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Update a installation ```http PATCH /api/v3/diffinery/installations/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X PATCH "https://diffinery.com/api/v3/diffinery/installations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a installation (DELETE /diffinery/installations/id) ```http DELETE /api/v3/diffinery/installations/{id} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/installations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Create a accept permission ```http POST /api/v3/diffinery/installations/{id}/accept_permissions ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/installations/1/accept_permissions" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set suspended (PUT /diffinery/installations/id/suspended) ```http PUT /api/v3/diffinery/installations/{id}/suspended ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X PUT "https://diffinery.com/api/v3/diffinery/installations/1/suspended" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete suspended (DELETE /diffinery/installations/id/suspended) ```http DELETE /api/v3/diffinery/installations/{id}/suspended ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/installations/1/suspended" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List your model keys ```http GET /api/v3/diffinery/model-keys ``` Your saved model API keys for agent tasks: `provider` (`anthropic`, `openai` or `openrouter`), `hint` (the key's last four characters), `opted_in_at`, `created_at` and `updated_at`. The keys themselves are never returned. See /docs/agents/tasks#model-access. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/model-keys" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Save a model key ```http PUT /api/v3/diffinery/model-keys/{provider} ``` From a browser session only: no token, agent, job or sandbox can set a model key. `provider` is `anthropic`, `openai` or `openrouter`. `accept_terms: true` records that you use that provider through Diffinery under your own account and its terms (422 without it). The key is encrypted, never shown again and never sent to a sandbox. Sends you a security email. Audited as `model_key.set`. **Access:** Any active account (a token, or a browser session). No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `provider` | string | yes | provider | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | no | The API key (`sk-ant-...`, `sk-...` or `sk-or-...`). | | `accept_terms` | boolean | no | Must be `true`. | ```sh curl -X PUT "https://diffinery.com/api/v3/diffinery/model-keys/provider" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a model key ```http DELETE /api/v3/diffinery/model-keys/{provider} ``` From a browser session only. Running tasks that use it lose model access. Audited as `model_key.delete`. **Access:** Any active account (a token, or a browser session). No token can call it: it needs a browser session. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `provider` | string | yes | provider | ```sh curl -X DELETE "https://diffinery.com/api/v3/diffinery/model-keys/provider" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get your model usage ```http GET /api/v3/diffinery/model-usage ``` The model usage you paid for over the last `days`, as on Settings → Model access: `days`, `since`, and `repositories`, one line per repository (named as it was when its runs ended) and `kind` (`task`, `review` or `coordinator`) with `runs`, `reported_by_cli_runs`, `requests`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` and `cost_usd` (`null` when no run reported a cost). A token limited to some repositories sees only their lines. See /docs/agents/tasks#usage-and-cost. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `days` | integer | no | 1 to 90 (default 30). | ```sh curl "https://diffinery.com/api/v3/diffinery/model-usage" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Report content to the site administrators ```http POST /api/v3/diffinery/reports ``` Diffinery only. People only, at most 20 a day and one open report per thing. Returns the report (`id`, `kind`, `target`, `html_url`, `reason`, `body`, `state`, `created_at`). Audited as `report.create`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `201`. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `kind` | string | yes | `user`, `repository`, `issue`, `pull_request` or `comment`. | | `login` | string | no | The person, for `user`. | | `repository` | string | no | `owner/name`, for everything but `user`. | | `number` | integer | no | The issue or pull request, for `issue` and `pull_request`. | | `comment_id` | string | no | The comment, for `comment`. | | `reason` | string | yes | `spam`, `harassment`, `hate`, `violence`, `sexual`, `malware`, `impersonation` or `other`. | | `body` | string | no | A note for the administrators (up to 2,000 characters; required for `other`). | ```sh curl -X POST "https://diffinery.com/api/v3/diffinery/reports" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind":"...","reason":"..."}' ``` ### List your agent tasks ```http GET /api/v3/diffinery/tasks ``` Agent tasks you started across repositories where you still have write access, most recently changed first (for an agent's token: the tasks it runs). A token limited to repositories sees only those. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/tasks" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Your morning view ```http GET /api/v3/diffinery/today ``` Pull requests that need you, your agents' open pull requests with their state, and what merged, was released or failed in the last 24 hours, across the repositories you can see that were active in the last 30 days (at most 100). A token limited to repositories sees only those. See /docs/agents/today. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/diffinery/today" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List repositories ```http GET /api/v3/installation/repositories ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/installation/repositories" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete token ```http DELETE /api/v3/installation/token ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. ```sh curl -X DELETE "https://diffinery.com/api/v3/installation/token" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List the people an organization blocked ```http GET /api/v3/orgs/{org}/blocks ``` Organization owners and site administrators. **Access:** An owner of the organization (or a site admin). Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/blocks" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Check whether an organization blocked a person ```http GET /api/v3/orgs/{org}/blocks/{username} ``` `204` if it did, `404` if not. **Access:** An owner of the organization (or a site admin). Tokens need the `read` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `username` | string | yes | username | ```sh curl "https://diffinery.com/api/v3/orgs/acme/blocks/username" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Block a person from an organization ```http PUT /api/v3/orgs/{org}/blocks/{username} ``` As blocking for yourself, for every repository the organization owns. Members can't be blocked. Audited as `org.block`. **Access:** An owner of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `username` | string | yes | username | ```sh curl -X PUT "https://diffinery.com/api/v3/orgs/acme/blocks/username" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Unblock a person from an organization ```http DELETE /api/v3/orgs/{org}/blocks/{username} ``` Audited as `org.unblock`. **Access:** An owner of the organization (or a site admin). Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `username` | string | yes | username | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/blocks/username" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an organization's agent policy ```http GET /api/v3/orgs/{org}/diffinery/agent-policy ``` Owners only (and site administrators); a token limited to repositories gets 403. `keys` (`personal`, `either` or `organization`: whose key pays), `runtimes` (`claude-code`, `codex`, `custom`), `providers` (`anthropic`, `openai`, `openrouter`), `max_running` (tasks at once per repository, 1 to 10), `terminals`, `terminal_tasks`, `review`, `member_monthly_tokens` (`null` for no allowance), `updated_by` and `updated_at`. See /docs/agents/organizations. **Access:** An owner of the organization (or a site admin). Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/diffinery/agent-policy" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Change an organization's agent policy ```http PUT /api/v3/orgs/{org}/diffinery/agent-policy ``` From a browser session only, by an owner. Fields left out stay as they are; answers with the whole policy. 422 when a value is out of range, or no runtime or provider is left allowed. Each repository's tasks at once are held to `max_running` right away. Audited as `org.agent_policy.update`, with the new values. **Access:** An owner of the organization (or a site admin). No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `keys` | string | no | `personal` (each person's own keys), `either` (the organization's keys when it has them) or `organization` (only the organization's keys). | | `runtimes` | array | no | Allowed runtimes: `claude-code`, `codex`, `custom`. At least one. | | `providers` | array | no | Allowed providers: `anthropic`, `openai`, `openrouter`. At least one. | | `max_running` | integer | no | Most tasks at once per repository, 1 to 10. | | `terminals` | boolean | no | Allow web terminals. | | `terminal_tasks` | boolean | no | Let terminals run agent tasks. | | `review` | boolean | no | Allow agent review. | | `member_monthly_tokens` | integer | no | Tokens one person may spend of the organization's keys in a calendar month (UTC), 10,000 to 10,000,000,000, or `null` for no allowance. | ```sh curl -X PUT "https://diffinery.com/api/v3/orgs/acme/diffinery/agent-policy" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List an organization's model keys ```http GET /api/v3/orgs/{org}/diffinery/model-keys ``` Owners only; a token limited to repositories gets 403. Each key as `GET /diffinery/model-keys` shows a personal one: `provider`, `hint` (last four characters), `opted_in_at`, `created_at` and `updated_at`. The keys themselves are never returned. See /docs/agents/organizations. **Access:** An owner of the organization (or a site admin). Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/diffinery/model-keys" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Save an organization's model key ```http PUT /api/v3/orgs/{org}/diffinery/model-keys/{provider} ``` From a browser session with a sign-in from the last 2 hours, by an owner. `provider` is `anthropic`, `openai` or `openrouter`; `accept_terms: true` is required. The key is encrypted, never shown again and never sent to a sandbox. Every owner gets a security email. Audited as `org.model_key.set`. **Access:** An owner of the organization (or a site admin). No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `provider` | string | yes | provider | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `key` | string | no | The API key (`sk-ant-...`, `sk-...` or `sk-or-...`). | | `accept_terms` | boolean | no | Must be `true`. | ```sh curl -X PUT "https://diffinery.com/api/v3/orgs/acme/diffinery/model-keys/provider" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete an organization's model key ```http DELETE /api/v3/orgs/{org}/diffinery/model-keys/{provider} ``` From a browser session with a sign-in from the last 2 hours, by an owner. 404 when the organization has no key for that provider. Audited as `org.model_key.delete`. **Access:** An owner of the organization (or a site admin). No token can call it: it needs a browser session. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | | `provider` | string | yes | provider | ```sh curl -X DELETE "https://diffinery.com/api/v3/orgs/acme/diffinery/model-keys/provider" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an organization's model usage ```http GET /api/v3/orgs/{org}/diffinery/model-usage ``` Owners only; a token limited to repositories gets 403. The runs the organization's keys paid for over the last `days`, shaped like `GET /diffinery/model-usage` with a `person` on each line, and `month`: `month`, `member_monthly_tokens` and `people` (each `person` and their `tokens` on the organization's keys this month). See /docs/agents/organizations#usage. **Access:** An owner of the organization (or a site admin). Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `days` | integer | no | 1 to 90 (default 30). | ```sh curl "https://diffinery.com/api/v3/orgs/acme/diffinery/model-usage" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List installation ```http GET /api/v3/orgs/{org}/installation ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/installation" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an organization's quotas and usage ```http GET /api/v3/orgs/{org}/usage ``` The same as `GET /user/usage`, for the organization's owners and admins. **Access:** An owner or admin of the organization (or a site admin). Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/orgs/acme/usage" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get agent review settings ```http GET /api/v3/repos/{owner}/{repo}/diffinery/agent-review ``` Whether agent review is on, which agent reviews with which runtime, whose model key every review uses (`uses_key_of`), when it reviews (`trigger`: `push`, `open` or `request`), which pull requests (`authors`: `all`, `people` or `agents`), `drafts`, `autofix`, `daily_limit` and `tokens_per_review`. `{"enabled": false}` when it was never turned on. Needs write access. See /docs/agents/review. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/agent-review" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Turn agent review on, change or off ```http PUT /api/v3/repos/{owner}/{repo}/diffinery/agent-review ``` From a browser session only, by a repository admin who can run tasks as the agent and has the runtime's model key: every review on the repository, including ones other people ask for, spends that admin's key, so `accept_terms` must be true. `{"enabled": false}` turns it off. Audited as `review.settings`. **Access:** Admin access to the repository (or to the package). No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `agent` | string | no | The reviewing agent's login (required). | | `runtime` | string | no | `claude-code` (default), `codex` or `custom`. | | `provider` | string | no | Where the runtime's model calls go: its own provider (`anthropic` for Claude Code, `openai` for Codex; the default) or `openrouter`. | | `model` | string | no | With `provider: openrouter`: the OpenRouter model id (default `~anthropic/claude-sonnet-latest` for Claude Code, `~openai/gpt-sol-latest` for Codex). | | `command` | string | no | For `custom`: the shell command; it must end its output with the findings block the prompt describes. | | `trigger` | string | no | `push` (default: on open and every push), `open` (once per pull request) or `request` (only when asked). | | `authors` | string | no | `all` (default), `people` or `agents`. | | `drafts` | boolean | no | Review drafts too (default false). | | `autofix` | boolean | no | Start a fix task on the pull request's branch for blocking findings (default false; at most 3 times per pull request). | | `daily_limit` | integer | no | Most reviews and autofix runs the repository starts in 24 hours, 1 to 1000 (default 50). Past it, pull requests get an action-required check, autofix waits, and you get an email. | | `tokens_per_review` | integer | no | Most tokens one review may use, counted from the provider's responses (cache reads count as a tenth), 100000 to 50000000 (default 2000000). | | `accept_terms` | boolean | no | Must be true: reviews use your model key. | | `enabled` | boolean | no | `false` turns agent review off. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/diffinery/agent-review" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get the agent setup check ```http GET /api/v3/repos/{owner}/{repo}/diffinery/agent-setup ``` The latest Check on Settings → Agent setup, or `check: null` before any: `ok`, `checked_at`, `first_ok_at` (when a check first passed), the `account` and `token` (`name`, `prefix`) that reported it, `cli_version` and the `checks` lines. See [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents). **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/agent-setup" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Report an agent setup check ```http POST /api/v3/repos/{owner}/{repo}/diffinery/agent-setup/checks ``` What the hosted setup script (`/{owner}/{repo}/agent-setup.sh`, run by `diffinery setup --repo`) sends after its self-test: the Check on Settings → Agent setup. Tokens only, with write access to the repository; browser sessions and workflow tokens get 403. The report is green only when every line passed. Returns `ok` and the page's `html_url`. See [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents). **Access:** Write access to the repository. Tokens need the `write` scope. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `checks` | object[] | yes | 1 to 30 lines of `{ name, ok, message }`: what was checked, whether it passed, and what to change when it didn't. | | `cli_version` | string | no | The CLI's version. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/agent-setup/checks" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"checks":"..."}' ``` ### Hide (minimize) a comment ```http PUT /api/v3/repos/{owner}/{repo}/diffinery/comments/{id}/minimized ``` Diffinery only (GitHub's GraphQL `minimizeComment`). Write access, people only. The comment is shown collapsed with its reason. Audited as `comment.minimize`. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | yes | `spam`, `abuse`, `off_topic`, `outdated`, `duplicate` or `resolved` (or GitHub's `classifier`, such as `OFF_TOPIC`). | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/diffinery/comments/1/minimized" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"reason":"..."}' ``` ### Show a hidden comment again ```http DELETE /api/v3/repos/{owner}/{repo}/diffinery/comments/{id}/minimized ``` Diffinery only. Write access, people only. Audited as `comment.unminimize`. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/diffinery/comments/1/minimized" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete closed pull requests ```http POST /api/v3/repos/{owner}/{repo}/diffinery/delete-closed-pulls ``` For starting over with fresh history after rewriting the branches. Permanently deletes every closed and merged pull request (not ones still in the merge queue) with its comments, reviews, review comments, timeline events, reactions and `refs/pull/{number}/*` refs, then purges unreachable objects as `POST .../diffinery/purge-unreachable` does. Issues, open pull requests, branches, tags and workflow runs stay; pull request numbers are not reused. Returns `{ pull_requests, objects, bytes }`. People only, not agents. Repositories of up to 300,000 objects (422 otherwise). From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL. Audited as `repo.delete_closed_pulls`. See /docs/repositories/overview#start-over-with-fresh-history. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/delete-closed-pulls" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get the project ```http GET /api/v3/repos/{owner}/{repo}/diffinery/project ``` The repository's project: `settings` (`max_running`, `instructions`, `plan_first`), `memory` (`body`, `version`, `updated_by`, `updated_at`) and `board`, the agent tasks by column (`waiting`, `working`, `queued`, `review`, `landing`, `done`, `idle`). Agent reviews aren't on the board. Needs write access. See /docs/agents/projects. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/project" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get your coordinator ```http GET /api/v3/repos/{owner}/{repo}/diffinery/project/coordinator ``` Your coordinator's settings in this project: `enabled`, `agent`, `runtime`, `command`, `provider`, `model`, `tokens_per_run`, `tokens_per_task`, `opted_in_at`, `updated_at`. Needs write access. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/coordinator" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Turn on or change your coordinator ```http PUT /api/v3/repos/{owner}/{repo}/diffinery/project/coordinator ``` Your agent then reads your posts and turns them into tasks, on your model key. Each coordinator starts at most 5 tasks per post and 20 per 24 hours in the project. A person in a browser session only. Audited as `project.coordinator`. See /docs/agents/projects#coordinator. **Access:** Write access to the repository. No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `agent` | string | yes | The login of an agent you manage with write access to the repository. | | `runtime` | string | no | `claude-code` (default), `codex` or `custom`. | | `command` | string | no | For `custom`: the shell command. | | `provider` | string | no | `openrouter` to run Claude Code or Codex via OpenRouter. | | `model` | string | no | With `provider: openrouter`: the OpenRouter model id. | | `tokens_per_run` | integer | no | Token budget of each coordinator run, 100,000 to 50,000,000 (default 1,000,000). | | `tokens_per_task` | integer | no | Token budget of each run of a task it starts, 100,000 to 50,000,000 (default 2,000,000). | | `accept` | boolean | yes | Must be `true`: confirms your coordinator and its tasks use your model key. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/coordinator" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"agent":"...","accept":true}' ``` ### Turn off your coordinator ```http DELETE /api/v3/repos/{owner}/{repo}/diffinery/project/coordinator ``` A person in a browser session only. Audited as `project.coordinator`. **Access:** Write access to the repository. No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/coordinator" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List the coordinators in a project ```http GET /api/v3/repos/{owner}/{repo}/diffinery/project/coordinators ``` Every person's coordinator in this project, oldest first: `person`, `agent`, `runtime`, `provider`, `model`, `opted_in_at`. Never their token budgets or custom command. Needs write access. See /docs/agents/projects#coordinator. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/coordinators" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get the project memory ```http GET /api/v3/repos/{owner}/{repo}/diffinery/project/memory ``` The current memory and its last 50 `versions` (`version`, `updated_by`, `updated_at`, `length`). Every agent task reads it as background notes; agent reviews don't. Needs write access. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/memory" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Save or restore the project memory ```http PUT /api/v3/repos/{owner}/{repo}/diffinery/project/memory ``` Saves `body` (at most 40,000 characters) as a new version, or restores an earlier version as a new one with `restore` (404 `no such memory version`). A person in a browser session only. Audited as `project.memory`. **Access:** Write access to the repository. No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `body` | string | no | The new memory (Markdown). | | `restore` | integer | no | A version number to restore instead. | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/memory" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List the project conversation ```http GET /api/v3/repos/{owner}/{repo}/diffinery/project/posts ``` Posts and task events, oldest first: those after `after`, or the latest 100. Needs write access. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `after` | integer | no | Return posts after this id (default 0). | | `per_page` | integer | no | At most 100 (default 100). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/posts" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Post to the project conversation ```http POST /api/v3/repos/{owner}/{repo}/diffinery/project/posts ``` Markdown, at most 20,000 characters (422 otherwise). If you have a coordinator in this project, it handles the post unless you send `coordinator: false`; the response's `coordinator` says whether it did. A person in a browser session only. 403 on an archived repository. **Access:** Write access to the repository. No token can call it: it needs a browser session. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `body` | string | yes | The post. | | `coordinator` | boolean | no | `false` to post without handing it to your coordinator. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/posts" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"..."}' ``` ### Hand a post to your coordinator ```http POST /api/v3/repos/{owner}/{repo}/diffinery/project/posts/{n}/handle ``` Your coordinator handles post `n`, anyone's, on your model key. Each post is handled once. A person in a browser session only. Audited as `project.coordinate`. See /docs/agents/projects#coordinator. **Access:** Write access to the repository. No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `n` | string | yes | n | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/posts/n/handle" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Change the project settings ```http PUT /api/v3/repos/{owner}/{repo}/diffinery/project/settings ``` Project instructions every agent task and agent review gets, how many tasks run at once, and whether tasks plan first. Tasks beyond `max_running` queue and start oldest first. A person in a browser session only. Audited as `project.settings`. **Access:** Admin access to the repository (or to the package). No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `max_running` | integer | no | 1 to 10 (default 3). | | `instructions` | string | no | At most 10,000 characters. | | `plan_first` | string | no | `off` (default: tasks build at once unless started with `plan_first: true`), `default` (tasks plan first unless started with `plan_first: false`) or `required` (every task plans first, including ones coordinators start). | ```sh curl -X PUT "https://diffinery.com/api/v3/repos/acme/website/diffinery/project/settings" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Purge unreachable objects ```http POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable ``` Permanently deletes every commit, tree and file that no branch, tag, pull request, merge queue entry or unfinished workflow run reaches, and empties the archive cache, so a secret force-pushed away can't be fetched by its SHA. Returns `{ objects, bytes }`. People only, not agents. Repositories of up to 300,000 objects (422 otherwise). From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL. Audited as `repo.purge_unreachable`. See /docs/security/push-protection#removing-a-secret-that-was-pushed. **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/purge-unreachable" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List agent tasks ```http GET /api/v3/repos/{owner}/{repo}/diffinery/tasks ``` The repository's agent tasks, newest first. Needs write access: transcripts show an agent's reasoning and command output. `state` filters: `queued`, `running`, `ready`, `failed` or `stopped`, or `active` for running and queued together. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `queued`, `running`, `ready`, `failed`, `stopped` or `active` (running or queued). | | `per_page` | integer | no | At most 100 (default 50). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Start an agent task ```http POST /api/v3/repos/{owner}/{repo}/diffinery/tasks ``` From a browser session only, by a person with write access, as one of their agents (they own it, or administer the organization that owns it) that has write access here: each run spends the person's own model key, so no token can start one. The agent works in a sandbox, then Diffinery pushes `agent/<number>-<slug>` and opens a pull request as the agent. 422 when the runtime needs a model key you haven't saved. Audited as `task.start`. See /docs/agents/tasks. **Access:** Write access to the repository. No token can call it: it needs a browser session. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `goal` | string | no | What to do (required, at most 20,000 characters). | | `runtime` | string | no | `claude-code` (default), `codex` or `custom`. | | `provider` | string | no | Where the runtime's model calls go: its own provider (`anthropic` for Claude Code, `openai` for Codex; the default) or `openrouter`. | | `model` | string | no | With `provider: openrouter`: the OpenRouter model id (default `~anthropic/claude-sonnet-latest` for Claude Code, `~openai/gpt-sol-latest` for Codex). | | `agent` | string | no | The agent's login (required). | | `base` | string | no | Branch to start from (default: the default branch). | | `plan_first` | boolean | no | `true` to plan in a read-only sandbox before building, `false` to build at once. Left out, the project's default; a project that requires plans ignores `false`. See /docs/agents/tasks#plan-first. | | `attempts` | integer | no | 1 (default) to 4: run the first build as that many attempts side by side, then pick one (422 above 1 for a task on a pull request's branch). See /docs/agents/tasks#best-of-n. | | `fix_checks` | boolean | no | Start a fix run when the pull request's checks fail (default `true`, `false` for a task on a pull request's branch). See /docs/agents/tasks#verify-and-land. | | `land` | boolean | no | Turn on auto-merge for its pull request as the task's agent, so it merges once approved and checks pass (default `false`). | | `command` | string | no | For `custom`: the shell command to run. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get an agent task ```http GET /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number} ``` The task, its runs and its pull request. Needs write access. A plan-first task also has `plan_state` (`planning`, `waiting`, `approved` or `split`) and `plan`; a task split from a plan has `parent_task`. A task with more than one attempt has `pick_state` (`running`, `waiting` or `picked`) and `picked_attempt`, and each run its `attempt`. Every task has `fix_checks`, `check_fixes` (fix runs started so far) and `land`, `paid_by` (`person`, or `organization` when an organization's key pays), and `usage` (the model usage of all its runs; each run has its own `usage`, with `source`: `proxy`, or `runtime` for a run in a terminal). See /docs/agents/tasks#usage-and-cost. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Change a task's options ```http PATCH /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number} ``` From a browser session only, by the person who started the task. `fix_checks` starts a fix run when its pull request's checks fail (one per commit, at most 3 per task); turning `land` on turns on auto-merge for an open pull request as the task's agent, under the usual rules for agents. 422 when a value isn't `true` or `false`; 409 `only agent tasks have these options` for an agent review. Audited as `task.options`. See /docs/agents/tasks#verify-and-land. **Access:** Write access to the repository. No token can call it: it needs a browser session. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `fix_checks` | boolean | no | Fix failing checks. | | `land` | boolean | no | Land when approved. | ```sh curl -X PATCH "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Compare a task's attempts ```http GET /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number}/attempts ``` Needs write access. One entry per attempt of a best-of-N task: `attempt`, `run`, `state`, `error`, `branch`, `head_sha`, `changes` (`files`, `additions`, `deletions`), `checks` and `failing_checks` on its branch, `model_requests`, `tokens`, `summary` (its last message) and `compare_url`. See /docs/agents/tasks#best-of-n. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42/attempts" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Pick a task's attempt ```http POST /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number}/attempts/{attempt}/pick ``` From a browser session only, by the person who started the task. The attempt's branch becomes the task's branch, with a pull request; the other attempts' branches are deleted. 409 `the attempts are still running`, `attempt <n> is already picked` or `this task has no attempts to pick from`; 404 `no such attempt`; 422 `attempt <n> has no work to pick: ...` when it changed nothing or didn't finish. Audited as `task.pick`. **Access:** Write access to the repository. No token can call it: it needs a browser session. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | | `attempt` | string | yes | attempt | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42/attempts/attempt/pick" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Send a task a follow-up ```http POST /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number}/messages ``` From a browser session only, by the person who started the task. Starts a new run on the task's branch with the message (while a run is active, it waits and starts when that run ends). Audited as `task.message`. **Access:** Write access to the repository. No token can call it: it needs a browser session. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `body` | string | no | The message. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42/messages" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Approve a task's plan ```http POST /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number}/plan/approve ``` From a browser session only, by the person who started the task. The build run starts with the goal and the plan, and every later run gets the plan as the approved one. 409 `the plan isn't ready yet` while it plans, `this task has no plan waiting for approval`, or `the task is running`. Audited as `task.plan_approve`. See /docs/agents/tasks#plan-first. **Access:** Write access to the repository. No token can call it: it needs a browser session. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42/plan/approve" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Split a task's plan into tasks ```http POST /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number}/plan/split ``` From a browser session only, by the person who started the task. Your coordinator gets the goal and the plan as a post of yours and starts tasks for its parts, each with `parent_task`; this task is then done with. Returns `post`, `task` (the coordinator's run) and `status` (`started` or `queued`). 422 `set up your coordinator for this project first` without a coordinator; the same 409s as approving. Audited as `task.plan_split`. **Access:** Write access to the repository. No token can call it: it needs a browser session. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42/plan/split" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Retry a task ```http POST /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number}/retry ``` From a browser session only, by the person who started the task, when no run is active (409 otherwise). Audited as `task.retry`. **Access:** Write access to the repository. No token can call it: it needs a browser session. **Success:** `201`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42/retry" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Stop a task ```http POST /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number}/stop ``` The person who started it, its agent, or a repository admin. Stops the agent, closes the sandbox (its git credential stops working) and ends the run's model access. Work already pushed stays. Audited as `task.stop`. **Access:** Write access to the repository. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42/stop" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Read a task's transcript ```http GET /api/v3/repos/{owner}/{repo}/diffinery/tasks/{number}/transcript ``` Lines after `after` (a line number), oldest first: `stream` is `out` and `err` (the runtime's output, Claude Code's `stream-json` or Codex's `--json` lines), `setup` (installing and pushing) or `note` (Diffinery's own). Model keys and git credentials are masked. **Access:** Write access to the repository. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | | `number` | string | yes | Issue or pull request number. | Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `after` | integer | no | Return lines after this one. | | `per_page` | integer | no | At most 5000 (default 2000). | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/diffinery/tasks/42/transcript" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List installation (GET /repos/owner/repo/installation) ```http GET /api/v3/repos/{owner}/{repo}/installation ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | ```sh curl "https://diffinery.com/api/v3/repos/acme/website/installation" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Transfer a repository ```http POST /api/v3/repos/{owner}/{repo}/transfer ``` Moves the repository to `new_owner`: the caller's own account, or an organization where the caller is an owner or admin. Needs admin access to the repository (a personal repository: its owner) and a token with the admin scope; agents, job tokens (`GITHUB_TOKEN`) and sandbox tokens are refused. 422 when the new owner already has a repository (or a container image of a moved image's name) with that name. Git data, issues, pull requests, Actions runs, secrets, variables, environments, webhooks and caches move with it, and so do container images linked to it. Direct collaborators keep their access; grants that came from the old organization (its members, their agents, its agents) are dropped, and the new owner's organization secrets and variables apply. The old owner/name keeps working for anyone who can read the repository, as on GitHub: API calls get a 301 (reads) or 307 (anything else) to the new URL, web pages a 301, Git (with a `This repository moved` notice on push) and MCP are served under the old name, and image names resolve to the moved images. Creating a repository at the old name ends the redirect. Admins of both owners get an email. Audited as `repo.transfer`. Returns the repository. From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL (see /docs/security/overview#confirming-its-you). **Access:** Admin access to the repository (or to the package). Tokens need the `admin` scope and access to all repositories. **Success:** `202`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Account (user or organization) that owns the repository or package. | | `repo` | string | yes | Repository name. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `new_owner` | string | yes | Login of the user or organization to transfer to. | | `new_name` | string | no | A new name for the repository (default: keep its name). | ```sh curl -X POST "https://diffinery.com/api/v3/repos/acme/website/transfer" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"new_owner":"..."}' ``` ### List the people you blocked ```http GET /api/v3/user/blocks ``` People only. See [Blocking and reporting](https://diffinery.com/docs/accounts/blocking-and-reporting). **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope and access to all repositories. ```sh curl "https://diffinery.com/api/v3/user/blocks" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Check whether you blocked a person ```http GET /api/v3/user/blocks/{username} ``` `204` if you did, `404` if not. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | username | ```sh curl "https://diffinery.com/api/v3/user/blocks/username" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Block a person ```http PUT /api/v3/user/blocks/{username} ``` In your repositories they (and their agents) can't open issues or pull requests, comment, review or react; they stop being a collaborator, pending invitations between you are cancelled and you get no notifications from them. Audited as `user.block`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | username | ```sh curl -X PUT "https://diffinery.com/api/v3/user/blocks/username" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Unblock a person ```http DELETE /api/v3/user/blocks/{username} ``` Audited as `user.unblock`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | username | ```sh curl -X DELETE "https://diffinery.com/api/v3/user/blocks/username" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List installations (GET /user/installations) ```http GET /api/v3/user/installations ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/user/installations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List repositories (GET /user/installations/id/repositories) ```http GET /api/v3/user/installations/{id}/repositories ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl "https://diffinery.com/api/v3/user/installations/1/repositories" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Set a repository ```http PUT /api/v3/user/installations/{id}/repositories/{repoId} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | | `repoId` | string | yes | repoId | ```sh curl -X PUT "https://diffinery.com/api/v3/user/installations/1/repositories/repoId" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Delete a repository (DELETE /user/installations/id/repositories/repoId) ```http DELETE /api/v3/user/installations/{id}/repositories/{repoId} ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `write` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | | `repoId` | string | yes | repoId | ```sh curl -X DELETE "https://diffinery.com/api/v3/user/installations/1/repositories/repoId" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List your organization memberships ```http GET /api/v3/user/memberships/orgs ``` Active memberships and pending invitations, each with `state` (`active` or `pending`), `role` (GitHub's `admin` or `member`) and `diffinery_role` (`owner`, `admin` or `member`). **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope and access to all repositories. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `active` or `pending`. | ```sh curl "https://diffinery.com/api/v3/user/memberships/orgs" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get your organization membership ```http GET /api/v3/user/memberships/orgs/{org} ``` 404 when you are neither a member nor invited. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | ```sh curl "https://diffinery.com/api/v3/user/memberships/orgs/acme" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Accept an organization invitation ```http PATCH /api/v3/user/memberships/orgs/{org} ``` Send `{"state": "active"}` to join with the role the invitation offers. Audited as `invite.accept`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login. | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | yes | `active`. | ```sh curl -X PATCH "https://diffinery.com/api/v3/user/memberships/orgs/acme" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"state":"..."}' ``` ### List your repository invitations ```http GET /api/v3/user/repository_invitations ``` Pending invitations to repositories, sent to your username or your email address. **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope and access to all repositories. ```sh curl "https://diffinery.com/api/v3/user/repository_invitations" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Accept a repository invitation ```http PATCH /api/v3/user/repository_invitations/{id} ``` You get the role it offers. Audited as `invite.accept`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X PATCH "https://diffinery.com/api/v3/user/repository_invitations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Decline a repository invitation ```http DELETE /api/v3/user/repository_invitations/{id} ``` The invitation is deleted; nobody is told. Audited as `invite.decline`. **Access:** Any active account (a token, or a browser session). Tokens need the `write` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Identifier. | ```sh curl -X DELETE "https://diffinery.com/api/v3/user/repository_invitations/1" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Get your quotas and usage ```http GET /api/v3/user/usage ``` Hosted minutes used this month, hosted jobs running, sandboxes open, repositories owned and bytes stored, each with its `limit` (`-1` is unlimited), and `exempt` for accounts run by a site administrator. An agent gets its owner's. See [Limits](https://diffinery.com/docs/reference/limits#account-quotas). **Access:** Any active account (a token, or a browser session). Tokens need the `read` scope. ```sh curl "https://diffinery.com/api/v3/user/usage" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### List installation (GET /users/username/installation) ```http GET /api/v3/users/{username}/installation ``` **Access:** Read access to the repository. Public repositories allow anyone to read. Tokens need the `read` scope. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | username | ```sh curl "https://diffinery.com/api/v3/users/username/installation" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ### Suspend a person ```http PUT /api/v3/users/{username}/suspended ``` Site administrators only (GitHub Enterprise's endpoint). Their sessions end, their tokens, agents and runners are refused, Actions in their repositories stop, and their profile, repositories and conversations are hidden from everyone else until they are unsuspended. Audited as `user.suspend`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | username | Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | yes | Why (up to 500 characters). Emailed to them and kept in the audit log. | ```sh curl -X PUT "https://diffinery.com/api/v3/users/username/suspended" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"reason":"..."}' ``` ### Unsuspend a person ```http DELETE /api/v3/users/{username}/suspended ``` Site administrators only. Everything comes back, tokens included. Audited as `user.unsuspend`. **Access:** A site administrator. Tokens need the `admin` scope and access to all repositories. **Success:** `204`. Path parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | username | ```sh curl -X DELETE "https://diffinery.com/api/v3/users/username/suspended" \ -H "Authorization: Bearer $DIFFINERY_TOKEN" ``` ## Other HTTP endpoints These aren't part of the JSON API, but clients and tools talk to them directly. | Endpoint | What it is | | --- | --- | | `GET /{owner}/{repo}.git/info/refs` | Git smart HTTP: ref advertisement (`git clone`, `git fetch`, `git push`). Authenticate with a token as the password. See [Git](https://diffinery.com/docs/repositories/git). | | `POST /{owner}/{repo}.git/git-upload-pack` | Git smart HTTP: fetch and clone. | | `POST /{owner}/{repo}.git/git-receive-pack` | Git smart HTTP: push. | | `POST /{owner}/{repo}.git/info/lfs/objects/batch` | Git LFS batch API. See [Git LFS](https://diffinery.com/docs/repositories/git-lfs). | | `GET, PUT /{owner}/{repo}.git/info/lfs/objects/{oid}` | Git LFS object download and upload. | | `GET /{owner}/{repo}/actions/runs/{run_id}/artifacts/{name}` | Download a workflow artifact by name or numeric id, for browsers (the run page links here). Serves a zip, the file itself for an artifact uploaded as a single unzipped file, or a `.tar.gz` for an artifact from an older runner. Expired artifacts aren't served. | | `GET /{owner}/{repo}/agent-setup.sh` | The setup script for a cloud agent environment, from Settings → Agent setup: `curl -fsSL https://diffinery.com/{owner}/{repo}/agent-setup.sh | bash` installs the CLI, wires git and gh to `DIFFINERY_TOKEN`, clones the repository and checks it all. The same script for anyone, with no secret in it. See [Cloud agent environments](https://diffinery.com/docs/connect/cloud-agents). | | `POST /mcp` | The MCP server (Streamable HTTP, JSON-RPC). See [MCP](https://diffinery.com/docs/agents/mcp) and the [MCP tool reference](https://diffinery.com/docs/reference/mcp-tools). | | `GET /.well-known/oauth-protected-resource/mcp` | OAuth protected resource metadata for the MCP server (RFC 9728); also at `/.well-known/oauth-protected-resource`. See [How sign-in works](https://diffinery.com/docs/connect/how-sign-in-works). | | `GET /.well-known/oauth-authorization-server` | OAuth authorization server metadata (RFC 8414) for MCP clients' browser sign-in. | | `POST /oauth/register` | Dynamic client registration for MCP clients (RFC 7591): public clients, or confidential ones with `client_secret_basic` or `client_secret_post`. | | `GET, POST /oauth/authorize` | The consent page where a person connects an MCP client, choosing whom it acts as and its access. Authorization code with PKCE (S256) only. | | `POST /oauth/token` | Trades an authorization code (with its PKCE verifier) or a refresh token for an 8-hour access token and a new refresh token. | | `POST /oauth/revoke` | Revokes a connected app's access or refresh token, ending its connection (RFC 7009). | | `/v2/...` | The OCI container registry (Docker Registry HTTP API v2). See [Container registry](https://diffinery.com/docs/packages/container-registry). | | `GET /.well-known/openid-configuration` | OIDC discovery for workflow ID tokens. See [OIDC](https://diffinery.com/docs/actions/oidc). | | `GET /.well-known/jwks` | Public keys that sign workflow ID tokens. | | `GET /.well-known/security.txt` | Where to report a vulnerability (RFC 9116): this instance's report form first, then any `SECURITY_CONTACT` entries. See [Reporting a vulnerability](https://diffinery.com/docs/security/disclosure). | | `GET /.well-known/mcp-registry-auth` | The proof record that lets this instance's MCP server be published to the official MCP registry under its domain; 404 until `MCP_REGISTRY_AUTH` is set. See [MCP](https://diffinery.com/docs/agents/mcp#list-your-instance-in-the-mcp-registry). | | `GET, POST /security/report` | The private security report form: signed in, or anonymously after Cloudflare Turnstile. Only site administrators can read reports. See [Reporting a vulnerability](https://diffinery.com/docs/security/disclosure). | | `POST /-/email/events` | Signed bounce and complaint intake for the email suppression list; 404 until `MAIL_EVENTS_SECRET` is set. See [Site administration](https://diffinery.com/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | | `GET, POST /-/unsubscribe/{token}` | One-click unsubscribe from a notification email (RFC 8058): a POST with `List-Unsubscribe=One-Click` turns that kind of email off. See [Notifications](https://diffinery.com/docs/accounts/notifications). | | `GET /status` | The status page: each component's status and the incidents site administrators posted, for anyone without signing in. As JSON at `GET /api/v3/diffinery/status`. See [Status page](https://diffinery.com/docs/self-hosting/status-page). | | `GET /healthz` | Health check: `{"ok": true}`. Point an external uptime monitor at it: the status page can't report the whole Worker being down. | | `GET /llms.txt` | Index of these docs for AI agents. See [Docs for AI agents](https://diffinery.com/docs/agents/docs-for-agents). | | `GET /llms-full.txt` | All of these docs in one Markdown file. | | `GET /docs/{page}.md` | Any docs page as Markdown. | | `GET /docs/api/openapi.json` | This reference as an OpenAPI 3.1 document. | --- # CLI reference > Every command and option of the diffinery command-line client, generated from its source. Source: https://diffinery.com/docs/reference/cli This page lists every command of the `diffinery` command-line client (version 0.1.0) with all of its options. It is generated from the CLI's own command definitions, so it always matches `diffinery <command> --help`. ## Install ```sh curl -fsSL https://diffinery.com/install | bash # Linux and macOS diffinery setup # sign in through your browser, set up git and gh ``` On Windows, in PowerShell: `powershell -c "irm https://diffinery.com/install.ps1 | iex"`. The CLI runs on Node.js 22 or later; the script installs one for it when yours is older or missing. `diffinery upgrade` updates it. See [Install and sign in to the CLI](https://diffinery.com/docs/accounts/cli-sign-in). ## Configuration | Setting | Where | Notes | | --- | --- | --- | | Server URL | `DIFFINERY_URL`, else `url` in the config file, else `https://diffinery.com` | Set this for your own Diffinery instance. | | Token | `DIFFINERY_TOKEN`, else `token` in the config file | `diffinery login` signs in through the browser and stores the CLI's token in the config file. | | Config file | `~/.config/diffinery/config.json` (`$XDG_CONFIG_HOME/diffinery/config.json` when set) | Written by `diffinery login`. | Commands that work on a repository take `-R OWNER/NAME`. Without it, the CLI uses the `origin` remote of the git repository you are in, when that remote points at the configured server. Most read commands take `--json` to print the raw API response, for scripts and agents. ## Exit codes | Code | Meaning | | --- | --- | | `0` | Success. | | `1` | The command failed (an API error, a failed check, a failed run when waiting). | | `2` | Usage error: unknown command, bad option or missing argument. Help is printed to stderr. | ## Commands | Command | Summary | | --- | --- | | [`agent`](#diffinery-agent) | Manage agent accounts | | [`api`](#diffinery-api) | Make an authenticated API request and print the response | | [`auth`](#diffinery-auth) | Git and GitHub CLI integration | | [`invite`](#diffinery-invite) | Invite someone by email (to an organization, or to the site as an admin) | | [`issue`](#diffinery-issue) | Work with issues | | [`login`](#diffinery-login) | Sign in through your browser (or with a token) | | [`logout`](#diffinery-logout) | Sign out: forget the saved token, and revoke it if a browser sign-in created it | | [`mcp-config`](#diffinery-mcp-config) | Print an MCP server config snippet for Claude Code and other MCP clients | | [`migrate`](#diffinery-migrate) | Import repositories from other forges | | [`pr`](#diffinery-pr) | Work with pull requests | | [`repo`](#diffinery-repo) | Create, list, view and clone repositories | | [`run`](#diffinery-run) | Inspect and control workflow runs | | [`runner`](#diffinery-runner) | Run self-hosted workflow jobs on this machine | | [`secret`](#diffinery-secret) | Manage Actions secrets | | [`setup`](#diffinery-setup) | Set up this computer in one go: sign in, then git and gh | | [`token`](#diffinery-token) | Manage personal access tokens | | [`upgrade`](#diffinery-upgrade) | Update the CLI to the server's latest build | | [`variable`](#diffinery-variable) | Manage Actions variables | | [`whoami`](#diffinery-whoami) | Show the account the current token acts as | | [`workflow`](#diffinery-workflow) | Run and validate workflows | ### diffinery agent Manage agent accounts. #### diffinery agent create Create an agent account and print its token once. ```text diffinery agent create <login> [options] ``` Creating an agent mints its first token, which tokens can't do: on diffinery.com create agents in the web UI (Settings → Agents); the command works against a local development server. | Option | Description | | --- | --- | | `-o, --org ORG` | Owned by this organization instead of you | | `-n, --name NAME` | Display name | | `-s, --scope SCOPE` | Token scope: read, write (default) or admin Repeatable. | | `-e, --expires-days DAYS` | Token lifetime in days (default 90) | | `--json` | Print raw JSON | #### diffinery agent disable Disable an agent (its tokens stop working). ```text diffinery agent disable <login> ``` #### diffinery agent list List agents you or your organizations own. ```text diffinery agent list [options] ``` | Option | Description | | --- | --- | | `--json` | Print raw JSON | ### diffinery auth Git and GitHub CLI integration. #### diffinery auth git-credential Git credential helper (used by 'auth setup-git'). ```text diffinery auth git-credential <get|store|erase> ``` #### diffinery auth setup-gh Sign the GitHub CLI (gh) in to Diffinery with your token. ```text diffinery auth setup-gh ``` Runs: gh auth login --hostname <host> --with-token --git-protocol https, with the token from the config file or DIFFINERY_TOKEN on its standard input. gh then treats the Diffinery host like GitHub Enterprise Server: gh pr create, view, checks, status, merge and the rest work in clones of Diffinery repositories, and so does the pull request badge of Claude Code, which asks gh for its token. #### diffinery auth setup-git Make git use your Diffinery token for HTTPS remotes. ```text diffinery auth setup-git ``` Runs: git config --global credential.<url>.helper "!diffinery auth git-credential" (after resetting other helpers for that URL), so clone/fetch/push to the Diffinery host authenticate with the token from the config file or DIFFINERY_TOKEN. ### diffinery issue Work with issues. #### diffinery issue close Close an issue. ```text diffinery issue close <number> [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-r, --reason REASON` | completed (default) or not_planned | | `-c, --comment TEXT` | Leave a comment too | | `--json` | Print raw JSON | #### diffinery issue comment Comment on an issue. ```text diffinery issue comment <number> [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-b, --body TEXT` | Body text (Markdown) | | `--body-file FILE` | Read the body from a file ('-' for stdin) | | `--json` | Print raw JSON | #### diffinery issue create Open an issue. ```text diffinery issue create [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-t, --title TEXT` | Title (required) | | `-b, --body TEXT` | Body text (Markdown) | | `--body-file FILE` | Read the body from a file ('-' for stdin) | | `-l, --label NAME` | Add a label Repeatable. | | `-a, --assignee LOGIN` | Assign a login Repeatable. | | `--json` | Print raw JSON | #### diffinery issue list List issues. ```text diffinery issue list [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-s, --state STATE` | open, closed or all (default: open) | | `-l, --label NAME` | Only issues with this label | | `-A, --author LOGIN` | Only issues opened by this login | | `-S, --search QUERY` | Search text | | `-L, --limit N` | Maximum number to show (default 30) | | `--json` | Print raw JSON | #### diffinery issue reopen Reopen an issue. ```text diffinery issue reopen <number> [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-c, --comment TEXT` | Leave a comment too | | `--json` | Print raw JSON | #### diffinery issue view Show an issue and its comments. ```text diffinery issue view <number> [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--json` | Print raw JSON | ### diffinery migrate Import repositories from other forges. #### diffinery migrate github Import a GitHub repository (git data, issues, pull requests, releases and more). ```text diffinery migrate github <GH-OWNER/GH-REPO> [options] ``` Runs a full server-side import: git data (keeping issue and pull-request numbers), labels, milestones, issues and pull requests with comments and reviews, branch protection, Actions variables, release assets and deployment environments. The command follows progress until the import finishes. Secret values are never read from GitHub; their names are listed to set by hand. With --local, runs the old client-side import instead (mirror push plus a best-effort recreation of issues and open pull requests); --issues then also copies labels, issues and comments. GitHub access uses GH_TOKEN or GITHUB_TOKEN (needed for private repositories; GITHUB_API_URL selects a GitHub Enterprise API). | Option | Description | | --- | --- | | `-t, --to OWNER/NAME` | Diffinery repository to create (default: <you>/<gh-repo>) | | `-p, --private` | Create the repository as private regardless of GitHub's visibility | | `-l, --local` | Use the old client-side import (no server importer) | | `-i, --issues` | With --local: also import labels, issues, comments and open pull requests | | `--json` | Print raw JSON | #### diffinery migrate github-org Import many repositories from a GitHub organization or user. ```text diffinery migrate github-org <GH-ORG-OR-USER> [options] ``` Starts a server-side bulk import of every repository in a GitHub organization or user account (or just the ones named with --repos), into a Diffinery organization or user. Each repository is imported in full, the same as "migrate github". The command follows progress until every repository has finished. GitHub access uses GH_TOKEN or GITHUB_TOKEN; GITHUB_API_URL selects a GitHub Enterprise API. | Option | Description | | --- | --- | | `-t, --to OWNER` | Diffinery owner to import into (default: you) | | `--repos A,B,C` | Comma-separated repository names to import (default: all) | | `-p, --private` | Create every repository as private | | `--exclude-forks` | Skip forks | | `--exclude-archived` | Skip archived repositories | | `-c, --concurrency N` | Repositories to import at once, 1–8 (default 3) | | `--json` | Print raw JSON | ### diffinery pr Work with pull requests. #### diffinery pr checks Show check status for a pull request. ```text diffinery pr checks [<number>] [options] ``` Without a number, shows the open pull request for the current branch. With --watch, polls until no check is pending (waiting up to 2 minutes for the first check to appear, as right after a push), printing the checks whenever they change (on a terminal; otherwise once at the end). Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending (never with --watch). | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--watch` | Wait until no check is pending | | `-i, --interval SECONDS` | Seconds between polls with --watch (default 10) | | `--json` | Print raw JSON | #### diffinery pr comment Comment on a pull request. ```text diffinery pr comment [<number>] [options] ``` Without a number, comments on the open pull request for the current branch. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-b, --body TEXT` | Body text (Markdown) | | `--body-file FILE` | Read the body from a file ('-' for stdin) | | `--json` | Print raw JSON | #### diffinery pr create Open a pull request. ```text diffinery pr create [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-t, --title TEXT` | Title (required) | | `-b, --body TEXT` | Body text (Markdown) | | `--body-file FILE` | Read the body from a file ('-' for stdin) | | `-H, --head BRANCH` | Branch with your changes (default: current branch) | | `-B, --base BRANCH` | Branch to merge into (default: the repository's default branch) | | `-d, --draft` | Open as a draft | | `--intent TEXT` | What this change is for, for reviewers (agents should always set it) | | `-r, --reviewer LOGIN` | Request a review from this login Repeatable. | | `--auto` | Turn on auto-merge as it opens: it merges once the base branch's required checks pass and every rule is met | | `--no-auto` | Keep auto-merge off, even if your setting turns it on for pull requests you open | | `--json` | Print raw JSON | #### diffinery pr diff Show a pull request's changes as a unified diff. ```text diffinery pr diff [<number>] [options] ``` Without a number, shows the open pull request for the current branch. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--name-only` | Only list changed file names | #### diffinery pr list List pull requests. ```text diffinery pr list [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-s, --state STATE` | open, closed, merged or all (default: open) | | `-L, --limit N` | Maximum number to show (default 30) | | `-A, --author LOGIN` | Only pull requests opened by this login | | `-H, --head BRANCH` | Only pull requests from this branch | | `--json` | Print raw JSON | #### diffinery pr merge Merge a pull request. ```text diffinery pr merge [<number>] [options] ``` Without a number, merges the open pull request for the current branch. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-m, --merge` | Create a merge commit (default) | | `-s, --squash` | Squash into one commit | | `-r, --rebase` | Rebase the commits onto the base branch | | `-d, --delete-branch` | Delete the head branch after merging | | `-t, --subject TEXT` | Commit title | | `-b, --body TEXT` | Body text (Markdown) | | `--body-file FILE` | Read the body from a file ('-' for stdin) | | `--match-head-commit SHA` | Only merge if the head is still at this SHA | | `--admin` | Merge now, bypassing unmet branch rules and any merge queue (repository admins; audited) | | `--auto` | Turn on auto-merge: merge (or join the merge queue) once every requirement is met | | `--disable-auto` | Turn off auto-merge (and take the pull request out of the merge queue if it is in it) | | `--json` | Print raw JSON | #### diffinery pr ready Mark a draft pull request ready for review. ```text diffinery pr ready [<number>] [options] ``` Without a number, uses the open pull request for the current branch. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--undo` | Convert the pull request back to a draft | | `--json` | Print raw JSON | #### diffinery pr review Approve, request changes or comment on a pull request. ```text diffinery pr review [<number>] [options] ``` Without a number, reviews the open pull request for the current branch. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-a, --approve` | Approve | | `-r, --request-changes` | Request changes | | `-c, --comment` | Comment without approving | | `-b, --body TEXT` | Body text (Markdown) | | `--body-file FILE` | Read the body from a file ('-' for stdin) | | `--json` | Print raw JSON | #### diffinery pr view Show a pull request. ```text diffinery pr view [<number>] [options] ``` Without a number, shows the open pull request for the current branch. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-c, --comments` | Include the conversation | | `--json` | Print raw JSON | ### diffinery repo Create, list, view and clone repositories. #### diffinery repo clone Clone a repository. ```text diffinery repo clone <OWNER/NAME> [directory] ``` The token is passed to this one git command as an HTTP header and is never written to .git/config. Run 'diffinery auth setup-git' so later fetches and pushes authenticate too. #### diffinery repo create Create a repository. ```text diffinery repo create <name> [options] ``` | Option | Description | | --- | --- | | `--org ORG` | Create it in this organization | | `--private` | Only collaborators can see it (default) | | `--public` | Anyone can see it | | `-d, --description TEXT` | Description | | `--default-branch BRANCH` | Default branch name | | `--json` | Print raw JSON | #### diffinery repo list List repositories you can see. ```text diffinery repo list [options] ``` | Option | Description | | --- | --- | | `--owner OWNER` | Only repositories owned by this user or organization | | `--json` | Print raw JSON | #### diffinery repo view Show a repository. ```text diffinery repo view [OWNER/NAME] [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--json` | Print raw JSON | ### diffinery run Inspect and control workflow runs. #### diffinery run cancel Cancel a queued or running run. ```text diffinery run cancel <run-id> [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | #### diffinery run list List workflow runs. ```text diffinery run list [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-b, --branch BRANCH` | Only runs for this branch | | `-w, --workflow FILE` | Only runs of this workflow (file name or path) | | `-s, --status STATUS` | Only runs with this status (queued, in_progress, completed, ...) | | `-e, --event EVENT` | Only runs triggered by this event | | `-c, --commit SHA` | Only runs for this commit SHA | | `-L, --limit N` | Maximum number to show (default 20) | | `--json` | Print raw JSON | #### diffinery run logs Print a job's log. ```text diffinery run logs <job-id> [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--json` | Print raw JSON | #### diffinery run rerun Re-run a workflow run. ```text diffinery run rerun [run-id] [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--failed` | Only re-run the failed jobs and the jobs that depend on them | | `-j, --job JOB-ID` | Re-run this job and the jobs that depend on it (no run id needed) | | `--json` | Print raw JSON | #### diffinery run view Show a run and its jobs. ```text diffinery run view <run-id> [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--json` | Print raw JSON | #### diffinery run watch Follow a run until it completes. ```text diffinery run watch <run-id> [options] ``` Polls every 3 seconds and prints job status changes. Exits non-zero unless the run succeeds. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-i, --interval SECONDS` | Seconds between polls (default 3) | ### diffinery secret Manage Actions secrets. #### diffinery secret delete Delete an Actions secret. ```text diffinery secret delete <NAME> [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-o, --org ORG` | Delete an organization secret instead | #### diffinery secret list List Actions secret names. ```text diffinery secret list [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-o, --org ORG` | List organization secrets instead | | `--json` | Print raw JSON | #### diffinery secret set Create or update an Actions secret. ```text diffinery secret set <NAME> [options] ``` The value comes from --body, from stdin when piped, or a hidden prompt. Repository level by default; --org sets an organization secret. An organization secret reaches every repository unless --visibility (or --repos, which implies selected) says otherwise. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-b, --body VALUE` | Secret value | | `-o, --org ORG` | Set an organization secret instead | | `--visibility all|private|selected` | Organization secrets: which repositories get it (all, private or selected) | | `--repos a,b` | Organization secrets: the repositories that get it, comma separated | ### diffinery token Manage personal access tokens. #### diffinery token create Create a personal access token (printed once). ```text diffinery token create [options] ``` Tokens can't create tokens, so on diffinery.com this needs the web UI (Settings → Access tokens); the command works against a local development server. | Option | Description | | --- | --- | | `-n, --name NAME` | Token name (required) | | `-s, --scope SCOPE` | read, write (default) or admin Repeatable. | | `-e, --expires-days DAYS` | Days until it expires (1-366, default 90) | | `-R, --repo OWNER/NAME` | Restrict the token to this repository Repeatable. | | `--json` | Print raw JSON | #### diffinery token list List your tokens. ```text diffinery token list [options] ``` | Option | Description | | --- | --- | | `--json` | Print raw JSON | #### diffinery token revoke Revoke a token. ```text diffinery token revoke <id> ``` ### diffinery variable Manage Actions variables. #### diffinery variable list List Actions variables. ```text diffinery variable list [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--json` | Print raw JSON | #### diffinery variable set Create or update an Actions variable. ```text diffinery variable set <NAME> [options] ``` The value comes from --body, or from stdin when piped. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-b, --body VALUE` | Variable value | ### diffinery workflow Run and validate workflows. #### diffinery workflow list List the repository's workflows. ```text diffinery workflow list [options] ``` | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `--json` | Print raw JSON | #### diffinery workflow run Start a workflow_dispatch run. ```text diffinery workflow run <workflow-file> [options] ``` The workflow is matched by path (.github/workflows/deploy.yml) or file name (deploy.yml). | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository (default: inferred from the git origin remote) | | `-r, --ref REF` | Branch or tag to run on (default: the default branch) | | `-f, --field KEY=VALUE` | Input value Repeatable. | | `--json` | Print raw JSON | #### diffinery workflow validate Check workflow files for errors. ```text diffinery workflow validate [files or directories...] [options] ``` Default: every .yml/.yaml file in .github/workflows and .diffinery/workflows of the current repository. Uses the same parser as the server. Exits 1 when any file has errors. | Option | Description | | --- | --- | | `--json` | Print raw JSON | ### diffinery api Make an authenticated API request and print the response. ```text diffinery api <METHOD> <PATH> [options] ``` PATH is relative to /api/v3 (e.g. /user, /repos/OWNER/NAME/pulls) unless it starts with /api/. --input sends a JSON body from a file, or from stdin with '-'. | Option | Description | | --- | --- | | `-i, --input FILE` | Request body file, or '-' for stdin | | `-f, --field KEY=VALUE` | Add a string field to the JSON body (or query for GET) Repeatable. | ### diffinery invite Invite someone by email (to an organization, or to the site as an admin). ```text diffinery invite <email> [options] ``` | Option | Description | | --- | --- | | `-o, --org ORG` | Organization to join (omit for a site invite; site admins only) | | `-r, --role ROLE` | member (default), admin or owner | | `--json` | Print raw JSON | ### diffinery login Sign in through your browser (or with a token). ```text diffinery login [options] ``` Opens your browser on the server's authorize page; approve the sign-in there and the CLI gets its own token, valid for 90 days and listed under Settings → Access tokens. On a machine without a browser (over SSH, in a container), --device prints a short code to enter at <server>/login/device from any browser; the CLI picks this by itself when it sees no display. For scripts and CI, pass a token with --token, or pipe one in with --with-token (piping into a plain `diffinery login` still works). The config file is written with mode 0600. | Option | Description | | --- | --- | | `--url URL` | Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com) | | `--scope SCOPE` | Access to ask for: read, write (default) or admin. You can change it on the authorize page | | `--web` | Sign in through the browser on this computer (the default in a terminal) | | `--device` | Sign in with a code you enter in a browser on any device | | `--token TOKEN` | Use this token (dfy_...) instead of signing in through the browser | | `--with-token` | Read a token from stdin | ### diffinery logout Sign out: forget the saved token, and revoke it if a browser sign-in created it. ```text diffinery logout ``` ### diffinery mcp-config Print an MCP server config snippet for Claude Code and other MCP clients. ```text diffinery mcp-config [options] ``` By default the token is written as ${DIFFINERY_TOKEN} (expanded by clients such as Claude Code from the environment). Pass --show-token to embed the saved token instead. | Option | Description | | --- | --- | | `--show-token` | Embed the actual token in the snippet | ### diffinery runner Run self-hosted workflow jobs on this machine. ```text diffinery runner [options] ``` Polls the given repositories for jobs with 'runs-on: [self-hosted, ...]' whose labels this runner offers, and runs each with the Diffinery runner agent. Jobs run directly on this machine with your user's permissions. The runner registers with each repository and claims jobs with its own token, which can't do anything else. Register it with a registration token from a repository admin (POST /repos/OWNER/NAME/actions/runners/registration-token, valid for an hour): diffinery runner -R OWNER/NAME --registration-token drg_... --labels linux so the machine never holds a personal token, which the jobs it runs could read. The runner's credentials are saved under ~/.config/diffinery/runners and used on the next start. Signed in as a repository admin instead, the runner registers itself with your token. --ephemeral registers a runner for one job: its token stops working once it takes the job, and the runner exits when the job finishes. The runner needs the Diffinery runner agent (a single Node.js file). Build it from the Diffinery repository with: pnpm --filter @diffinery/runner build # produces containers/runner/dist/agent.mjs then pass --agent /path/to/agent.mjs or set DIFFINERY_RUNNER_AGENT. | Option | Description | | --- | --- | | `-R, --repo OWNER/NAME` | Repository to take jobs from (default: the git origin) Repeatable. | | `-l, --labels a,b` | Extra labels this runner offers, comma separated ('self-hosted' is implied) | | `-n, --name NAME` | Runner name (default: host name) | | `--once` | Run at most one job, then exit with its exit code | | `-a, --agent PATH` | Path to the runner agent (agent.mjs); default DIFFINERY_RUNNER_AGENT | | `-w, --work-dir DIR` | Working directory for jobs (default ~/.cache/diffinery/runner/<name>) | | `--interval SECONDS` | Seconds between polls when idle (default 5) | | `-q, --quiet` | Do not echo step output locally (logs still go to the server) | | `--registration-token TOKEN` | Register this runner with a repository admin's registration token (one repository) | | `--ephemeral` | Register for a single job, then exit when it finishes | ### diffinery setup Set up this computer in one go: sign in, then git and gh. ```text diffinery setup [options] ``` Signs in through your browser (or with a code, on a machine without one) unless the CLI is signed in already, then runs `diffinery auth setup-git` so git clone, fetch and push use the sign-in, and `diffinery auth setup-gh` when the GitHub CLI (gh) is installed. Where Claude Code is installed, it also adds Claude Code's Diffinery plugins (diffinery and diffinery-ci): they're saved in ~/.claude/diffinery-plugins and named in ~/.claude/settings.json, so every new Claude Code session loads them, with their Diffinery URL setting pointed at this server. Skip that with --no-claude-plugins (or DIFFINERY_NO_CLAUDE_PLUGINS=1). For a server other than diffinery.com (--url, a self-hosted Diffinery), it also points Codex's diffinery MCP server there when Codex is installed (codex mcp add; skip with DIFFINERY_NO_CODEX=1). Last, on a terminal, it asks whether to turn on auto-merge for the pull requests you open (--auto-merge says yes without asking, --no-auto-merge skips the question). Safe to run again; it brings the plugins up to date. With --repo OWNER/NAME it sets up an agent environment instead (a Claude Code cloud environment, Codex, a CI runner), with no browser and no prompt: git and gh use the token in DIFFINERY_TOKEN (and GH_ENTERPRISE_TOKEN with GH_HOST for gh) straight from the environment, so no token is written to disk; the repository is cloned unless it's there already; then a self-test prints one line per check, saying what to change when one fails, and reports the result to the repository's Agent setup page. Exits 1 when a check fails. This is what <server>/OWNER/NAME/agent-setup.sh runs. | Option | Description | | --- | --- | | `--url URL` | Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com) | | `--scope SCOPE` | Access to ask for: read, write (default) or admin. You can change it on the authorize page | | `--web` | Sign in through the browser on this computer (the default in a terminal) | | `--device` | Sign in with a code you enter in a browser on any device | | `--repo OWNER/NAME` | Set up an agent environment for this repository instead, with the token in DIFFINERY_TOKEN (what <server>/OWNER/NAME/agent-setup.sh runs) | | `--no-clone` | With --repo: don't clone the repository (also DIFFINERY_NO_CLONE=1) | | `--clone-dir DIR` | With --repo: where the clone goes (default: DIFFINERY_CLONE_DIR, /home/user in a Claude Code cloud environment, else the current directory) | | `--no-report` | With --repo: don't report the result to the repository's Agent setup page | | `--no-claude-plugins` | Don't add Claude Code's Diffinery plugins (also DIFFINERY_NO_CLAUDE_PLUGINS=1) | | `--auto-merge` | Turn on auto-merge for pull requests you open, without asking (it still waits for each branch's required checks and approvals) | | `--no-auto-merge` | Don't ask about auto-merge | ### diffinery upgrade Update the CLI to the server's latest build. ```text diffinery upgrade ``` Downloads <server>/-/cli/diffinery.mjs and replaces the file this CLI runs from, when it changed. The install script (curl -fsSL https://diffinery.com/install | bash) does the same and sets up PATH. ### diffinery whoami Show the account the current token acts as. ```text diffinery whoami [options] ``` | Option | Description | | --- | --- | | `--json` | Print raw JSON | --- # MCP tool reference > Every tool of the Diffinery MCP server with its arguments, generated from the server's tool list. Source: https://diffinery.com/docs/reference/mcp-tools Every tool the Diffinery MCP server offers, generated from the server's own tool list, so it always matches what `tools/list` returns. For setup in Claude Code, Cursor and other clients, read [MCP](https://diffinery.com/docs/agents/mcp). ## Connection - Endpoint: `https://diffinery.com/mcp` (Streamable HTTP; responses are JSON, not event streams). - Authentication: `Authorization: Bearer dfy_...`, a token for an agent or a person. Every call needs an active account. - Protocol versions: `2025-06-18`, `2025-03-26`, `2024-11-05`. The server answers with the version the client asks for when it supports it, else the newest. - Every tool runs through the same permission checks, branch protection and agent guardrails as git and the REST API. - Errors come back as a tool result with `isError: true` and the message as text. Successful results carry the JSON both as text and as `structuredContent`. ## Tools | Tool | Read-only | Summary | | --- | --- | --- | | [`whoami`](#whoami) | yes | Show the account this token acts as, including the human an agent works on behalf of. | | [`list_repos`](#list_repos) | yes | List repositories visible to you, optionally filtered by owner. | | [`list_tree`](#list_tree) | yes | List files and directories at a path in a repository. | | [`get_file`](#get_file) | yes | Read a file's contents. | | [`list_branches`](#list_branches) | yes | List branches, marking the default and protected ones. | | [`create_branch`](#create_branch) | no | Create a branch from another branch, tag or commit. | | [`commit_files`](#commit_files) | no | Create one commit that writes and deletes files on a branch, without a git client. | | [`compare`](#compare) | yes | Compare two refs: commits ahead/behind and a unified diff. | | [`list_pull_requests`](#list_pull_requests) | yes | List pull requests. | | [`get_pull_request`](#get_pull_request) | yes | Get a pull request with its conversation, reviews, mergeability and (optionally) the full diff. | | [`create_pull_request`](#create_pull_request) | no | Open a pull request. | | [`comment`](#comment) | no | Comment on an issue or pull request. | | [`review_pull_request`](#review_pull_request) | no | Submit a review: APPROVE, REQUEST_CHANGES or COMMENT, with optional line comments [{ path, line, side?, body }]. | | [`merge_pull_request`](#merge_pull_request) | no | Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced). | | [`enable_auto_merge`](#enable_auto_merge) | no | Turn on auto-merge: Diffinery merges the pull request by itself as soon as every branch rule is met (required checks green on the head, approvals from people, linear history), or adds it to the merge queue when the base branch uses one. | | [`disable_auto_merge`](#disable_auto_merge) | no | Turn off auto-merge on a pull request. | | [`dequeue_pull_request`](#dequeue_pull_request) | no | Remove a pull request from its base branch's merge queue. | | [`list_issues`](#list_issues) | yes | List or search issues. | | [`get_issue`](#get_issue) | yes | Get an issue with its comments. | | [`create_issue`](#create_issue) | no | Open an issue. | | [`update_issue`](#update_issue) | no | Update an issue or pull request: title, body, state (open/closed), labels. | | [`list_workflow_runs`](#list_workflow_runs) | yes | List workflow runs, newest first. | | [`get_workflow_run`](#get_workflow_run) | yes | Get a run with its jobs, their status, conclusion and failure reason. | | [`get_job_logs`](#get_job_logs) | yes | Fetch a job's log as plain text. | | [`rerun_workflow`](#rerun_workflow) | no | Re-run a finished workflow run as a new attempt. | | [`rerun_job`](#rerun_job) | no | Re-run one job (one matrix leg) of a finished run, with the jobs that depend on it, as a new attempt of the run; the other jobs keep their results. | | [`cancel_workflow`](#cancel_workflow) | no | Cancel a queued or running workflow run. | | [`dispatch_workflow`](#dispatch_workflow) | no | Start a workflow_dispatch run, e.g. | | [`get_organization`](#get_organization) | yes | Show an organization: its name and, to its members and their agents, the base permission every member gets on its repositories (default_repository_permission: none, read, write or admin). | | [`list_invitations`](#list_invitations) | yes | List pending invitations to a repository (owner and repo, for its admins), an organization (org, for its owners and admins), or the site (neither, for site admins). | | [`revoke_invitation`](#revoke_invitation) | no | Revoke a pending invitation by id (from list_invitations), so it can't be accepted and the address can be invited again. | | [`sandbox_create`](#sandbox_create) | no | Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). | | [`sandbox_exec`](#sandbox_exec) | no | Run a shell command in a sandbox. | | [`sandbox_read_file`](#sandbox_read_file) | no | Read a file inside a sandbox. | | [`sandbox_write_file`](#sandbox_write_file) | no | Write a file inside a sandbox. | | [`sandbox_close`](#sandbox_close) | no | Shut down a sandbox. | | [`list_releases`](#list_releases) | yes | List a repository's releases, newest first. | | [`get_release`](#get_release) | yes | Get a release by tag, by id, or the latest release when neither is given. | | [`create_release`](#create_release) | no | Create a release. | | [`update_release`](#update_release) | no | Edit a release: title, notes, tag, draft or prerelease state. | | [`upload_release_asset`](#upload_release_asset) | no | Attach a file (up to 25 MB) to a release. | | [`generate_release_notes`](#generate_release_notes) | yes | Draft release notes for a tag from the pull requests merged since the previous release. | | [`search_docs`](#search_docs) | yes | Search the Diffinery documentation (workflows, API, git, agents, settings) and the site's product, comparison and FAQ pages. | | [`read_docs`](#read_docs) | yes | Read a Diffinery documentation page as Markdown. | | [`today`](#today) | yes | Your morning view across every repository you can see: pull requests that need you (review requests, agent pull requests waiting for a person's approval, your own blocked ones), what your agents are working on and where each stands, and what merged, was released or failed in the last 24 hours. | | [`agent_tasks`](#agent_tasks) | yes | List agent tasks. | | [`project`](#project) | yes | A repository as a project: its task board grouped by state (waiting on you, working, queued, ready for review, landing, done, idle), the project's memory and its settings (how many tasks run at once, instructions every task gets). | | [`project_posts`](#project_posts) | yes | The project conversation: people's posts and task events, oldest first, after `after` (a post id), or the latest 100. | | [`agent_task`](#agent_task) | yes | One agent task: its goal, state, runs, branch and pull request, and its transcript after `after` (a line number; default from the start, at most 500 lines). | | [`start_github_import`](#start_github_import) | no | Start a server-side import of a GitHub repository into Diffinery: git history (keeping issue and pull-request numbers), issues and pull requests with comments and reviews, labels, milestones, branch protection, Actions variables, releases and deployment environments. | | [`get_import`](#get_import) | yes | Get an import you started: its status, per-step progress, warnings, any error, and (for an organization import) its repositories. | | [`list_imports`](#list_imports) | yes | List the imports you have started, newest first. | ## Identity and repositories ### whoami Show the account this token acts as, including the human an agent works on behalf of. Read-only: yes. No arguments. ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whoami","arguments":{}}} ``` ### list_repos List repositories visible to you, optionally filtered by owner. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_repos","arguments":{}}} ``` ## Code ### list_tree List files and directories at a path in a repository. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `ref` | string | no | Branch, tag or commit (default: default branch) | | `path` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_tree","arguments":{"owner":"acme","repo":"website"}}} ``` ### get_file Read a file's contents. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `path` | string | yes | | | `ref` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_file","arguments":{"owner":"acme","repo":"website","path":"README.md"}}} ``` ### commit_files Create one commit that writes and deletes files on a branch, without a git client. Pass expected_head_sha to avoid racing other writers; pass create_branch_from to start a new branch. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `branch` | string | yes | | | `message` | string | yes | | | `files` | array of object | no | [{ path, content, encoding?: 'utf-8'\|'base64', executable?: boolean }] | | `deletes` | array of string | no | | | `expected_head_sha` | string | no | | | `create_branch_from` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"commit_files","arguments":{"owner":"acme","repo":"website","branch":"main","message":"..."}}} ``` ## Branches and commits ### list_branches List branches, marking the default and protected ones. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_branches","arguments":{"owner":"acme","repo":"website"}}} ``` ### create_branch Create a branch from another branch, tag or commit. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `branch` | string | yes | | | `from` | string | no | Default: the default branch | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_branch","arguments":{"owner":"acme","repo":"website","branch":"main"}}} ``` ### compare Compare two refs: commits ahead/behind and a unified diff. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `base` | string | yes | | | `head` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"compare","arguments":{"owner":"acme","repo":"website","base":"...","head":"..."}}} ``` ## Pull requests ### list_pull_requests List pull requests. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `state` | string (`open`, `closed`, `all`) | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_pull_requests","arguments":{"owner":"acme","repo":"website"}}} ``` ### get_pull_request Get a pull request with its conversation, reviews, mergeability and (optionally) the full diff. `mergeability.blockers` is what stops you merging it yourself now. For agents and workflow tokens, `auto_merge_blockers` is what stops Diffinery landing it through auto-merge or the merge queue (enable_auto_merge): an empty list means it would land now. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | | `include_diff` | boolean | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_pull_request","arguments":{"owner":"acme","repo":"website","number":"42"}}} ``` ### create_pull_request Open a pull request. Use `intent` to explain the goal for reviewers (agents should always set it). `auto_merge`: true turns on auto-merge as it opens (it merges once required checks pass and every rule of the base branch is met), false keeps it off where your own setting would turn it on; `autoMergeSkipped` says why it stayed off when you asked. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `title` | string | yes | | | `body` | string | no | | | `head` | string | yes | | | `base` | string | no | | | `draft` | boolean | no | | | `intent` | string | no | | | `reviewers` | array of string | no | | | `auto_merge` | boolean | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_pull_request","arguments":{"owner":"acme","repo":"website","title":"...","head":"..."}}} ``` ### review_pull_request Submit a review: APPROVE, REQUEST_CHANGES or COMMENT, with optional line comments [{ path, line, side?, body }]. Agent approvals never count toward required human approvals of agent-authored PRs. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | | `event` | string (`APPROVE`, `REQUEST_CHANGES`, `COMMENT`) | yes | | | `body` | string | no | | | `comments` | array of object | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"review_pull_request","arguments":{"owner":"acme","repo":"website","number":"42","event":"..."}}} ``` ### merge_pull_request Merge a pull request when it is mergeable (checks, approvals and branch protection are enforced). When the base branch uses a merge queue, this adds the pull request to the queue instead; it merges once its merge group passes the required checks. Agents can't merge into protected or default branches themselves: use enable_auto_merge there. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | | `method` | string (`merge`, `squash`, `rebase`) | no | | | `expected_head_sha` | string | no | | | `delete_branch` | boolean | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"merge_pull_request","arguments":{"owner":"acme","repo":"website","number":"42"}}} ``` ### enable_auto_merge Turn on auto-merge: Diffinery merges the pull request by itself as soon as every branch rule is met (required checks green on the head, approvals from people, linear history), or adds it to the merge queue when the base branch uses one. This is how an agent lands a change on a protected or default branch: it is allowed only where the branch protection sets allowAgentAutoMerge and has at least one required check, and nothing is bypassed. A push to the branch by anyone else turns it off. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | | `merge_method` | string (`merge`, `squash`, `rebase`) | no | Default: the merge queue's method, squash where linear history is required, else merge. | | `commit_title` | string | no | | | `commit_message` | string | no | | | `delete_branch` | boolean | no | Delete the head branch once it lands (never a protected or default branch). | | `expected_head_sha` | string | no | Only turn it on if the head is still at this commit. | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"enable_auto_merge","arguments":{"owner":"acme","repo":"website","number":"42"}}} ``` ### disable_auto_merge Turn off auto-merge on a pull request. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"disable_auto_merge","arguments":{"owner":"acme","repo":"website","number":"42"}}} ``` ### dequeue_pull_request Remove a pull request from its base branch's merge queue. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"dequeue_pull_request","arguments":{"owner":"acme","repo":"website","number":"42"}}} ``` ## Issues ### comment Comment on an issue or pull request. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | | `body` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"comment","arguments":{"owner":"acme","repo":"website","number":"42","body":"..."}}} ``` ### list_issues List or search issues. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `state` | string (`open`, `closed`, `all`) | no | | | `q` | string | no | | | `label` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_issues","arguments":{"owner":"acme","repo":"website"}}} ``` ### get_issue Get an issue with its comments. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_issue","arguments":{"owner":"acme","repo":"website","number":"42"}}} ``` ### create_issue Open an issue. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `title` | string | yes | | | `body` | string | no | | | `labels` | array of string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_issue","arguments":{"owner":"acme","repo":"website","title":"..."}}} ``` ### update_issue Update an issue or pull request: title, body, state (open/closed), labels. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | | | `title` | string | no | | | `body` | string | no | | | `state` | string (`open`, `closed`) | no | | | `labels` | array of string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"update_issue","arguments":{"owner":"acme","repo":"website","number":"42"}}} ``` ## Actions ### list_workflow_runs List workflow runs, newest first. Filter by branch, commit sha, PR number or status. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `branch` | string | no | | | `sha` | string | no | | | `pr_number` | integer | no | | | `status` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_workflow_runs","arguments":{"owner":"acme","repo":"website"}}} ``` ### get_workflow_run Get a run with its jobs, their status, conclusion and failure reason. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `run_id` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_workflow_run","arguments":{"owner":"acme","repo":"website","run_id":"..."}}} ``` ### get_job_logs Fetch a job's log as plain text. Use `tail` to limit to the last N lines (default 400). Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `job_id` | string | yes | | | `tail` | integer | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_job_logs","arguments":{"owner":"acme","repo":"website","job_id":"..."}}} ``` ### rerun_workflow Re-run a finished workflow run as a new attempt. With `failed_only`, only the jobs that failed, were cancelled or timed out run again (with the jobs that depend on them); the others keep their results. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `run_id` | string | yes | | | `failed_only` | boolean | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rerun_workflow","arguments":{"owner":"acme","repo":"website","run_id":"..."}}} ``` ### rerun_job Re-run one job (one matrix leg) of a finished run, with the jobs that depend on it, as a new attempt of the run; the other jobs keep their results. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `job_id` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rerun_job","arguments":{"owner":"acme","repo":"website","job_id":"..."}}} ``` ### cancel_workflow Cancel a queued or running workflow run. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `run_id` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"cancel_workflow","arguments":{"owner":"acme","repo":"website","run_id":"..."}}} ``` ### dispatch_workflow Start a workflow_dispatch run, e.g. workflow 'deploy.yml' on ref 'main' with inputs. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `workflow` | string | yes | | | `ref` | string | no | | | `inputs` | object | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"dispatch_workflow","arguments":{"owner":"acme","repo":"website","workflow":"ci.yml"}}} ``` ## Releases ### list_releases List a repository's releases, newest first. Drafts are included only for writers. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `limit` | number | no | At most this many (default 30, max 100) | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_releases","arguments":{"owner":"acme","repo":"website"}}} ``` ### get_release Get a release by tag, by id, or the latest release when neither is given. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `tag` | string | no | | | `id` | number | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_release","arguments":{"owner":"acme","repo":"website"}}} ``` ### create_release Create a release. Creates the tag at target_commitish (default branch by default) when it doesn't exist yet. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `tag_name` | string | yes | | | `target_commitish` | string | no | Branch or commit for a new tag | | `name` | string | no | Release title | | `body` | string | no | Release notes (Markdown) | | `draft` | boolean | no | Keep it as a draft, visible to writers only | | `prerelease` | boolean | no | | | `make_latest` | string (`true`, `false`, `legacy`) | no | Whether this becomes the latest release (default true) | | `generate_release_notes` | boolean | no | Fill in notes from merged pull requests since the previous release | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_release","arguments":{"owner":"acme","repo":"website","tag_name":"..."}}} ``` ### update_release Edit a release: title, notes, tag, draft or prerelease state. Publishing a draft sends the release event. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `id` | number | yes | | | `tag_name` | string | no | | | `target_commitish` | string | no | | | `name` | string | no | Release title | | `body` | string | no | Release notes (Markdown) | | `draft` | boolean | no | Keep it as a draft, visible to writers only | | `prerelease` | boolean | no | | | `make_latest` | string (`true`, `false`, `legacy`) | no | Whether this becomes the latest release (default true) | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"update_release","arguments":{"owner":"acme","repo":"website","id":"..."}}} ``` ### upload_release_asset Attach a file (up to 25 MB) to a release. Pass text as content, or bytes as content_base64. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `release_id` | number | yes | | | `name` | string | yes | File name | | `label` | string | no | | | `content_type` | string | no | | | `content` | string | no | | | `content_base64` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"upload_release_asset","arguments":{"owner":"acme","repo":"website","release_id":"...","name":"website"}}} ``` ### generate_release_notes Draft release notes for a tag from the pull requests merged since the previous release. Doesn't create anything. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `tag_name` | string | yes | | | `target_commitish` | string | no | | | `previous_tag_name` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate_release_notes","arguments":{"owner":"acme","repo":"website","tag_name":"..."}}} ``` ## Imports ### start_github_import Start a server-side import of a GitHub repository into Diffinery: git history (keeping issue and pull-request numbers), issues and pull requests with comments and reviews, labels, milestones, branch protection, Actions variables, releases and deployment environments. Secret VALUES are never read from GitHub (only their names are reported, to set by hand), and push protection is never bypassed on an agent's behalf. An agent can import only where its owner may: the owner's own account or an organization the owner administers (an organization's agent: that organization), and only when the agent's policy allows write. Returns the import id; poll it with get_import. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `source` | string | yes | GitHub repository as owner/name or a github.com/Enterprise URL | | `owner` | string | yes | Diffinery user or organization to import into | | `name` | string | no | Target repository name (default: the GitHub name) | | `visibility` | string (`keep`, `private`, `public`) | no | Visibility of the new repository (default: keep GitHub's; GitHub internal repositories become private) | | `github_token` | string | no | A GitHub token to read a private repository (optional) | | `github_api_url` | string | no | GitHub Enterprise REST API base, e.g. https://ghe.example.com/api/v3 (optional) | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"start_github_import","arguments":{"source":"...","owner":"acme"}}} ``` ### get_import Get an import you started: its status, per-step progress, warnings, any error, and (for an organization import) its repositories. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The import id | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_import","arguments":{"id":"..."}}} ``` ### list_imports List the imports you have started, newest first. Read-only: yes. No arguments. ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_imports","arguments":{}}} ``` ## Sandboxes ### sandbox_create Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). Returns a sandbox id. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `ref` | string | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sandbox_create","arguments":{"owner":"acme","repo":"website"}}} ``` ### sandbox_exec Run a shell command in a sandbox. Returns exit code, stdout and stderr. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `sandbox_id` | string | yes | | | `command` | string | yes | | | `cwd` | string | no | | | `timeout_seconds` | integer | no | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sandbox_exec","arguments":{"owner":"acme","repo":"website","sandbox_id":"...","command":"..."}}} ``` ### sandbox_read_file Read a file inside a sandbox. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `sandbox_id` | string | yes | | | `path` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sandbox_read_file","arguments":{"owner":"acme","repo":"website","sandbox_id":"...","path":"README.md"}}} ``` ### sandbox_write_file Write a file inside a sandbox. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `sandbox_id` | string | yes | | | `path` | string | yes | | | `content` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sandbox_write_file","arguments":{"owner":"acme","repo":"website","sandbox_id":"...","path":"README.md","content":"..."}}} ``` ### sandbox_close Shut down a sandbox. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `sandbox_id` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sandbox_close","arguments":{"owner":"acme","repo":"website","sandbox_id":"..."}}} ``` ## Docs ### search_docs Search the Diffinery documentation (workflows, API, git, agents, settings) and the site's product, comparison and FAQ pages. Returns the best matching pages and sections with a snippet; read one with read_docs. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `query` | string | yes | Words to look for, like 'merge queue' or 'workflow_dispatch inputs' | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"..."}}} ``` ### read_docs Read a Diffinery documentation page as Markdown. Pass its slug (like 'actions/workflow-syntax'), or 'index' for the overview, which links to every page. Site pages work too, by path (like '/compare/github' or '/faq'). Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `page` | string | yes | Page slug or docs URL | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_docs","arguments":{"page":"..."}}} ``` ## Other ### get_organization Show an organization: its name and, to its members and their agents, the base permission every member gets on its repositories (default_repository_permission: none, read, write or admin). Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `org` | string | yes | Organization login | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_organization","arguments":{"org":"acme"}}} ``` ### list_invitations List pending invitations to a repository (owner and repo, for its admins), an organization (org, for its owners and admins), or the site (neither, for site admins). Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | no | Repository owner (user or org login) | | `repo` | string | no | Repository name | | `org` | string | no | Organization login | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_invitations","arguments":{}}} ``` ### revoke_invitation Revoke a pending invitation by id (from list_invitations), so it can't be accepted and the address can be invited again. Takes the same owner and repo, org, or neither as list_invitations. Read-only: no. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | no | Repository owner (user or org login) | | `repo` | string | no | Repository name | | `org` | string | no | Organization login | | `invitation_id` | string | yes | | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"revoke_invitation","arguments":{"invitation_id":"..."}}} ``` ### today Your morning view across every repository you can see: pull requests that need you (review requests, agent pull requests waiting for a person's approval, your own blocked ones), what your agents are working on and where each stands, and what merged, was released or failed in the last 24 hours. Read-only: yes. No arguments. ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"today","arguments":{}}} ``` ### agent_tasks List agent tasks. With owner and repo: that repository's tasks (you need write access). Without: the tasks you started (for an agent: the tasks it runs). Starting, steering and retrying tasks happens on the web, by the person whose model key it spends. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | no | Repository owner (user or org login) | | `repo` | string | no | Repository name | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"agent_tasks","arguments":{}}} ``` ### project A repository as a project: its task board grouped by state (waiting on you, working, queued, ready for review, landing, done, idle), the project's memory and its settings (how many tasks run at once, instructions every task gets). You need write access. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"project","arguments":{"owner":"acme","repo":"website"}}} ``` ### project_posts The project conversation: people's posts and task events, oldest first, after `after` (a post id), or the latest 100. Posting happens on the web, by a person. Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `after` | integer | no | Return posts after this id | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"project_posts","arguments":{"owner":"acme","repo":"website"}}} ``` ### agent_task One agent task: its goal, state, runs, branch and pull request, and its transcript after `after` (a line number; default from the start, at most 500 lines). Read-only: yes. | Argument | Type | Required | Description | | --- | --- | --- | --- | | `owner` | string | yes | Repository owner (user or org login) | | `repo` | string | yes | Repository name | | `number` | integer | yes | Task number | | `after` | integer | no | Return transcript lines after this one | ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"agent_task","arguments":{"owner":"acme","repo":"website","number":"42"}}} ``` --- # Webhook events > Reference of every webhook event and action Diffinery sends, with the exact payload fields and example payloads. Source: https://diffinery.com/docs/reference/webhook-events This page lists every event a Diffinery [webhook](https://diffinery.com/docs/repositories/webhooks) can deliver, the actions of each event, and the exact payload fields, with an example for each. Use it to write or adapt a webhook receiver. There are two kinds of payload. `push`, `pull_request`, `pull_request_review`, `issues`, `issue_comment` and `workflow_run` have small Diffinery payloads with ids and links. `release`, `status`, `deployment`, `deployment_status`, `deployment_review`, `repository_dispatch` and `workflow_dispatch` have GitHub's payload structure. ## Common structure Every delivery is an HTTPS `POST` with a compact JSON body and these headers: | Header | Value | |---|---| | `X-GitHub-Event` | The event name from this page, for example `pull_request` | | `X-Diffinery-Event` | The same event name | | `X-GitHub-Delivery` | A unique delivery id (32 hexadecimal characters). Retries of a failed delivery keep the same id. | | `X-Hub-Signature-256` | `sha256=` and the hex HMAC-SHA256 of the body with the webhook's secret | | `User-Agent` | `Diffinery-Hookshot/1` | | `Content-Type` | `application/json` | Every payload contains: | Field | Type | Description | |---|---|---| | `action` | string | The action, for events that have one. Absent for `push`, `status` and `workflow_dispatch`. | | `at` | string | ISO 8601 time the event happened. In the small payloads and in `workflow_dispatch`; `release`, `status`, `deployment`, `deployment_status`, `deployment_review` and `repository_dispatch` have no `at` (use their own `created_at` and `updated_at` fields). | | `repository` | object | The repository (see below) | | `sender` | object | The account that caused the event (see below) | A delivery that fails with a network error, a timeout (10 seconds), `408`, `429` or a `5xx` status is sent again up to five times, 1 minute, 5 minutes, 30 minutes, 1 hour and 3 hours after each failed attempt, with the same id and body. See [Webhooks](https://diffinery.com/docs/repositories/webhooks#retries). Deliveries to an [app's](https://diffinery.com/docs/repositories/apps) webhook also carry `installation` (`id` and `node_id`): the installation they came through. Apps also get `installation` events (`created`, `deleted`, `suspend`, `unsuspend`, `new_permissions_accepted`, with the installation object and, when created, its `repositories`) and `installation_repositories` events (`added`, `removed`, with `repositories_added` and `repositories_removed`), shaped like GitHub's. The other top-level fields depend on the event. In the small payloads, Diffinery-specific fields use camelCase (`headRef`, `headSha`, `baseRef`, `commentId`, `runId`), and GitHub-style objects (`pull_request`, `issue`) are added where GitHub has them. ### The repository object `repository` is GitHub's full repository object, the same shape `GET /api/v3/repos/{owner}/{repo}` returns, so receivers that read `repository.private`, `repository.visibility`, `repository.owner` or `repository.default_branch` work unchanged: ```json { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "private": false, "owner": { "login": "acme", "id": 3915577218345164, "node_id": "T3JnYW5pemF0aW9uOjdlNmQ1YzRiM2EyZjFlMGQ5YzhiN2E2ZjVlNGQzYzJi", "avatar_url": "https://diffinery.com/api/avatars/acme", "gravatar_id": "", "url": "https://diffinery.com/api/v3/users/acme", "html_url": "https://diffinery.com/acme", "repos_url": "https://diffinery.com/api/v3/users/acme/repos", "organizations_url": "https://diffinery.com/api/v3/users/acme/orgs", "type": "Organization", "user_view_type": "public", "site_admin": false }, "html_url": "https://diffinery.com/acme/website", "description": "The acme.example website", "fork": false, "url": "https://diffinery.com/api/v3/repos/acme/website", "archive_url": "https://diffinery.com/api/v3/repos/acme/website/{archive_format}{/ref}", "branches_url": "https://diffinery.com/api/v3/repos/acme/website/branches{/branch}", "commits_url": "https://diffinery.com/api/v3/repos/acme/website/commits{/sha}", "compare_url": "https://diffinery.com/api/v3/repos/acme/website/compare/{base}...{head}", "contents_url": "https://diffinery.com/api/v3/repos/acme/website/contents/{+path}", "deployments_url": "https://diffinery.com/api/v3/repos/acme/website/deployments", "git_refs_url": "https://diffinery.com/api/v3/repos/acme/website/git/refs{/sha}", "hooks_url": "https://diffinery.com/api/v3/repos/acme/website/hooks", "issues_url": "https://diffinery.com/api/v3/repos/acme/website/issues{/number}", "labels_url": "https://diffinery.com/api/v3/repos/acme/website/labels{/name}", "languages_url": "https://diffinery.com/api/v3/repos/acme/website/languages", "merges_url": "https://diffinery.com/api/v3/repos/acme/website/merges", "pulls_url": "https://diffinery.com/api/v3/repos/acme/website/pulls{/number}", "releases_url": "https://diffinery.com/api/v3/repos/acme/website/releases{/id}", "statuses_url": "https://diffinery.com/api/v3/repos/acme/website/statuses/{sha}", "tags_url": "https://diffinery.com/api/v3/repos/acme/website/tags", "git_url": "https://diffinery.com/acme/website.git", "ssh_url": "https://diffinery.com/acme/website.git", "clone_url": "https://diffinery.com/acme/website.git", "svn_url": "https://diffinery.com/acme/website", "homepage": null, "size": 0, "stargazers_count": 0, "watchers_count": 0, "language": null, "has_issues": true, "has_projects": false, "has_downloads": true, "has_wiki": false, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": null, "allow_forking": false, "is_template": false, "web_commit_signoff_required": false, "topics": [ "website" ], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main", "created_at": "2026-03-02T14:20:11Z", "updated_at": "2026-10-12T16:03:40Z", "pushed_at": "2026-10-13T09:30:00Z" } ``` | Field | Description | |---|---| | `id` | The repository's numeric id, the same number the REST API returns. It does not change when the repository is renamed or transferred, so use it rather than `full_name` to recognize a repository. | | `node_id` | An opaque string id for the repository, as in the REST API | | `name`, `full_name` | Repository name, and `owner/name` | | `private` | `true` for a private repository, `false` for a public one | | `visibility` | `public` or `private`: the repository's real visibility when the event is delivered | | `owner` | The owning account as a user object: `login`, numeric `id`, `node_id`, `type` (`User` or `Organization`), `html_url`, `avatar_url` and API URLs | | `description` | The repository's description, or `null` | | `default_branch` | The default branch, for example `main` | | `archived` | `true` when the repository is archived | | `topics` | The repository's topics | | `html_url`, `clone_url`, `git_url`, `ssh_url`, `svn_url` | Web address and clone URLs (all HTTPS: Diffinery has no SSH or git protocol access) | | `url` and the `*_url` templates | REST API addresses, as on GitHub | | `created_at`, `updated_at`, `pushed_at` | ISO 8601 times | Fields GitHub fills with counts (`size`, `stargazers_count`, `watchers_count`, `forks_count`, `open_issues_count` and their short forms) are always `0` in webhook payloads, `fork` and `allow_forking` are `false`, and there is no `permissions` object. If the repository was deleted before the delivery was sent, `repository` falls back to a minimal object (`id`, `node_id`, `name`, `full_name`, `owner.login`, `html_url`, `clone_url`) with `"private": true` and `"visibility": "private"`. To keep them short, the examples on this page show `repository` with only a few of its fields. ### The sender object A person: ```json { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" } ``` An agent, with the login of the person or organization it acts for: ```json { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "diffy" } ``` | Field | Description | |---|---| | `id` | Account id (32 hexadecimal characters, unlike the numeric `repository.id`), or an id such as `system:merge-queue` for Diffinery's own actors | | `login` | Login of the account | | `type` | `User` for people, `Bot` for agents and for Diffinery's own actors | | `on_behalf_of` | Only for agents: the login of the agent's owner | Diffinery's own actors appear as `Bot` senders: `diffinery-merge-queue[bot]` (merge queue), `diffinery-scheduler` (scheduled workflow runs), and `diffinery-actions[bot]` with an id such as `job:2f1e0d9c8b7a69584736251403f2e1d0` for what a workflow job does with its `GITHUB_TOKEN` (opening an issue, posting a status, creating a release, ...). ## push Sent when a branch or tag is created, moved or deleted. This includes `git push`, commits from the web editor, the contents API, the Git data API (`git/refs`) and MCP, merges of pull requests (a push to the base branch), updating a pull request's branch, the merge queue's own `gh-readonly-queue/...` branches, and tags created by publishing a release. | Field | Type | Description | |---|---|---| | `ref` | string | The full ref, for example `refs/heads/main` or `refs/tags/v1.2.0` | | `before` | string | Commit SHA the ref pointed to before; 40 zeros when the ref was created | | `after` | string | Commit SHA the ref points to now; 40 zeros when the ref was deleted | | `forced` | boolean | `true` when the update was not a fast-forward | | `created` | boolean | `true` when the ref was created | | `deleted` | boolean | `true` when the ref was deleted | | `at` | string | Time of the push | There is no `action`, no `commits` list, no `head_commit` and no `pusher`; `sender` is the pusher. Use `GET /api/v3/repos/{owner}/{repo}/compare/{before}...{after}` to get the commits. ```json { "ref": "refs/heads/main", "before": "6f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6", "after": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d", "forced": false, "created": false, "deleted": false, "at": "2026-10-13T09:30:00.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" } } ``` A new branch: ```json { "ref": "refs/heads/fix-typo", "before": "0000000000000000000000000000000000000000", "after": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d", "forced": false, "created": true, "deleted": false, "at": "2026-10-13T09:31:12.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "diffy" } } ``` ## pull_request Sent for pull request activity. | Action | Sent when | Sender | |---|---|---| | `opened` | A pull request is created | Its author | | `synchronize` | The head branch moved to a new commit | The pusher | | `edited` | The title or description changed (web UI, `PATCH /pulls/{number}`, or the `update_issue` MCP tool). Not sent when the new text equals the old, or for label changes. | Who edited it | | `ready_for_review` | A draft is marked ready for review (**Ready for review**, `diffinery pr ready`, or `PATCH` with `"draft": false`) | Who marked it | | `converted_to_draft` | An open pull request is turned back into a draft (`diffinery pr ready --undo`, or `PATCH` with `"draft": true`). The pull request's timeline records this as `convert_to_draft`, GitHub's timeline name; the webhook action is `converted_to_draft`, as on GitHub. | Who converted it | | `closed` | The pull request is closed without merging (`pull_request.merged` is `false`), or is merged (`pull_request.merged` is `true`) | Who closed or merged it; for a merge by the merge queue, who added it to the queue | | `reopened` | A closed pull request is reopened | Who reopened it | | `assigned` | One or more people are newly assigned | Who assigned them | | `review_requested` | One or more reviewers are newly requested | Who requested the review | | `enqueued` | The pull request was added to a merge queue | Who added it | | `dequeued` | The pull request was removed from the merge queue without merging: by a person, or because its merge group failed, conflicted or timed out | Who removed it, or `diffinery-merge-queue[bot]` | | `auto_merge_enabled` | [Auto-merge](https://diffinery.com/docs/collaboration/pull-requests#auto-merge) was turned on, or its method changed | Who turned it on | | `auto_merge_disabled` | Auto-merge was turned off: by someone, by a push from someone else, by converting the pull request to a draft, or by itself because it can't go through | Who turned it off, the pusher, or `diffinery-auto-merge[bot]` | | Field | Type | Description | |---|---|---| | `action` | string | One of the actions above | | `number` | number | The pull request number | | `headRef` | string | Head branch name | | `headSha` | string | Head commit SHA | | `baseRef` | string | Base branch name | | `at` | string | Time of the event | | `assignees` | array of strings | Only for `assigned`: logins newly assigned | | `reviewers` | array of strings | Only for `review_requested`: logins newly requested | | `pull_request.number` | number | The pull request number | | `pull_request.html_url` | string | Web address of the pull request | | `pull_request.head.ref` | string | Head branch name | | `pull_request.head.sha` | string | Head commit SHA | | `pull_request.base.ref` | string | Base branch name | | `pull_request.merged` | boolean | `true` only when the pull request was merged (action `closed`) | The payload does not include the title, body, author, labels or draft state, and `edited` has no `changes` object with the old values. Fetch the current values with `GET /api/v3/repos/{owner}/{repo}/pulls/{number}`. Opened: ```json { "action": "opened", "number": 42, "headRef": "fix-typo", "headSha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d", "baseRef": "main", "at": "2026-10-13T09:32:40.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "diffy" }, "pull_request": { "number": 42, "html_url": "https://diffinery.com/acme/website/pull/42", "head": { "ref": "fix-typo", "sha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d" }, "base": { "ref": "main" }, "merged": false } } ``` Merged (delivered as `closed`): ```json { "action": "closed", "number": 42, "headRef": "fix-typo", "headSha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d", "baseRef": "main", "at": "2026-10-13T10:05:11.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" }, "pull_request": { "number": 42, "html_url": "https://diffinery.com/acme/website/pull/42", "head": { "ref": "fix-typo", "sha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d" }, "base": { "ref": "main" }, "merged": true } } ``` A merge also sends a `push` event for the base branch. Review requested: ```json { "action": "review_requested", "number": 42, "headRef": "fix-typo", "headSha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d", "baseRef": "main", "at": "2026-10-13T09:33:05.000Z", "reviewers": ["diffy"], "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "diffy" }, "pull_request": { "number": 42, "html_url": "https://diffinery.com/acme/website/pull/42", "head": { "ref": "fix-typo", "sha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d" }, "base": { "ref": "main" }, "merged": false } } ``` The `assigned` payload has the same shape with `"assignees": ["diffy"]` instead of `reviewers`. Ready for review (`edited` and `converted_to_draft` look the same apart from `action`): ```json { "action": "ready_for_review", "number": 42, "headRef": "fix-typo", "headSha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d", "baseRef": "main", "at": "2026-10-13T09:45:10.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" }, "pull_request": { "number": 42, "html_url": "https://diffinery.com/acme/website/pull/42", "head": { "ref": "fix-typo", "sha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d" }, "base": { "ref": "main" }, "merged": false } } ``` ## pull_request_review Sent when someone submits a review on a pull request. | Action | Sent when | |---|---| | `submitted` | A review is submitted (approve, request changes, or comment) | | Field | Type | Description | |---|---|---| | `action` | string | `submitted` | | `number` | number | The pull request number | | `state` | string | `APPROVED`, `CHANGES_REQUESTED` or `COMMENTED` (uppercase) | | `at` | string | Time of the review | There is no `review` or `pull_request` object, and the review body is not included. Get the review with `GET /api/v3/repos/{owner}/{repo}/pulls/{number}/reviews`. GitHub sends `review.state` in lowercase; Diffinery's top-level `state` is uppercase. ```json { "action": "submitted", "number": 42, "state": "APPROVED", "at": "2026-10-13T09:50:00.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" } } ``` ## issues Sent for issue activity. Pull requests send `pull_request` events instead. | Action | Sent when | |---|---| | `opened` | An issue is created | | `edited` | The title or body changed (web UI, `PATCH /issues/{number}`, or the `update_issue` MCP tool) | | `closed` | An issue is closed, including by a merged pull request that says `fixes #12` | | `reopened` | A closed issue is reopened | | `assigned` | One or more people are newly assigned | Saving the same title and body again sends nothing, and changing labels sends no event. `edited` has no `changes` object with the old values; read the issue with the REST API for the current ones. | Field | Type | Description | |---|---|---| | `action` | string | One of the actions above | | `number` | number | The issue number | | `at` | string | Time of the event | | `assignees` | array of strings | Only for `assigned`: logins newly assigned | | `issue.number` | number | The issue number | | `issue.html_url` | string | Web address of the issue | ```json { "action": "opened", "number": 12, "at": "2026-10-13T08:15:00.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" }, "issue": { "number": 12, "html_url": "https://diffinery.com/acme/website/issues/12" } } ``` ```json { "action": "assigned", "number": 12, "at": "2026-10-13T08:20:00.000Z", "assignees": ["review-bot"], "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" }, "issue": { "number": 12, "html_url": "https://diffinery.com/acme/website/issues/12" } } ``` ## issue_comment Sent when a comment is added to an issue or to a pull request's conversation. Line comments inside a review do not send this event. | Action | Sent when | |---|---| | `created` | A comment is added | | Field | Type | Description | |---|---|---| | `action` | string | `created` | | `number` | number | The issue or pull request number | | `commentId` | string | The comment's id | | `at` | string | Time of the comment | | `issue.number` | number | The issue or pull request number | | `issue.html_url` | string | `https://diffinery.com/{owner}/{repo}/issues/{number}`; for a pull request this address redirects to the pull request | The comment body is not included. Read it with `GET /api/v3/repos/{owner}/{repo}/issues/{number}/comments` and find the entry whose `id` equals `commentId`. Comments posted by `diffinery-merge-queue[bot]` (for example when a pull request leaves the queue) also send this event. ```json { "action": "created", "number": 42, "commentId": "3e2d1c0b9a8f4e7d6c5b4a3f2e1d0c9b", "at": "2026-10-13T09:40:00.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "diffy" }, "issue": { "number": 42, "html_url": "https://diffinery.com/acme/website/issues/42" } } ``` ## workflow_run Sent when a [Diffinery Actions](https://diffinery.com/docs/actions/overview) workflow run is created and when it finishes. | Action | Sent when | |---|---| | `requested` | A run is created, for any trigger (push, pull request, schedule, manual dispatch, merge queue, workflow run, release, repository dispatch) | | `completed` | The run finished | | Field | Type | Description | |---|---|---| | `action` | string | `requested` or `completed` | | `runId` | string | The run id. The run is at `https://diffinery.com/{owner}/{repo}/actions/runs/{runId}` and in the API at `GET /api/v3/repos/{owner}/{repo}/actions/runs/{runId}`. | | `conclusion` | string | Only for `completed`: `success`, `failure` or `cancelled` | | `at` | string | Time of the event | The sender is the account that triggered the run; for scheduled runs it is `diffinery-scheduler`. The sender of this event has no `on_behalf_of` field, even for agents. There is no `workflow_run` object: fetch the run for its workflow, branch and SHA. ```json { "action": "completed", "runId": "8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a", "conclusion": "success", "at": "2026-10-13T09:36:21.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" } } ``` ## release Sent for release activity, including activity on drafts. See [Releases](https://diffinery.com/docs/collaboration/releases). | Action | Sent when | |---|---| | `created` | A release is created, as a draft or published | | `published` | A release is published: created as published, or a draft is published | | `released` | A full (not pre-release) release is published, or a published pre-release becomes a full release | | `prereleased` | A pre-release is published, or a published release becomes a pre-release | | `edited` | A release is edited without publishing or unpublishing it (also for drafts) | | `unpublished` | A published release is turned back into a draft | | `deleted` | A release is deleted | One change can send several deliveries, in this order. Creating a published release sends `created`, `published` and then `released` (or `prereleased`). Publishing a draft sends `published` and `released` (or `prereleased`). Editing a published release's pre-release flag sends `edited` and `released` (or `prereleased`). Uploading, renaming or deleting assets sends nothing. | Field | Type | Description | |---|---|---| | `action` | string | One of the actions above | | `release` | object | The release, exactly as `GET /api/v3/repos/{owner}/{repo}/releases/{id}` returns it, including `assets`, `upload_url`, `author` and Diffinery's `latest` | | `repository` | object | The repository | | `sender` | object | Who made the change | The `release.reactions` counts in webhook payloads are always zero; read reactions with the REST API. ```json { "action": "published", "release": { "url": "https://diffinery.com/api/v3/repos/acme/website/releases/7", "assets_url": "https://diffinery.com/api/v3/repos/acme/website/releases/7/assets", "upload_url": "https://diffinery.com/api/uploads/repos/acme/website/releases/7/assets{?name,label}", "html_url": "https://diffinery.com/acme/website/releases/tag/v1.2.0", "id": 7, "author": { "login": "diffy", "id": 7220136606554077, "node_id": "VXNlcjphMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MA", "avatar_url": "https://diffinery.com/api/avatars/diffy", "gravatar_id": "", "url": "https://diffinery.com/api/v3/users/diffy", "html_url": "https://diffinery.com/diffy", "repos_url": "https://diffinery.com/api/v3/users/diffy/repos", "organizations_url": "https://diffinery.com/api/v3/users/diffy/orgs", "type": "User", "user_view_type": "public", "site_admin": false }, "node_id": "UmVsZWFzZTo3", "tag_name": "v1.2.0", "target_commitish": "main", "name": "Website 1.2.0", "draft": false, "immutable": false, "prerelease": false, "created_at": "2026-10-13T10:00:00Z", "updated_at": "2026-10-13T10:00:00Z", "published_at": "2026-10-13T10:00:00Z", "assets": [], "tarball_url": "https://diffinery.com/api/v3/repos/acme/website/tarball/v1.2.0", "zipball_url": "https://diffinery.com/api/v3/repos/acme/website/zipball/v1.2.0", "body": "## What's Changed\n* Fix broken footer links by @diffy in https://diffinery.com/acme/website/pull/41\n\n**Full Changelog**: https://diffinery.com/acme/website/compare/v1.1.0...v1.2.0", "reactions": { "url": "https://diffinery.com/api/v3/repos/acme/website/releases/7/reactions", "total_count": 0, "+1": 0, "-1": 0, "laugh": 0, "confused": 0, "heart": 0, "hooray": 0, "rocket": 0, "eyes": 0 }, "latest": true }, "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" } } ``` > **Note:** Webhooks receive events for drafts, which only people with the write role can see in the web UI and API. Treat release payloads from private repositories, and drafts in public ones, as private. ## status Sent every time a commit status is posted with `POST /repos/{owner}/{repo}/statuses/{sha}`. There is no `action`. See [Checks and commit statuses](https://diffinery.com/docs/collaboration/checks-and-statuses). | Field | Type | Description | |---|---|---| | `id` | number | The status id | | `sha` | string | The commit | | `name` | string | The repository's full name, `owner/name` | | `state` | string | `pending`, `success`, `failure` or `error` | | `context` | string | The status context (`default` when none was given) | | `description` | string or null | The description | | `target_url` | string or null | The link to the build | | `commit.sha` | string | The commit, again | | `created_at`, `updated_at` | string | When the status was posted (ISO 8601 with milliseconds) | ```json { "id": 17, "sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d", "name": "acme/website", "state": "success", "context": "ci/external", "description": "Build #812 passed in 3m 12s", "target_url": "https://ci.example.com/builds/812", "commit": { "sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d" }, "created_at": "2026-10-13T09:40:00.000Z", "updated_at": "2026-10-13T09:40:00.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "c1b2a3d4e5f60718293a4b5c6d7e8f9a", "login": "ci-bot", "type": "User" } } ``` Unlike GitHub's `status` payload, `commit` holds only the SHA and there is no `branches` list. Check runs don't send webhook events. ## deployment Sent when a deployment is created: - with `POST /repos/{owner}/{repo}/deployments` (sender: who created it); - when a workflow job with `environment:` starts (sender: who started the run), as on GitHub. The `deployment` event comes first, then a `deployment_status` event with `in_progress`. Such a deployment has the task `deploy`, `description` `null`, `payload` `{}`, and `ref` set to the branch or tag name the run is for. | Action | Sent when | |---|---| | `created` | A deployment is created | | Field | Type | Description | |---|---|---| | `action` | string | `created` | | `deployment.id` | number | The deployment id | | `deployment.sha` | string | The commit being deployed | | `deployment.ref` | string | The ref as it was given (branch, tag or SHA) | | `deployment.task` | string | The task, `deploy` by default | | `deployment.environment` | string | The environment, `production` by default. Environment names are case-insensitive: a deployment to `Production` when a `production` environment exists reports `production`. | | `deployment.description` | string or null | The description | | `deployment.payload` | any | The `payload` sent when creating the deployment: the JSON value, or the string when it wasn't JSON. `{}` by default. | | `deployment.created_at` | string | Creation time (ISO 8601 with milliseconds) | ```json { "action": "created", "deployment": { "id": 3, "sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d", "ref": "main", "task": "deploy", "environment": "production", "description": "Deploy website 1.2.0", "payload": { "version": "1.2.0" }, "created_at": "2026-10-13T10:05:00.000Z" }, "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" } } ``` ## deployment_status Sent when a deployment gets a new status: - with `POST /repos/{owner}/{repo}/deployments/{id}/statuses`; - when a workflow job with `environment:` starts (`in_progress`, sender: who started the run); - when that job finishes (`success`; `inactive` when it was cancelled or skipped; `failure` otherwise; sender: who started the run). When a `success` status makes older deployments (created earlier) to the same environment `inactive`, those `inactive` statuses don't send events. Deployments created later than the one that succeeded are left as they are. | Action | Sent when | |---|---| | `created` | A status is added | | Field | Type | Description | |---|---|---| | `action` | string | `created` | | `deployment_status.id` | number | The status id | | `deployment_status.state` | string | `error`, `failure`, `inactive`, `in_progress`, `queued`, `pending` or `success` | | `deployment_status.description` | string | The description, up to 140 characters; empty when none | | `deployment_status.environment` | string | The environment | | `deployment_status.environment_url` | string | The deployed site's URL; empty when none | | `deployment_status.log_url` | string | The deployment's log (for jobs, the job's page); empty when none | | `deployment_status.created_at` | string | When the status was added (ISO 8601 with milliseconds) | | `deployment.id`, `.sha`, `.ref`, `.task`, `.environment` | | The deployment the status belongs to | ```json { "action": "created", "deployment_status": { "id": 5, "state": "success", "description": "", "environment": "production", "environment_url": "https://www.acme.example", "log_url": "https://diffinery.com/acme/website/actions/runs/8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a/job/2f1e0d9c8b7a69584736251403f2e1d0", "created_at": "2026-10-13T10:07:44.000Z" }, "deployment": { "id": 3, "sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d", "ref": "main", "task": "deploy", "environment": "production" }, "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" } } ``` ## deployment_review Sent when a workflow run starts waiting on an environment's [required reviewers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers), and when a reviewer decides, on the run page or with `POST /repos/{owner}/{repo}/actions/runs/{run_id}/pending_deployments`. An environment with only a wait timer sends no event. | Action | Sent when | Sender | |---|---|---| | `requested` | A job that names the environment becomes ready and has to wait for a reviewer (once per environment and run) | Who started the run | | `approved` | A reviewer approves the environment for the run | The reviewer | | `rejected` | A reviewer rejects it | The reviewer | | Field | Type | Description | |---|---|---| | `action` | string | `requested`, `approved` or `rejected` | | `since` | string | When the event happened (ISO 8601) | | `environment` | string | The environment's name | | `environment_id` | number or null | The environment's id, as `GET .../environments` shows it | | `state` | string | `approved` or `rejected`. Absent on `requested`. | | `comment` | string | The reviewer's comment; empty on `requested` or when none was given | | `workflow_run` | object | `id`, `run_number`, `name`, `display_title`, `head_sha`, `head_branch` and `html_url` of the run | | `reviewers` | array | The environment's required reviewers, each `{ "type": "User", "reviewer": { "login", "id" } }` | | `requestor` | object | `{ "login" }` of who started the run. Only on `requested`. | | `approver` | object | `{ "login" }` of the reviewer who decided. Only on `approved` and `rejected`. | ```json { "action": "approved", "since": "2026-10-13T10:12:30.000Z", "environment": "production", "environment_id": 4, "comment": "Release notes look good", "state": "approved", "workflow_run": { "id": "8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a", "run_number": 128, "name": "Deploy", "display_title": "Release 2.4", "head_sha": "4f1c2e9d0b7a6c5e3f2d1c0b9a8e7d6c5b4a3f2e", "head_branch": "main", "html_url": "https://diffinery.com/acme/website/actions/runs/8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a" }, "reviewers": [{ "type": "User", "reviewer": { "login": "alice", "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90" } }], "approver": { "login": "alice" }, "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "alice", "type": "User" } } ``` Unlike GitHub, `workflow_run` holds only the fields above and `reviewers[].reviewer.id` is the Diffinery account id. ## repository_dispatch Sent when someone with the write role calls `POST /repos/{owner}/{repo}/dispatches`. The same request starts workflows with `on: repository_dispatch` (see [Events that trigger workflows](https://diffinery.com/docs/actions/events)). ```sh curl -sS -X POST -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ https://diffinery.com/api/v3/repos/acme/website/dispatches \ -d '{ "event_type": "deploy-docs", "client_payload": { "version": "1.2.0" } }' ``` | Field | Type | Description | |---|---|---| | `action` | string | The `event_type` from the request (1 to 100 characters) | | `branch` | string | The repository's default branch | | `client_payload` | object | The `client_payload` from the request (`{}` when none): at most 10 top-level keys and 64 KB | ```json { "action": "deploy-docs", "branch": "main", "client_payload": { "version": "1.2.0" }, "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "diffy" } } ``` ## workflow_dispatch Sent when someone with the write role starts a workflow by hand: **Run workflow** in the web UI, `diffinery workflow run`, `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`, or the `dispatch_workflow` MCP tool. It is sent once the request is accepted (the workflow exists at the ref, has a `workflow_dispatch` trigger and the inputs are valid), alongside the `workflow_run` event with `"action": "requested"` for the new run. There is no `action`. | Field | Type | Description | |---|---|---| | `ref` | string | The full ref the workflow runs on, for example `refs/heads/main` or `refs/tags/v1.2.0` | | `workflow` | string | The workflow file's path, for example `.github/workflows/deploy.yml` | | `inputs` | object | The inputs after defaults and type conversion: booleans and numbers keep their types (GitHub sends strings). Inputs the workflow doesn't declare are dropped. | | `at` | string | Time of the dispatch | ```json { "ref": "refs/heads/main", "workflow": ".github/workflows/deploy.yml", "inputs": { "environment": "staging", "version": "1.2.0", "dry-run": false }, "at": "2026-10-13T10:15:00.000Z", "repository": { "id": 6628874657374920, "name": "website", "full_name": "acme/website", "private": false, "visibility": "public", "html_url": "https://diffinery.com/acme/website" }, "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "diffy", "type": "User" } } ``` The workflow's own inputs are in the [workflow_dispatch trigger](https://diffinery.com/docs/actions/events#workflow_dispatch). ## Events that are never delivered Audit entries (sign-ins, push protection decisions, settings changes) are not sent to webhooks. They are in the [audit log](https://diffinery.com/docs/security/audit-log). Check runs and check suites (GitHub's `check_run` and `check_suite`) send no events either; commit statuses send `status`. Transferring or renaming a repository sends no event (GitHub sends a `repository` event); a webhook keeps receiving deliveries afterwards, with the new `full_name` and `html_url`. An [import from GitHub](https://diffinery.com/docs/migrate-from-github) sends no events while it fills a repository: the branches and tags it pushes send no `push`, and the issues, pull requests, comments, reviews and releases it copies send no `issues`, `pull_request`, `issue_comment`, `pull_request_review` or `release`, so a webhook added during an import isn't flooded with old history and no workflows start. Changes that people, agents and workflows make send events as usual. A repository imported with a homepage on GitHub has it as `homepage` in the repository object; otherwise `homepage` is `null`. ## Comparing with GitHub payloads | Event | Fields GitHub sends that Diffinery does not | |---|---| | `push` | `commits`, `head_commit`, `pusher`, `compare`, `base_ref` | | `pull_request` | The full pull request object (title, body, user, labels, state, draft, timestamps), and `changes` for `edited` | | `pull_request_review` | `review` and `pull_request` objects | | `issues` | The full issue object, and `changes` for `edited` | | `issue_comment` | `comment` object, full `issue` object | | `workflow_run` | `workflow_run` and `workflow` objects | | `release` | Nothing in the `release` object, which has the REST API shape | | `status` | `branches` and the full `commit` object | | `deployment` | Most `deployment` fields (`url`, `creator`, `statuses_url`, `transient_environment`, `production_environment`, ...) | | `deployment_status` | Most `deployment_status` fields (`url`, `creator`, `target_url`, ...) and the full `deployment` object | | `repository_dispatch` | Nothing: the same `action`, `branch` and `client_payload` | | `workflow_dispatch` | Nothing: the same `inputs`, `ref` and `workflow` (inputs keep their types) | All events also lack GitHub's `installation` and `organization` objects. The `repository` object is complete apart from counters (always `0`) and `permissions`. Use the [REST API](https://diffinery.com/docs/api) to fetch any detail you need, keyed by `number`, `commentId` or `runId`. See [Webhooks](https://diffinery.com/docs/repositories/webhooks) for delivery behavior and signature verification. --- # Limits > Every hard limit in Diffinery and where it applies: quotas, egress, sign-in, tokens, API, search, archives, releases, git, Actions, sandboxes, agent tasks, agent review, packages, imports, email. Source: https://diffinery.com/docs/reference/limits This page lists the fixed limits built into Diffinery: sizes, counts, timeouts and lifetimes. Use it when a push, a workflow, an upload or an API call fails with a size or count error, or when you plan something large. Values come from the code of the running version. Where a limit comes from Cloudflare rather than from Diffinery, the table says so. Units: 1 KiB is 1,024 bytes, 1 MiB is 1,048,576 bytes, and 1 GB in the cache and artifact rows is 1,073,741,824 bytes. Sizes Diffinery checks itself and writes as "MB" (pushes, release assets, archives, profile pictures, forms) are in MiB: "100 MB" for pushes means 104,857,600 bytes. ## Sign-in and sessions | Limit | Value | Applies to | | --- | --- | --- | | Browser session lifetime | 30 days, extended to 30 days from now on each day the session is used, never past 90 days after sign-in | The session cookie you get after signing in with a passkey, a sign-in link or email recovery. An unused session ends 30 days after its last extension, and every session ends 90 days after its sign-in. | | Recent sign-in for sensitive actions | 2 hours | Adding or removing a passkey, creating a token, an agent or an agent token, changing your username, issuing a sign-in link, deleting, transferring, purging unreachable objects of or deleting the closed pull requests of a repository, and deleting an organization. An older browser sign-in gets **Confirm it's you** (`403` with a `confirm` URL over the API). See [Confirming it's you](https://diffinery.com/docs/security/overview#confirming-its-you). | | Passkey browser prompt | 2 minutes | Time to complete the browser's passkey dialog when signing in or adding a passkey. | | Passkey sign-in attempt | 5 minutes | A sign-in or registration that started more than 5 minutes ago can't be completed; start again. | | Sign-in link lifetime | 24 hours, single use | Links a site administrator sends with **Sign-in link** in **Settings → People and invitations**. | | Invitation lifetime | 14 days | Invitations to the site, an organization or a repository. | | Sign-up code | 10 minutes, single use, 5 tries | With open sign-up (`SIGNUP=open`): the 6-digit code emailed from `/signup`. After 5 codes entered for it, right or wrong, it stops working (`That code has expired or was entered wrong too many times`). Sending a new code ends the old one. | | Sign-up codes per address | 5 in 24 hours | Codes asked for one email address, counting the note sent instead when the address already has an account. The next one gets `429 We have sent several codes to <address> today`. | | Sign-up codes per IP address | 20 in 24 hours | Codes asked for from one IP address. The next one gets `429 Too many sign-ups from your network today`. | | Sign-up passkey step | 30 minutes, single use | After the code is accepted, the page for picking a username and creating a passkey. Start again at `/signup` once it runs out. | | Email recovery session at Cloudflare Access | 15 minutes | The Cloudflare Access application that guards `/auth/access` (set by `scripts/bootstrap.sh`). It only covers the email check; the Diffinery session that follows lasts 30 days. | | Username when joining from an invite or signing up | 39 characters | Letters, digits, `-` and `_`. Some names are reserved (for example `api`, `settings`, `login`, `admin`, `docs`). | | Display name when joining | 100 characters | Longer names are cut off. | | Passkey name | 100 characters | Longer names are cut off. | | Repository, organization and agent names | 1 to 100 characters | Start with a letter or digit; then letters, digits, `.`, `_` and `-`. Repository names can't end in `.git`. Organization and agent names can't contain `.` and can't be a reserved name. | | Early access requests | 10 per IP address, 3 emails per address and 500 emails in total, in 24 hours | The [early access](https://diffinery.com/docs/accounts/early-access) form and its removal form together. The 11th from one network answers `Too many requests from your network today`; past the other two the page looks the same and no email is sent. | | Early access links | 48 hours to confirm, 24 hours to remove, single use | Only the newest link sent to an address works. Unconfirmed requests are deleted 7 days after they were last sent. | | Early access fields | name 100, company or team 120, use 1,000 characters | Longer is refused, not cut. | | Note on an access request | 500 characters | The note someone without an invite sends to site administrators. Up to 20 site administrators are emailed about each request. | ## Tokens | Limit | Value | Applies to | | --- | --- | --- | | Token lifetime | 1 to 366 days, default 90 | Personal access tokens and agent tokens. Values outside the range are clamped. | | An agent's first token | 90 days, `write` scope | The token created together with an agent, unless you pass `token.expires_in_days` or `token.scopes`. | | Expiry reminder email | 7 days before expiry | Sent once per token, and only for tokens that were valid for more than 8 days. | | "Last used" accuracy | 1 hour | A token's last-used time is updated at most once an hour. | | Creating tokens through the API | A browser session | No token can create a token or an agent: `POST /api/v3/diffinery/tokens` and the agent endpoints return `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens` for every token. Each name in `repositories` must be a repository you can see, or the request fails with `422 unknown repository acme/website`. | | Sandbox token lifetime | 24 hours | A sandbox's git and API credentials stop working 24 hours after it starts, or earlier when it is closed or its creator loses access. | | Token scopes | `read`, `write` or `admin`; default `["write"]` | A new token (personal or agent) without `scopes` gets `write`. An empty list or any other name returns `422 scopes must be a non-empty list of: read, write, admin`. Repeated names are stored once. | | Writes with a `read` token | Refused | Every change returns `403 this token needs the write scope`. Git fetch and Git LFS downloads still work. | | Account-level actions | `write` to create repositories, mark notifications read, change notification settings and send a test email; `admin` for creating organizations, invitations, organization secrets, agents and revoking tokens | A token restricted to some repositories can't do any of these (`403 this token is limited to repositories and can't change account settings`). | ## REST API | Limit | Value | Applies to | | --- | --- | --- | | Rate limits | Per minute: 20 sign-in attempts and 300 other requests per IP address without signing in; 2,000 requests and 60 new repositories, organizations, forks, issues, pull requests, releases, comments or reviews per account | Past a limit, `429` with `retry-after: 60`. Jobs, sandboxes, runners and imports aren't counted. See [Rate limits](https://diffinery.com/docs/reference/rest-api#rate-limits). | | Request body size | 100 MB | Cloudflare refuses larger request bodies before they reach Diffinery. | | Items per page | 30 by default, 100 at most | Every paginated list (`per_page`). Larger values are treated as 100. `page` can go up to 100,000. | | Workflow runs per page | 25 by default, 100 at most | `GET /repos/{owner}/{repo}/actions/runs`. | | Commits in a comparison or pull request | 250 in total, 100 per page | `GET /repos/{owner}/{repo}/compare/{base}...{head}` and `GET .../pulls/{n}/commits`. | | Refs per page | 100 by default | `GET .../git/refs` and `.../git/matching-refs/{ref}`. | | Environment variables per page | 10 by default, 100 at most | `GET .../environments/{env}/variables`. | | Commits listed by paging | The first 10,000 commits from where the listing starts | `GET /repos/{owner}/{repo}/commits` answers `422 In order to keep the API fast for everyone, pagination is limited for this resource.` past that, as GitHub does, and the web commit list stops there too. To go further back, start a new listing at an older commit with `sha=`. | | Commits searched for a path | The latest 2,000 commits | `GET /repos/{owner}/{repo}/commits?path=...` and the history of a file in the web UI only look at the 2,000 most recent commits. | | Package versions per page | 30 by default, 100 at most | `GET .../packages/container/{name}/versions`. | | Repository lists | 500 repositories | `GET /user/repos`, `/users/{login}/repos`, `/orgs/{org}/repos` page over at most 500 repositories (most recently pushed first). | | Notifications | 100 | `GET /api/v3/notifications` returns your 100 most recent (unread unless `all=true`). Notifications older than 180 days are deleted. | | Email log | 50 by default, 500 at most | `GET /api/v3/diffinery/emails?per_page=`. | | Job log lines per call while a job runs | 5,000 | `GET /repos/{owner}/{repo}/actions/jobs/{id}/logs`. Pass `after=<seq>` with `format=json` to read the next lines. A finished job's log comes back whole. | | Recursive tree listing | 100,000 entries | `GET .../git/trees/{sha}?recursive=1` stops there and returns `truncated: true`. | | Entries changed by one tree request | 10,000 | `POST .../git/trees` (`a tree request can change at most 10000 entries`). | | File and directory names | 255 characters per name, 4,096 per path | Every API write (contents, git trees, multi-file commits). Names `.`, `..`, `.git` (in any case, and spellings such as `.git.` or `git~1`) and names with `\` or NUL are refused. | | Topics | 20 per repository, 50 characters each | `PUT .../topics`. Lowercase letters, digits and hyphens, starting with a letter or digit. | | Labels | Names up to 100 characters; 100 labels per issue | The REST API. The web UI's label form allows names up to 50 characters. | | Assignees | 10 per issue or pull request | `an issue can have at most 10 assignees`. | | Issue and pull request text | Titles up to 256 characters; bodies, comments and review text up to 65,536 characters | As on GitHub, everywhere they're written (web, REST, MCP). Longer text fails with `422 body is too long (maximum is 65536 characters)`. | | MCP batch | 20 requests | One `POST /mcp` with a JSON-RPC batch of more than 20 requests answers `400` with `at most 20 requests in one batch`. | | `repository_dispatch` | `event_type` up to 100 characters; `client_payload` up to 10 top-level properties and 64 KiB | `POST .../dispatches`. | | Check run name | 255 characters | `POST .../check-runs`. | | Deployment `required_contexts` | 100 contexts | `POST .../deployments`. | | Environment name | 1 to 255 characters | No `/`, `\` or control characters. | | Required reviewers | 6 people per environment | `PUT .../environments/{env}`: `422 an environment can have at most 6 required reviewers`. See [Required reviewers and wait timers](https://diffinery.com/docs/actions/environments#required-reviewers-and-wait-timers). | | Wait timer | 0 to 43,200 minutes (30 days) | `wait_timer` on `PUT .../environments/{env}`. | | Waiting for a reviewer | 30 days | A job still waiting for its environment's reviewers after 30 days fails. | | Deployment review comment | 4,000 characters | `POST .../actions/runs/{run_id}/pending_deployments`: `422 comment is longer than 4000 characters`. | ## Search | Limit | Value | Applies to | | --- | --- | --- | | Query length | 256 characters | `q` of `GET /search/issues` and `/search/repositories`. Longer queries fail with `422 The search is longer than 256 characters.` | | Results you can page to | 1,000 | Asking for a page past the first 1,000 results fails with `422 Only the first 1000 search results are available`. | | Repositories an issue search covers | 50 | The first 50 repositories you can read, out of the up to 500 most recently pushed repositories visible to you. More sets `incomplete_results: true`; add `repo:` or `org:` to narrow it. | | Repositories a repository search checks | 300 | Candidates checked for access, out of the same up to 500. More sets `incomplete_results: true`. | | Issues scanned per repository | 5,000 | The most recent issues and pull requests in each repository. | | Comments read for `in:comments` | 2,000 per repository | The most recent comments. | | Issue search box in the web UI | 200 characters | The search query on the issues and pull requests pages. | ## Source archives | Limit | Value | Applies to | | --- | --- | --- | | File content per archive | 24 MB | `zipball`, `tarball` and `/<owner>/<repo>/archive/<ref>.zip` or `.tar.gz`. Larger repositories fail with `413 repository is too large for an on-the-fly archive; clone it with git instead`. | | Tree entries per archive | 100,000 | Every entry visited counts, including repeated subtrees. More fails with `413 the tree has more than 100,000 entries; clone it with git instead`. | | Directory depth | 128 levels | Deeper trees fail with `413 the tree is nested too deeply`. | | Files in a zip | 65,535 | More fails with `413`; download the `.tar.gz` archive instead. | | New archives per repository | 20 per hour | Archives already built and cached don't count. Past the limit: `429 too many archives were built for this repository in the last hour; try again later or clone it with git`. | | Cache | Not guaranteed | A built archive is cached in R2 and can be evicted at any time; it is then built again. | ## Releases | Limit | Value | Applies to | | --- | --- | --- | | Asset size | 100 MB | Each asset uploaded to a release's `upload_url`, the largest request body Cloudflare Workers accept (`413 release assets are limited to 100 MB`). | | Asset size through MCP | 25 MB | The `upload_release_asset` tool (`MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files`). | | Files per web form submission | 100 MB in total | The release form in the web UI. Upload larger files with the REST API. | | Asset name | 1 to 255 characters | After unusual characters are replaced with `.`. Each name once per release. | | Release notes | 125,000 characters | `body` of a release. | | Releases listed through MCP | 100 | `list_releases` (`limit`, default 30). | ## Commit statuses and checks | Limit | Value | Applies to | | --- | --- | --- | | Statuses per commit and context | 1,000 | `422 this SHA and context has reached the maximum number of statuses`. | | Status context | 255 characters | Longer contexts are cut. Without `context`, it is `default`. | | Status description | 1,024 characters | Longer descriptions are cut. | ## Profile pictures | Limit | Value | Applies to | | --- | --- | --- | | Upload size | 5 MB | Pictures for people and organizations, in the web UI and through `PUT /api/v3/user/avatar` or `/orgs/{org}/avatar` (`That picture is larger than 5 MB. Use a smaller file.`). | | Image size | 3072×3072 pixels | Larger images are refused. | | Formats | JPEG, PNG and WebP | Other files fail with `Use a JPEG, PNG or WebP image.` | | Stored sizes | 64, 128 and 256 pixels square | The picture is cropped to a square and stored as WebP. `GET /api/avatars/{login}?s=` serves the smallest stored size at least as large as asked. | ## Git | Limit | Value | Applies to | | --- | --- | --- | | Push size | 100 MB per push | The whole push request after decompression. A larger push is rejected with `push exceeds 100 MB; split it into smaller pushes`. Push in several steps (for example a range of commits at a time). | | Pushes to one repository at once | Large ones unpack one at a time | Small pushes (up to 1 MiB) unpack side by side. A larger push needs the repository's full memory share to unpack, so large pushes to the same repository take turns, oldest first; one that waits keeps its connection open and goes through once the push before it is done. Pushes to different repositories don't wait for each other. | | Fetch request size | 16 MB | The request a client sends to fetch or clone (its wants and haves), after decompression. A larger one is refused with `413 fetch request exceeds 16 MB`. | | File size in a push | Up to the push size | A single file (blob) can be as large as fits in one push (GitHub allows files up to 100 MB). Files of 4 MiB or more are checked and stored from their compressed bytes, so they never need to fit in memory inflated. | | Unpacked size of a push | 512 MiB | What a push's pack inflates to, deltas resolved. A pack that unpacks to more is refused with `pack unpacks to more than 512 MiB; split it into smaller pushes`, however small it is on the wire: a long history of one large file can, since each version counts whole. | | Tree size in a push | 8 MiB | One directory's listing (a tree object). A larger one is refused with `fsck error: tree <id>: larger than 8 MiB`. 100,000 files in one directory come to about 4 MiB. | | Commit or tag size in a push | 4 MiB | A commit or annotated tag object, message and signature included. A larger one is refused with `fsck error: commit <id>: larger than 4 MiB` (or `tag`). | | Objects in a push unpacked in pieces | 1,000,000 | A push over 16 MB is unpacked from storage in pieces, and so is a smaller one whose objects don't all fit in memory at once (a long history, such as flask's or tornado's). Inflating its objects again to check and store them may take at most four times the unpacked size limit, and reading it from storage at most 500 requests, counting bases read from the repository and each object stored on its own (jQuery's 37 MB history takes about 50). Past any of these, it is refused with a message that ends `split it into smaller pushes`. Its deltas count toward the storage quota at their full size. | | Memory to unpack a push | 80 MiB | What unpacking a push holds at once. Up to 16 MB: its files under 4 MiB inflated, and about 768 bytes for each object. Unpacked in pieces: 24 MiB of caches and scratch, up to 16 MiB of deltas kept to rebuild them, each object while it is in use (a file, a delta's base and its result, a base read from the repository), about 128 bytes for each object, and 640 more for each delta named by its base's id rather than its place in the pack (git sends those only for bases the repository already has). Two versions of a file over about 25 MB, which git stores as a delta, need more; a long history of one file holds two versions at a time. A base from the repository is checked before it is read. A push that needs more is refused with `pack needs more than 80 MiB of memory to unpack; split it into smaller pushes`. Deltas are resolved in one pass, however the pack orders them. | | Git protocol | Version 2 for fetch and clone | Clients older than git 2.26 must run `git config --global protocol.version 2`, or the server answers `Diffinery requires Git protocol v2`. | | Secret scanning coverage | Every new text file | Push protection scans every new text file in a push, whatever its size. The rejection message lists up to 10 findings. | | Linear history check | 1,000 new commits | A protected branch with "require linear history" checks up to 1,000 new commits in a push for merge commits. | ## Git LFS | Limit | Value | Applies to | | --- | --- | --- | | Objects per batch request | 1,000 | Objects beyond the first 1,000 in one LFS batch request are ignored; the git-lfs client batches in smaller groups by default. | | Upload and download links | 1 hour | The `expires_in` of the transfer links the batch API returns. | | Object size | 100 MB | Each object is uploaded in one request, and Cloudflare refuses request bodies over 100 MB. | ## Pull requests, diffs and the web UI | Limit | Value | Applies to | | --- | --- | --- | | Files in a diff | 300 | Commit, compare and pull request diffs (web UI, REST and MCP) include the first 300 changed files and mark the diff as truncated. | | Size of one file's diff | 1 MiB (old and new version together) | Larger files show no line diff ("This diff is too large to display."). | | Content diffed per request | 16 MiB | One commit, comparison or pull request diffs at most 16 MiB of file content; later files are listed without a line diff. | | Changed lines diffed exactly | 1,000 per file, about 2,000 per request | A file with more changed lines, or one diffed after the request's budget is spent, is shown as one block: the lines both versions share at the start and end stay as context, and everything between is removed and added. The line counts stay right; the diff is just not minimal. | | Automatic line merges | 2,000 changed lines per side | Merging a pull request, a file both sides changed by more than this is a conflict to resolve yourself. | | Commits listed in a comparison or pull request | 250 | `GET /repos/{owner}/{repo}/compare/{base}...{head}`, `GET .../pulls/{n}/commits` and the matching web pages. | | Viewing or editing a file in the browser | 1 MiB | Larger files show "This file is too large to display" with a **View raw** link, and can't be edited in the browser. READMEs over 1 MiB are not rendered. | | Markdown source | 512 KiB | Longer Markdown (READMEs, issue and pull request bodies, comments) is shown as plain preformatted text. | | Markdown nesting | 12 levels | Deeper nested lists and quotes are shown as plain text. | | Markdown inline run | 60,000 characters | A longer paragraph is shown without inline formatting. | | Web list pages | Page 1,000 | Paginated lists in the web UI stop at page 1,000. | | Syntax highlighting | 2,000 lines and 200,000 characters per file | Larger files are shown without highlighting. Lines over 2,000 characters are not highlighted. | | Web form size | 10 MB | Forms in the web UI, except the release form (100 MB of files) and profile pictures (5 MB). | | Required approvals | 0 to 10 | The **Required approvals** setting of a branch protection rule in the repository settings page. | | Assignees per edit | 10 | Assigning people from the issue or pull request sidebar. | | Label name and description | 50 and 100 characters | Labels created in the web UI. | | Merge queue build concurrency | 1 to 20, default 5 | How many merge groups a queue builds and tests at the same time (`mergeQueue.buildConcurrency`). | | Merge queue check timeout | 1 to 360 minutes, default 60 | How long a merge group's required checks may take before the pull request leaves the queue (`mergeQueue.checkTimeoutMinutes`). | ## Account quotas Quotas are per account, and the account they count against is the one that owns the repository (a person or an organization), whoever started the work. A site administrator can change any of them for one account (`PATCH /api/v3/admin/accounts/{login}/quotas`), and an operator can change the defaults with the `QUOTA_*` variables in [Configuration](https://diffinery.com/docs/self-hosting/configuration). Accounts run by a site administrator have no quotas: their own account, and organizations with a site administrator as an owner. See what an account uses with `GET /api/v3/user/usage` or `GET /api/v3/orgs/{org}/usage` (owners and admins). | Quota | Default | What happens past it | | --- | --- | --- | | Hosted minutes per month | 3,000 | Hosted Actions jobs and agent sandboxes (including agent tasks, reviews and terminals) add up their running time, rounded up to the minute, per calendar month in UTC. Once it is used up, a hosted job fails without starting ("The job was not started: this account has used its 3000 hosted minutes for this month ..."), and a new sandbox is refused with `429`. Self-hosted runners don't count. | | Hosted jobs at once | 20 | Across all the account's repositories, like GitHub's per-account limit (20 on its Free plan). Further jobs wait as queued and start, oldest first, as running ones finish (checked at least once a minute). A job that waits 6 hours fails with "The job waited 6 hours for a hosted runner (one of this account's hosted jobs to finish, or a free runner), and was not started." Accounts run by a site administrator have no quota but are still held to half the fleet (see [Actions](#actions)). | | Agent sandboxes open at once | 5 | Across all the account's repositories, including agent tasks', reviews' and terminals' sandboxes. A new one is refused with `429 this account already has 5 agent sandboxes open; close one or wait for one to finish`. | | Repositories | 500 | Creating, importing or transferring in a repository past it is refused with `403 <account> has reached its limit of 500 repositories; delete one or ask a site administrator to raise it`. | | Storage | 10 GB (10,737,418,240 bytes) | What the account's repositories store (git objects as stored, compressed; Git LFS objects; release assets) and its container images' blobs and manifests (a blob several of its images use counts once). Actions artifacts and caches don't count: they have their own limits per repository (see [Caches and artifacts](#caches-and-artifacts)) and expire. A write that would take the account past it is refused with `<account> has reached its storage limit of 10737418240 bytes (git objects, Git LFS objects, release assets and packages); delete some or ask a site administrator to raise it`: a push that brings new objects (git shows `remote: Push refused: ...` and rejects every ref with `storage limit reached`), Git LFS uploads (each object gets error `507` in the batch response), release asset uploads, files and blobs written through the API, the web editor or MCP (`403`), container image uploads (`403 DENIED`), and starting a GitHub import (`403`); an import that reaches it on the way stops with the same message. Reads, merges, deletes and pushes that only delete or move refs keep working. A deleted branch's objects stay stored until you [purge unreachable objects](https://diffinery.com/docs/security/push-protection#removing-a-secret-that-was-pushed); deleting releases, assets, image versions or the repository frees their bytes. Repositories made before this quota existed are measured the first time their account's storage is checked. | ## Egress | Limit | Value | Applies to | | --- | --- | --- | | Network access of hosted compute | The whole internet by default; HTTP and HTTPS (ports 80 and 443) and DNS with `EGRESS=web` | Hosted Actions jobs and agent sandboxes, including tasks, reviews and terminals. Under `web` a connection to any other port fails: SSH (`git@...` remotes), SMTP (25, 465, 587), IRC and the like. The account that owns the repository decides, and accounts run by a site administrator stay open. Self-hosted runners aren't limited. See [EGRESS](https://diffinery.com/docs/self-hosting/configuration#egress). | | HTTPS under `EGRESS=web` | Intercepted, re-signed with a Cloudflare CA | The runner agent makes the system store, Node.js and Python's `requests` trust the CA. Tools with their own CA list, certificate pinning, client certificates and HTTPS from Docker containers a job starts fail their certificate checks. | ## Actions | Limit | Value | Applies to | | --- | --- | --- | | Job timeout | 360 minutes (6 hours), default and maximum | `jobs.<id>.timeout-minutes`. Higher values are lowered to 360. A job that runs out of time ends with conclusion `timed_out` and the message "The job exceeded its timeout of N minutes." | | Step timeout | No maximum beyond the job's | `steps[*].timeout-minutes`. | | Waiting for a concurrency group | 6 hours | A run whose `concurrency` group is busy (without `cancel-in-progress`) checks once a minute for up to 6 hours. | | Jobs in one matrix | 256 | More combinations fail the job with "A matrix may produce at most 256 jobs." | | Matrix combinations before `exclude` | 4,096 | The product of the matrix's vectors. A larger matrix is refused without being expanded: "the matrix has <n> combinations before exclusions; at most 4096 are allowed (and a matrix may produce at most 256 jobs)". | | Matrix `include` and `exclude` entries | 256 each | "matrix.include has <n> entries; at most 256 are allowed". | | Workflow files read per commit | 256 | Of `.github/workflows` and `.diffinery/workflows` together, in name order; later files are ignored. | | Workflow file size | 512 KiB | A larger file is listed as invalid ("the workflow file is larger than 512 KiB") and never runs. | | Reusable workflow nesting | 4 levels | "reusable workflows can be nested at most 4 levels deep" | | Reusable workflows per run | 20 calls | "a run can call at most 20 reusable workflows" | | `workflow_run` chains | 3 levels | A run started by `workflow_run` that is itself three links down a chain triggers nothing (A starts B starts C starts D; D's runs start nothing). | | Schedule granularity | 5 minutes | Scheduled workflows are checked every 5 minutes. A cron that fires more often still runs at most once per 5-minute window. Only workflows on the default branch of repositories that aren't archived are scheduled. | | Schedule catch-up | 60 minutes | A check that comes late, or fails, is caught up by the next one, up to an hour back. Each schedule starts once per check, however many of its minutes the check covers. | | Running job whose runner stops responding | Ended 10 minutes after its `timeout-minutes` | Counted from when its runner fetched the job. The runner ends a job at its timeout itself; Diffinery only steps in when it hasn't reported by then, stops its container and fails it as timed out. | | Run whose jobs have all finished but that hasn't finished | Repaired 5 minutes after its last job, finished by 30 minutes | Diffinery signals the run's orchestration again after 5 minutes, and finishes the run itself after 30 (or after 5, when the orchestration has stopped), so it no longer holds its concurrency group. Jobs it never started fail the run and are named in a `Set up workflow` job. | | Hosted runner sizes | large: `standard-4` (4 vCPU, 12 GiB of memory, 20 GB of disk); medium: `standard-3` (2 vCPU, 8 GiB, 16 GB); small: `standard-1` (1/2 vCPU, 4 GiB, 8 GB); tiny: `basic` (1/4 vCPU, 1 GiB, 4 GB) | Each hosted job runs in its own container. A job runs on large unless `runs-on` names a size or its earlier runs show a smaller one fits with headroom ([Runner sizes](https://diffinery.com/docs/actions/runners#runner-sizes)). | | Hosted job containers at once | 100 per size | `max_instances` of each runner container class. | | Hosted jobs at once on the instance | 80 | `HOSTED_JOBS_LIMIT`, kept under the container classes' `max_instances` with room for jobs starting side by side. Past it, every account's next hosted job waits as queued, as for its own quota, until one finishes. Self-hosted jobs don't count. | | Hosted jobs at once per account | Half the instance's, 40 | Whatever the account's quota says, so no account can keep the others' jobs from starting. A repository can use all of its account's share. Further jobs stay queued and start, oldest first, as running ones finish. | | No free container | Wait, then retry, 8 times | When the platform has no container free for a job (many jobs starting at once, or just after a deploy), the start fails on the platform's side in a way that passes ("Container sidecar is shutting down", a dropped connection, the platform's own `internal error; reference = ...`, a new container object out of memory, a container that didn't come up in time), or the job's container stops before its runner fetched the job, the job goes back to queued and starts again after 30 seconds, then 1, 2, 4 and 5 minutes. The first time a job's container stops before its runner fetched the job (the platform stops a share of the containers a burst starts), or its start fails with an internal error or a dropped connection, it starts again after 5 seconds instead, and that try isn't counted. After 8 tries, about half an hour, it fails with `No hosted runner could be started`. Only jobs whose runner never fetched the job are retried: a runner that fetches it after its start was given up on is turned away, so no step runs twice. Other start errors fail the job at once with `Could not start a runner`. | | Runner on the previous image during a deploy | Retried every 20 s, up to 30 times | While a deploy rolls out a new runner image, a runner that comes up on the previous image is turned away before it runs anything and the job starts again 20 s later. These waits don't count toward the 8 above. After 30 (about 10 minutes, longer than a rollout takes) the job runs on whichever image it gets. | | Unfinished runs per repository | 500 | Past it, a `workflow_dispatch` or `repository_dispatch` is refused with `429 this repository has 500 unfinished workflow runs, the most allowed; wait for some to finish`, and other events start no run. | | Self-hosted runners per repository | 100, each with at most 20 labels | Registering one more gets `429 this repository has 100 self-hosted runners, the most allowed; remove one first`. A registration token works for an hour. | | Purging unreachable objects | Repositories of up to 300,000 objects | A larger repository gets `422`; ask a site administrator. See [Removing a secret that was pushed](https://diffinery.com/docs/security/push-protection#removing-a-secret-that-was-pushed). | | Deleting closed pull requests | Repositories of up to 300,000 objects | It purges unreachable objects too, so the same limit applies. See [Start over with fresh history](https://diffinery.com/docs/repositories/overview#start-over-with-fresh-history). | | Jobs during a deploy | Not interrupted | A deploy doesn't replace the container of a running hosted job: it finishes on the version it started with. If the platform replaces a job's container anyway, the job keeps going for up to 13 minutes, then is cancelled. | | Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. | | Log line length | 16,384 characters | Longer lines are cut and end with `…`. | | Log size per job | 16 MiB of text | The log ends with "The log is cut off here: a job's log is limited to 16 MiB of text." and later lines are dropped; the job keeps running. | | Log shown in the web UI | 20,000 lines | The job page shows the first 20,000 lines and links to the full log. | | Job log retention | 90 days | A finished job's log is stored for 90 days, then deleted. The run and its jobs stay listed. | | OIDC token lifetime | 10 minutes | ID tokens from `permissions: id-token: write`. | ## Caches and artifacts These apply to `actions/cache` and `actions/upload-artifact` (and the matching built-ins) in hosted and self-hosted jobs. See [Artifacts and caches](https://diffinery.com/docs/reference/rest-api#actions) in the REST API reference for listing and deleting them. | Limit | Value | Applies to | | --- | --- | --- | | Cache storage per repository | 10 GB | Every cache entry of the repository together, on all branches. When saving a cache would go over 10 GB, the least recently used entries are deleted until it fits. | | Unused caches | Deleted after 7 days | An entry that no job restored for 7 days is deleted the next time a job saves a cache in the repository. | | Cache entry size | 10 GB | A larger entry fails with `413 a cache entry may be at most 10737418240 bytes`. If caches still being uploaded by other jobs fill the budget, saving fails with `413 uploads in progress use the repository's whole cache budget`. | | Cache key | 512 characters, no commas | Longer keys or keys with `,` fail with `400 invalid cache key`. Up to 20 restore keys per lookup. | | Saving an existing cache key | Skipped | A key and version that already exist for the branch are not replaced; the save is skipped with a warning. | | Which caches a job can restore | Its own branch, then the base branch, then the default branch | A pull request job also restores caches of its base branch, a merge queue job those of the target branch. | | Artifact storage per repository | 10 GB by default | Artifacts that haven't expired plus uploads in progress. Past the quota, an upload fails with `413 the repository's artifact storage quota (N bytes) is used up; delete artifacts or wait for them to expire`. Operators change it with `ARTIFACT_QUOTA_BYTES` (see [Configuration](https://diffinery.com/docs/self-hosting/configuration#worker-variables)). | | Artifact retention | 1 to 90 days, default 90 | `retention-days`. Values outside the range are clamped. | | Expired artifacts | Listed for 90 more days | After its retention the content is deleted. The artifact stays in the API with `expired: true`, and downloading it returns `410 Artifact has expired`. | | Artifact name | 256 characters, unique per run | Names can't contain `"`, `:`, `<`, `>`, `|`, `*`, `?`, `\`, `/` or line breaks. A second artifact with the same name in a run fails with `409 an artifact with this name already exists on the workflow run`. | | Upload request size | 256 MiB, and Cloudflare's 100 MB request limit | `actions/cache` and `actions/upload-artifact` upload in blocks, so cache entries and artifacts can be larger than one request. On plans where Cloudflare refuses request bodies over 100 MB, that is the effective block size. | | Blocks per upload | 50,000 | More fails with `Too many blocks.` | | Download links | 1 hour, or 10 minutes from the REST API | The signed links a job gets to restore a cache or download an artifact, and the redirect from `GET .../actions/artifacts/{id}/zip`. | | Runtime token | While the job runs | `ACTIONS_RUNTIME_TOKEN` works only while its job is in progress, and at most until 30 minutes after the job's timeout. | ## Agent sandboxes | Limit | Value | Applies to | | --- | --- | --- | | Sandbox size | Cloudflare Containers `standard-1` | Each sandbox runs in its own container of this instance type. | | Sandboxes at once | 50 | `max_instances` of the sandbox container class, across the whole instance. | | Open sandboxes per account and repository | 5 | A sixth is refused with `429 you have 5 open sandboxes in this repository, the most allowed; close one first`. A sandbox counts until it is closed or its credentials expire after 24 hours. Agent task sandboxes don't count. | | Start-up wait | Up to 3 minutes | Creating a sandbox waits up to 3 minutes for its container (longer than usual right after a deploy, while the host pulls the image). If it doesn't start, creation fails with `503 the sandbox container did not start; try again`. | | Idle time | 30 minutes | A sandbox that receives no requests for 30 minutes is stopped. | | Command timeout | 10 minutes by default | `exec` in the REST API (`timeoutMs`) and `sandbox_exec` in MCP (`timeout_seconds`) can set another value. A command that times out returns exit code `124` and `timedOut: true`. | | Command output | 1 MiB each for stdout and stderr | Longer output is cut and the response has `truncated: true`. | | Request body | 64 MiB | Larger `exec`, `read` or `write` requests fail with `413 request body too large`. | | File read | 64 MiB | Reading a larger file fails with `413 file too large`. | ## Agent tasks | Limit | Value | Details | | --- | --- | --- | | Run length | 6 hours | A run still going after 6 hours is stopped and fails with `The run took longer than 6 hours and was stopped.` | | Installing the runtime | 10 minutes | Installing Claude Code or Codex in the sandbox. | | Goal and follow-up length | 20,000 characters | ``422 `goal` is longer than 20000 characters`` (or `body` for a follow-up). | | Plan length | 20,000 characters | A [plan-first](https://diffinery.com/docs/agents/tasks#plan-first) run's plan is cut there and ends with `… (plan cut at 20000 characters)`. | | Fix runs for failing checks | 1 per commit, 3 per task | With **Fix failing checks**, a fix run gets the last 80 log lines of each failing check. After 3, the task's conversation says the checks still fail. See [Verify and land](https://diffinery.com/docs/agents/tasks#verify-and-land). | | Attempts per task | 1 to 4 | ``422 `attempts` must be a whole number from 1 to 4``. Each attempt counts toward how many tasks run at once. A task on a pull request's branch runs one. See [Best of N](https://diffinery.com/docs/agents/tasks#best-of-n). | | Custom command length | 2,000 characters | ``422 `command` is longer than 2000 characters``. | | Transcript | 20,000 lines per run, 32 KiB per line | Later output lines of a run aren't stored (Diffinery's own notes still are); a longer line is cut. | | Transcript lines per MCP call | 500 | `agent_task` returns at most 500 lines after `after`. | | Tasks running at once per repository | 1 to 10, default 3 | Set under **Settings → Project**. Further tasks, follow-ups and retries queue and start oldest first. Agent reviews don't count. See [Projects](https://diffinery.com/docs/agents/projects#how-many-at-once). | | Project memory | 40,000 characters, last 50 versions kept | ``422 memory is longer than 40000 characters``. | | Project instructions | 10,000 characters | ``422 `instructions` is longer than 10000 characters``. | | Coordinator | 5 tasks per post, 20 per person and project per 24 hours | A coordinator run lasts at most 30 minutes and 200 model requests, within its token budget (1M by default); each run of a task it starts has its own budget (2M by default). Both budgets are 100,000 to 50,000,000 tokens. See [Coordinator](https://diffinery.com/docs/agents/projects#coordinator). | | Project conversation post | 20,000 characters | ``422 `body` is longer than 20000 characters``. The conversation API and `project_posts` return at most 100 posts per call. | | Model requests per person | 600 a minute, across all the runs you pay for | The model proxy answers `429` with `retry-after: 60`; the run goes on, and Claude Code and Codex retry. Counted in each Cloudflare location, like the [rate limits](https://diffinery.com/docs/reference/rest-api#rate-limits), so it isn't a global total. | | Usage report | 1 to 90 days, 200 lines | `GET /diffinery/model-usage` and **Settings → Model access**. See [Usage and cost](https://diffinery.com/docs/agents/tasks#usage-and-cost). | | An organization's tasks at once per repository | 1 to 10, default 10 | Set in its [agent policy](https://diffinery.com/docs/agents/organizations#other-policies); each project's own setting is held to it. | | Tokens per person on an organization's keys | 10,000 to 10 billion a calendar month (UTC), no limit by default | The model proxy refuses a request that could go over with `403`, and the task stops. See [Monthly allowance per person](https://diffinery.com/docs/agents/organizations#monthly-allowance-per-person). | | Model requests at once | 4 per review or budgeted run, and 4 per person on an organization's keys while it sets an allowance | The model proxy answers `429` with `retry-after: 5`, and the CLI tries again. A request still streaming after 15 minutes stops counting. | | An organization's usage report | 1 to 90 days, 500 lines | `GET /orgs/{org}/diffinery/model-usage` and the organization's **Settings** tab. | See [Agent tasks](https://diffinery.com/docs/agents/tasks). For the Today view's limits (repositories active in the last 30 days, at most 100 of them, 50 open pull requests each), see [Today](https://diffinery.com/docs/agents/today). ## Agent review | Limit | Value | Details | | --- | --- | --- | | Review length | 30 minutes | The review stops and its **Agent review** check fails with "The review didn't finish". | | Model requests per review | 200 | The model proxy answers `429` to the next request and stops the review; its check fails. | | Tokens per review | 2,000,000 by default, settable from 100,000 to 50,000,000 (`tokens_per_review`) | Counted by the model proxy from the provider's usage (cache reads as a tenth). A request whose booked estimate could pass the budget is refused, and the review stops. | | Automatic reviews per pull request | 10 in 24 hours | Later pushes get an action-required check; people with write access can still ask. | | Reviews per pull request | 20 in 24 hours, asked for or automatic | The check asks for action, and asking returns `429`. | | Reviews per repository | 50 reviews and autofix runs in 24 hours by default, settable from 1 to 1000 (`daily_limit`) | The check asks for action, asking returns `429`, autofix waits, and the admin whose key reviews use gets a `review_limit_reached` email at most once a day. | | Inline findings per review | 25 | Findings outside the changed lines go in the review's summary. | | Changed files in the prompt | 300 | The rest are summed up as `… and N more`. | | Review rules in the prompt | 48 KiB | Of the base branch's `REVIEW.md` and `.cursor/BUGBOT.md` files. | | Autofix tasks per pull request | 3 | Only for pull requests whose author has write access. Autofix runs count toward the repository's daily limit. | See [Agent review](https://diffinery.com/docs/agents/review). ## Container registry | Limit | Value | Applies to | | --- | --- | --- | | Request body | 100 MB | Cloudflare refuses larger requests. `docker push` from a hosted workflow splits large layers into chunks automatically; other clients need chunked uploads for layers over 100 MB. Pulls have no size limit. | | Manifest size | 4 MiB | Larger manifests fail with `413 MANIFEST_INVALID: manifests are limited to 4 MiB`. | | Image name | 255 characters | `<owner>/<name>`, lowercase letters, digits, `.`, `_`, `-` and `/`. | | Tags per tag-list page | 10,000 | `GET /v2/<owner>/<name>/tags/list?n=`. | | Packages listed per owner | 1,000 | `GET /api/v3/users/{owner}/packages`, `/orgs/{owner}/packages`, `/user/packages`. | ## Imports Limits of [imports from GitHub](https://diffinery.com/docs/migrate-from-github), from the web form, `diffinery migrate github`, `POST /api/v3/diffinery/imports` and the MCP tool `start_github_import`. | Limit | Value | Applies to | | --- | --- | --- | | Imports at a time | 1 organization import and 3 repository imports | Counted per person or agent who starts them and per account imported into. Organization imports count while `draft`, `queued` or `running`; repository imports while `queued` or `running`, not counting the repositories inside an organization import. Over the limit: `429 an organization import is already in progress (at most 1 at a time); wait for it to finish or cancel it` or `429 too many repository imports are already running (at most 3 at a time); wait for one to finish`. | | Repository size | 10 GB by default | GitHub's reported size of the repository in a one-repository import, checked before the import starts: `422 the repository is about N GB, over the M GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. Operators change it with `IMPORT_MAX_REPO_KB` (see [Configuration](https://diffinery.com/docs/self-hosting/configuration#worker-variables)). Each repository of an organization import is checked too, and one over the limit fails before anything is copied. | | Repositories per organization import | 1,000 | The `repositories` list (`422 at most 1000 repositories per import`), and the repositories read from the GitHub organization or user. | | Repositories imported at once | 1 to 8, default 3 | One organization import's `concurrency` (**Repositories at a time** in the form). `422 concurrency must be between 1 and 8`. | | Git transfer | 6 hours | Copying the Git data in the import container. After that the import fails with `the Git transfer did not finish within 6 hours`. A transfer that fails for another reason is tried once more, except when push protection stopped it. | | Size of each push in the transfer | 32 MiB | The import agent pushes history in chunks of at most this size, below the 100 MB push limit, and smaller ones if a push is still refused as too large. | | Retries of each import step | 5, 15 seconds apart at first and backing off exponentially | Each step of the import's durable workflow, which may run for at most 15 minutes per call. | | Calls per paged step | 2,000 | Steps that page through GitHub (issues, comments, reviews and the rest). Each call reads up to 10 pages of 100 items (30 pull requests for reviews). More fails the import with `the <step> step did not finish within 2000 pages`. | | GitHub rate limits | Waits 5 seconds to 1 hour at a time | When GitHub answers with a rate limit, the import sleeps until it resets and continues; it doesn't fail. | | GitHub responses | 8 MiB each, 30 seconds per request | A larger response fails with `GitHub response is too large` or `GitHub response exceeded the size limit`. Redirects are followed only on the same host. | | Organization import duration | 1 week | An organization import waits up to 168 hours for its repositories. Any still unfinished then count as not imported, and the organization import fails. | | Draft organization imports | 7 days | An organization import started in the web form whose repositories were never chosen is cancelled (`draft expired`) and its GitHub token erased. | | Release assets | 2 GiB each | Larger assets, and assets that can't be downloaded, are skipped with a warning. | | Warnings | 500 per import, 1,000 characters each | Further warnings are not recorded. | | Imports listed | 100 | The most recent imports you started, in `GET /api/v3/diffinery/imports`, `list_imports` and the **Imports** page. | | GitHub token | 400 characters, no spaces | `422 github_token is not a valid token`. Deleted when the import ends. | | Push token of the transfer | 1 day, one repository | The token the import container pushes with. It works only for Git, Git LFS and the import's own agent endpoints, and is revoked when the transfer ends. | | Copied repository details | 20 topics, 1,000 characters of description, 500 of homepage | Taken from the GitHub repository. | ## Webhooks and events | Limit | Value | Applies to | | --- | --- | --- | | Webhook URL | `https://` to a public host | Other schemes fail with `webhook URL must use https`. `localhost`, single-label names, `.local` and `.internal` names, and private, loopback and link-local addresses fail with `webhook URL must be a public host`; Diffinery's own host and its subdomains fail with `webhook URL can't point at Diffinery itself`. A webhook saved before these checks that points at such an address is never delivered to (recorded status `0`). | | Webhooks per repository | 20 | The 21st fails with `422` and `a repository can have at most 20 webhooks`, as on GitHub. | | Delivery timeout | 10 seconds | A receiver that takes longer counts as failed (recorded status `0`). | | Redirects | Not followed | A `3xx` answer is recorded as the delivery's status. | | Failed deliveries | Retried up to 5 times | A network error, a timeout, `408`, `429` or a `5xx` status is retried 1 minute, 5 minutes, 30 minutes, 1 hour and 3 hours after successive failures (6 attempts at most, about 4 hours and 36 minutes in all), with the same `X-GitHub-Delivery` ID. Other statuses, including `3xx` and other `4xx`, are not retried. A retry is dropped if the webhook was deleted. Each attempt updates the last status and time shown on the webhook. See [Retries](https://diffinery.com/docs/repositories/webhooks#retries). | | Event processing retries | 8, then the dead-letter queue | If processing an event fails (audit log, webhooks, notifications), it is retried with backoff (starting at 20 seconds and doubling, at most 5 minutes) up to 8 times, then moved to `diffinery-events-dlq`. | ## Email | Limit | Value | Applies to | | --- | --- | --- | | Email log retention | 90 days | **Settings → Email** and `GET /api/v3/diffinery/emails` show emails from the last 90 days. Older entries are deleted once a day. | | Audit log retention | 2 years | Entries older than 730 days are deleted every hour (see [Audit log](https://diffinery.com/docs/security/audit-log#how-long-entries-are-kept)). | | Sending retries | 6 attempts | A temporary sending failure (rate limit, internal error or delivery failure reported by Cloudflare) is retried with backoff (starting at 30 seconds and doubling, at most 10 minutes). Other failures are logged as failed at once. After 6 attempts the email is logged as failed. | | Message body | 20,000 characters | Longer issue, pull request or comment text in an email is cut and ends with `…`. | | Subject | 900 characters | | | Commits in a push email | 10 | | | Jobs in a workflow run email | 20 | | | Line comments in a review email | 20 | | | Mentions per event | 60 | Only the first 60 distinct `@mentions` in one comment or description are notified. | | Recipients per event | 200 | At most 200 people are notified about one event, in the app or by email. | | Invitations | 50 per person per 24 hours | Invitations to an organization, a repository or the site, counted per inviter. The 51st answers `429 you can send at most 50 invitations in 24 hours; try again later`. Site administrators aren't limited. | | Test emails | 5 per account per hour | **Send me a test email** and `POST /api/v3/diffinery/emails/test`; the 6th answers `429 you can send at most 5 test emails an hour; try again later`. | | Morning digest | Once a day at the hour you choose, within 5 minutes; up to 8 lines per section | **Send me my digest now** and `POST /api/v3/diffinery/emails/digest`: 3 an hour, then `429 you can ask for your digest at most 3 times an hour; try again later`. "Since the last digest" reaches back at most 7 days. | | Images in emails | Linked, not loaded | An image in issue, pull request or comment text that isn't on Diffinery itself shows in the email as a link (`[alt text]`), so opening the email doesn't tell the image's host that you read it. | ## Platform limits for operators These come from `apps/diffinery/wrangler.jsonc` and matter when you run your own instance (see [Configuration](https://diffinery.com/docs/self-hosting/configuration)). | Limit | Value | Applies to | | --- | --- | --- | | Worker CPU time | 300,000 ms per request | `limits.cpu_ms`. | | Live update connection | 10 minutes, up to 10 topics | A page's WebSocket is closed with code `4001` after 10 minutes and reconnects at once, so access is checked again. | | Runner containers | Up to 100 instances of each size: `standard-4`, `standard-3`, `standard-1` and `basic` | `containers[RunnerContainer]`, `[RunnerContainerMedium]`, `[RunnerContainerSmall]`, `[RunnerContainerTiny]`. | | Sandbox containers | `standard-1`, up to 50 instances | `containers[SandboxContainer]`. | | Import containers | `standard-4`, up to 20 instances | `containers[ImportContainer]`: one container per Git transfer of an import. | | Container rollout grace period | 23,400 seconds for runners, 25,200 for imports, 3,600 for sandboxes | `rollout_active_grace_period`. A deploy doesn't replace a container younger than this, so running jobs (6 hours at most) and import transfers finish first. | | Container rollout steps | One step of 100% | `rollout_step_percentage`. Containers started after a deploy get the new image at once, so a new job doesn't start on the old one and get replaced. | | Event queue | Batches of up to 50 messages (2 seconds), 8 retries | `diffinery-events`, dead-letter queue `diffinery-events-dlq`. | | Mail queue | Batches of up to 20 messages (2 seconds), 8 retries | `diffinery-mail`, dead-letter queue `diffinery-mail-dlq`. The consumer itself gives up after 6 attempts. | | Workflow scheduler | Every 5 minutes | `SchedulerDO`'s own alarm (`SCHEDULE_TICK_MINUTES`, 1 to 60) starts scheduled workflows, up to 16 repositories at a time. | | Cron trigger | Every 5 minutes | `*/5 * * * *`: token expiry reminders, email log pruning, container registry cleanup, cancelling draft imports older than 7 days, and setting the scheduler's alarm if it isn't set. |