Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Public documentation at /docs, with formats for AI agents (#32)

50 guides plus REST API, CLI and MCP references generated from code, served without sign-in at /docs. For agents: /llms.txt, /llms-full.txt, Markdown for every page (.md or Accept: text/markdown), /docs/api/openapi.json and the MCP tools search_docs and read_docs. CI checks pages, links, anchors and reference coverage; the live test fetches the docs signed out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q5wwBFck8JHq14gHUM2CL
Canberk Demirci authored · GitHub committed signed1 parent 6c101bacommit aa4f8e0096e4ab1fe3b079c5dc3cfbcbb00323dcBrowse files
71 files changed+21766−1279
Files (71)
modified.github/workflows/live-test.yml+20−0View file
@@ -94,6 +94,26 @@
9494 [[ "$type" == image/svg+xml* ]] || { echo "::error::after removal expected the initials SVG, got $type"; exit 1; }
9595 echo "removed: initials again ($type)"
9696
97+ - name: Public docs are served without signing in
98+ run: |
99+ set -euo pipefail
100+ check() { # path, content type, text it must contain
101+ body=$(curl -fsS -D /tmp/h "$DIFFINERY$1")
102+ grep -qi "content-type: $2" /tmp/h || { echo "::error::$1 is not $2"; exit 1; }
103+ grep -qF -- "$3" <<<"$body" || { echo "::error::$1 doesn't contain: $3"; exit 1; }
104+ echo "$1 ok ($(wc -c <<<"$body") bytes)"
105+ }
106+ check /docs "text/html" "Overview"
107+ check /docs/actions/workflow-syntax "text/html" "View as Markdown"
108+ check /docs/quickstart.md "text/markdown" "# Quickstart"
109+ check /llms.txt "text/plain" "/docs/actions/workflow-syntax.md"
110+ check /llms-full.txt "text/plain" "# REST API reference"
111+ check /docs/api/openapi.json "application/json" '"openapi": "3.1.0"'
112+ check /-/cli/diffinery.mjs "text/javascript" "#!/usr/bin/env node"
113+ md=$(curl -fsS -H "Accept: text/markdown" "$DIFFINERY/docs/agents/mcp")
114+ grep -q "^# MCP" <<<"$md" || { echo "::error::Accept: text/markdown didn't return Markdown"; exit 1; }
115+ curl -fsS "$DIFFINERY/-/cli/diffinery.mjs" -o /tmp/diffinery.mjs && node /tmp/diffinery.mjs --version
116+
97117 - name: Email sending is configured
98118 if: steps.token.outputs.skip != 'true'
99119 run: |
modifiedREADME.md+12−2View file
@@ -21,6 +21,16 @@
2121 out with a `diffinery:allow-secret` comment, for test fixtures. Secrets use
2222 envelope encryption, and every sensitive action goes to an append-only audit log.
2323
24+## Documentation
25+
26+The full documentation is public at [diffinery.com/docs](https://diffinery.com/docs):
27+guides for every feature, plus REST API, CLI and MCP references generated from
28+this code. For AI agents there's [llms.txt](https://diffinery.com/llms.txt),
29+[llms-full.txt](https://diffinery.com/llms-full.txt), a Markdown version of
30+every page (add `.md` to its URL) and an
31+[OpenAPI document](https://diffinery.com/docs/api/openapi.json). The pages live
32+in [apps/diffinery/docs](apps/diffinery/docs).
33+
2434 ## How it fits on Cloudflare
2535
2636 | Piece | Cloudflare product |
@@ -106,7 +116,7 @@
106116 each person can do.
107117
108118 Nobody needs a Cloudflare account. Git and the CLI use a personal token from
109-*Settings → Tokens*.
119+*Settings → Access tokens*.
110120
111121 ```sh
112122 git clone https://diffinery.com/codician/website.git # username: anything, password: your dfy_ token
@@ -255,7 +265,7 @@
255265 the job's `GITHUB_TOKEN` (`permissions: packages: write`) or a personal
256266 token, then push and pull `diffinery.com/<owner>/<name>`. Images are
257267 private by default and can be linked to a repository. See
258- [docs/container-registry.md](docs/container-registry.md).
268+ [Container registry](https://diffinery.com/docs/packages/container-registry).
259269
260270 **Not supported yet:** `actions/github-script`,
261271 Windows and macOS hosted runners, and
modifiedapps/cli/src/commands/auth.ts+1−1View file
@@ -18,7 +18,7 @@
1818 summary: "Log in with a personal or agent token",
1919 description: `Stores the server URL and token in the config file (mode 0600).
2020 The token is read from --token, from stdin when piped, or prompted for.
21-Create a token in the web UI (Settings > Tokens) or with 'diffinery token create'.`,
21+Create a token in the web UI (Settings → Access tokens) or with 'diffinery token create'.`,
2222 options: {
2323 url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" },
2424 token: { type: "string", desc: "Token (dfy_...)", arg: "TOKEN" },
modifiedapps/diffinery/.gitignore+1−0View file
@@ -1,1 +1,2 @@
11 build/
2+src/docs/content.generated.ts
addedapps/diffinery/docs/accounts/invitations.md+247−0View file
@@ -1,0 +1,247 @@
1+---
2+title: Invitations
3+description: How people join an invite-only Diffinery instance, who can invite, Add people by username or email, revoking invitations, the CLI and API, and access requests.
4+---
5+
6+Diffinery is invite-only: nobody can sign up from the sign-in page. 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: people who already have an account are added right away, and email addresses without an account get an invitation.
7+
8+## How sign-up works
9+
10+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).
11+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.
12+3. The invited person opens `https://diffinery.com/invite/<token>`, picks a username, creates a passkey and is signed in.
13+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.
14+5. They land on the organization or repository of the invite they opened (or the dashboard, for an instance invite).
15+
16+Nobody needs a password or a Cloudflare account. After joining, they use their passkey in the browser and create [personal access tokens](/docs/accounts/tokens) for Git, the CLI and agents.
17+
18+## Who can invite
19+
20+| Who | What they can invite to | Where |
21+| --- | --- | --- |
22+| Site administrators | The instance (no organization or repository), and any organization | **Settings → People and invitations**, an organization's **People** tab, `diffinery invite`, the API |
23+| Organization owners | Their organization, as member, admin or owner | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API |
24+| Organization admins | Their organization, as member or admin | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API |
25+| 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**) |
26+
27+Everyone else, including organization members and people with a lower repository role, can't create invites.
28+
29+## Invite from the web UI
30+
31+### Invite someone to the instance
32+
33+Site administrators only. An instance invite lets the person create an account, without adding them to any organization or repository.
34+
35+1. Open **Settings → People and invitations** (`/settings/admin`).
36+2. Under **Invite someone to this instance**, enter the **Email address**.
37+3. Click **Send invitation**.
38+
39+The page shows "Invite created for <email>" with the link and a copy button: "We emailed it to <email>; you can also send it yourself. It expires in 14 days and works once: they pick a username and create a passkey."
40+
41+### Add people to an organization
42+
43+Organization owners and admins, and site administrators. The **Add people** form on the organization's **People** tab takes a username or an email address.
44+
45+1. Open the organization's page, for example `https://diffinery.com/acme`, and click the **People** tab.
46+2. Under **Add people**, enter a **Username or email**: `octocat` (or `@octocat`) for someone who has an account, or `friend@example.com` for anyone.
47+3. Choose a **Role**: **Member** (default), **Admin** or **Owner**. Only owners can choose **Owner**.
48+4. Click **Add**.
49+
50+| You entered | What happens |
51+| --- | --- |
52+| A username, or the email address of an existing account | They join right away, with no invitation to accept: "Added octocat to acme as member. We let them know." They get an email and an in-app notification. |
53+| An email address without an account | Diffinery emails an invitation. The page shows **Invitation sent to friend@example.com** with the link and a copy button, and the note "They don't have an account yet, so we emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: opening it creates their account and passkey and adds them here." |
54+
55+The invitation then appears under **Pending invitations** on the same tab, with who sent it, when, the expiry date and the role. See [Organizations](/docs/accounts/organizations#add-people) for every message the form can show.
56+
57+You can send only one pending invitation per email address and organization from this form. 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").
58+
59+### Revoke an organization invitation
60+
61+On the organization's **People** tab, under **Pending invitations**, click **Revoke** next to the invitation and confirm "Revoke the invitation for friend@example.com? The link stops working." You see "Invitation revoked." Opening the link afterwards shows "this invite is invalid, already used, or expired". The person isn't notified.
62+
63+Only organization invitations can be revoked. Repository and instance invitations can't be withdrawn; they stop working once used or after 14 days.
64+
65+### Add people to a repository
66+
67+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:
68+
69+1. Under **Add people**, enter a **Username or email**: a person (`octocat`), an agent (`review-bot`) or an email address (`friend@example.com`).
70+2. Choose a **Role**: **Read**, **Triage**, **Write** (default), **Maintain** or **Admin**.
71+3. Click **Add**.
72+
73+| You entered | What happens |
74+| --- | --- |
75+| The username of a person or an agent, or the email address of an existing account | They get the role right away as a direct collaborator: "octocat now has write access. We let them know." People get an email and an in-app notification. If they already were a collaborator, their role changes to the one you chose. |
76+| An email address without an account | Diffinery emails an invitation and shows **Invitation sent to friend@example.com** with the link to send yourself. When the person joins, they get that role on the repository. |
77+
78+| Message | Cause |
79+| --- | --- |
80+| "no one has the username octocat; to invite someone new, enter their email address" | No account has that username. |
81+| "acme is an organization; add its people instead" | Organizations can't be collaborators. |
82+| "octocat's account is disabled" | A site administrator disabled the account. |
83+| "enter a valid email address" | The text has an `@` but isn't a complete address. |
84+
85+The repository's **Settings → Collaborators** page doesn't list pending invitations. See [Permissions](/docs/accounts/permissions) for what each role allows.
86+
87+## Invite from the CLI
88+
89+`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:
90+
91+```sh
92+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
93+diffinery login
94+```
95+
96+The CLI needs Node.js 22 or later.
97+
98+```sh
99+# Invite to the acme organization as a member (the default role)
100+diffinery invite friend@example.com --org acme
101+
102+# Invite as an organization admin
103+diffinery invite friend@example.com --org acme --role admin
104+
105+# Invite to the instance only (site administrators)
106+diffinery invite friend@example.com
107+```
108+
109+| Option | Description |
110+| --- | --- |
111+| `-o, --org ORG` | Organization to join. Leave it out for an instance invite, which only site administrators can create. |
112+| `-r, --role ROLE` | `member` (default), `admin` or `owner`. Needs `--org`. |
113+| `--json` | Print the API response (`id` and `url`) as JSON. |
114+
115+The command prints the invite link and reminds you that it is valid for 14 days. Unlike 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)). The CLI can't create repository invites; use the repository's **Settings → Collaborators** page. See [CLI reference](/docs/reference/cli) for every command.
116+
117+## Invite with the API
118+
119+Both endpoints take a JSON body and return `201 Created` with the invite's `id` and `url`. Diffinery also emails the link.
120+
121+Inviting 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](/docs/accounts/tokens#account-level-actions).
122+
123+### Organization invite
124+
125+`POST /api/v3/orgs/{org}/invitations`
126+
127+| Field | Type | Description |
128+| --- | --- | --- |
129+| `email` | string | Required. The address to invite. |
130+| `role` | string | `member` (default), `admin` or `owner`. |
131+
132+```sh
133+curl -X POST https://diffinery.com/api/v3/orgs/acme/invitations \
134+ -H "Authorization: Bearer dfy_..." \
135+ -H "Content-Type: application/json" \
136+ -d '{"email": "friend@example.com", "role": "member"}'
137+```
138+
139+```json
140+{ "id": "3f1c0e8a9b7d4c2e8f6a5b4c3d2e1f0a", "url": "https://diffinery.com/invite/..." }
141+```
142+
143+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, this endpoint always creates an invitation; it doesn't add existing accounts directly.
144+
145+### Instance invite
146+
147+`POST /api/v3/invitations` with `{"email": "friend@example.com"}`. Site administrators only.
148+
149+### API errors
150+
151+| Status | Message | Cause |
152+| --- | --- | --- |
153+| 401 | `sign in required` | No token was sent. |
154+| 403 | `this token needs the admin scope` | The token's scope is `read` or `write`. Create an `admin` token. |
155+| 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. |
156+| 403 | `only organization owners and admins can invite` | You are not an owner or admin of the organization. |
157+| 403 | `only owners can invite owners` | An organization admin asked for `"role": "owner"`. |
158+| 403 | `only site admins can invite people without an organization` | Instance invites need a site administrator. |
159+| 404 | `not found` | The organization doesn't exist, or the name is not an organization. |
160+| 422 | `a valid email is required` | The `email` field is missing or isn't an email address. |
161+| 422 | `role must be member, admin or owner` | `role` has another value. |
162+
163+Error bodies are JSON: `{"message": "...", "documentation_url": "..."}`.
164+
165+The full endpoint list is in the [API reference](/docs/api).
166+
167+## The invite email
168+
169+Diffinery sends the invite from `notifications@diffinery.com`. The subject depends on the kind of invite:
170+
171+| Invite | Subject |
172+| --- | --- |
173+| Organization | `@octocat invited you to join the acme organization on Diffinery` |
174+| Repository | `@octocat invited you to collaborate on acme/website` |
175+| Instance | `@octocat invited you to Diffinery` |
176+
177+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.
178+
179+If the address already belongs to a Diffinery account (an invitation from the CLI or API), the email goes to that account.
180+
181+## Invite link rules
182+
183+| Rule | Value |
184+| --- | --- |
185+| Lifetime | 14 days from creation |
186+| 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). |
187+| Who can use it | Only the email address it was sent to |
188+| What joining accepts | Every open invite for that email address, not just the one you opened |
189+| Withdrawing | Organization invitations can be revoked from the organization's **People** tab. Repository and instance invitations can't; they stop working once used or after 14 days. |
190+
191+If you sent a repository or instance invite to the wrong address, a site administrator can disable the account it creates (see [Site administration](/docs/admin/site-administration#people)).
192+
193+## Choosing a username
194+
195+The **Join Diffinery** page suggests a username made from the part of the email address before the `@`. Rules:
196+
197+- Starts with a letter or digit, then letters, digits, `-` and `_`, up to 39 characters.
198+- 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` and `diffinery`.
199+
200+Your username is part of your repository URLs (`https://diffinery.com/<username>/<repo>`). You can change it later under **Settings → Profile**, which changes those URLs; existing clones then need a new remote URL (see [Profile and preferences](/docs/accounts/profile-and-preferences#change-your-username)).
201+
202+| Message | What to do |
203+| --- | --- |
204+| "Usernames use letters, numbers and dashes (up to 39), and some names are reserved." | Pick a name that follows the rules above. |
205+| "The username <name> is taken. Pick another one." | Pick another name. |
206+| "<email> 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**. |
207+| "This invite has no email address. Sign in first, then open it again." | Sign in with your existing account, then open the link again. |
208+
209+## Accept an invite with an existing account
210+
211+If the invited address already has an account:
212+
213+1. Open the invite link. The page says the address already has an account and shows a **Sign in** button.
214+2. Sign in with your passkey. Diffinery brings you back to the invite.
215+3. Click **Accept invite**.
216+
217+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 <email>; sign in with that address" (HTTP 403).
218+
219+When someone accepts your invite, you get an "@<username> accepted your invitation" email (part of the **Organizations** email group, which you can turn off; see [Notifications](/docs/accounts/notifications)).
220+
221+## Pending accounts
222+
223+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:
224+
225+- can sign in, but only sees a **Waiting for an invite** page,
226+- can't see repositories, create organizations or tokens, or use the API (requests return "your account is waiting for an invite"),
227+- becomes active as soon as anyone creates an invite for its email address, an organization or repository admin adds the person by username or email with **Add people**, or a site administrator approves it.
228+
229+> **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.
230+
231+## Access requests
232+
233+From the **Waiting for an invite** page, a person with a pending account can ask the administrators for access:
234+
235+1. Optionally write a **Note to the administrators** (up to 500 characters), for example who you are and what you want to work on.
236+2. Click **Request access**. You see "Request sent. An administrator will review it."
237+
238+Diffinery emails up to 20 active site administrators an "<email> 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.
239+
240+Approving an account doesn't add it to any organization or repository; invite the person or add them by username for that.
241+
242+## How invitations differ from GitHub
243+
244+- On GitHub anyone can create an account. On Diffinery the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance.
245+- GitHub organization and repository invitations go to existing GitHub users. Diffinery invites go to an email address, and the same link creates the account.
246+- **Add people** takes a username or an email address, as on GitHub, but someone who already has an account is added immediately, with no invitation to accept.
247+- Organization invitations can be revoked from the **People** tab. Repository and instance invitations can't be cancelled; they expire after 14 days.
addedapps/diffinery/docs/accounts/notifications.md+334−0View file
@@ -1,0 +1,334 @@
1+---
2+title: Notifications
3+description: In-app notifications and email from Diffinery: every email group and template, settings, watching, thread subscriptions, unsubscribe links, headers and the API.
4+---
5+
6+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.
7+
8+## The notifications list and notification settings
9+
10+Two pages share the name, and they do different things:
11+
12+| Page | Where | What it is for |
13+| --- | --- | --- |
14+| **Notifications** (the list) | The bell in the header, or `/notifications` | Reading what happened: your recent notifications, marking them read |
15+| **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 |
16+
17+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.
18+
19+## The notifications list
20+
21+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 or a repository), a title such as `octocat commented on acme/website#42: Cache build artifacts`, its kind (for example `issue comment`, `pull request review` or `run failed`) and when it happened.
22+
23+- Click an entry to open what it is about; that marks it read.
24+- Click the check mark (**Mark as read**) next to an unread entry to mark only that one read.
25+- Click **Mark all as read** to clear the list.
26+- Click the gear button (**Notification settings**) to open **Settings → Notifications**.
27+
28+Your dashboard also shows up to 8 unread notifications under **Unread notifications**.
29+
30+You get an in-app notification for:
31+
32+- issue and pull request activity you take part in, when **Participating → On Diffinery** is on,
33+- activity in repositories you watch with **All activity**, when **Watching → On Diffinery** is on,
34+- failed workflow runs you started, when **Actions → On Diffinery** is on,
35+- someone adding you to an organization with **Add people** (kind `org membership`, for example "<admin> added you to acme as a member"),
36+- someone giving you access to a repository with **Add people** (kind `repo access`, for example "<admin> gave you write access to acme/website").
37+
38+The last two are always sent, whatever your settings; the matching emails belong to the **Organizations** and **Repositories** groups.
39+
40+Your own actions never create a notification for you. Other repository and organization events (role changes, removals, repositories created or deleted), and security and account events, are sent by email only.
41+
42+## Email groups
43+
44+Every email belongs to one group. Security and account emails always send; you can turn the others off.
45+
46+| Group | Emails | Can be turned off | Default |
47+| --- | --- | --- | --- |
48+| 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, push blocked or push protection bypassed, account disabled or enabled | No | On |
49+| Account | Invitations, welcome, sign-in links, test email | No | On |
50+| 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 |
51+| Watching | Everything else in repositories you watch with **All activity** | Yes | On |
52+| 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 |
53+| 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 |
54+| 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 |
55+
56+## Notification settings
57+
58+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`).
59+
60+The **Notify me about** table has a row per group with an **On Diffinery** checkbox (the notifications list) and an **Email** checkbox:
61+
62+| Row | On Diffinery | Email | Extra option |
63+| --- | --- | --- | --- |
64+| **Participating** | Yes | Yes | |
65+| **Watching** | Yes | Yes | |
66+| **Actions** | Yes | Yes | **Only failed runs** (on by default) |
67+| **Repositories** | No | Yes | |
68+| **Organizations** | No | Yes | |
69+| **Security and account** | No | Always on (locked) | |
70+
71+Below the table:
72+
73+- **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".
74+- **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**.
75+
76+Click **Save notification settings**. You see "Notification settings saved."
77+
78+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 a **Send me a test email** button.
79+
80+## Who gets notified about issues and pull requests
81+
82+For each event on an issue or pull request, Diffinery works out who is taking part:
83+
84+| You are notified because | Reason (`X-Diffinery-Reason`) |
85+| --- | --- |
86+| Your review was requested on the pull request | `review_requested` |
87+| You are assigned | `assign` |
88+| You were @mentioned in the text of this event (the description, comment or review) | `mention` |
89+| You opened the issue or pull request | `author` |
90+| You commented on it or reviewed it before | `comment` |
91+| You clicked **Subscribe** on it | `manual` |
92+| You watch the repository with **All activity** | `subscribed` |
93+
94+When several apply, the email gives the first one in this order. Mentions use `@username` outside code blocks and inline code.
95+
96+What sends:
97+
98+| Event | Who hears about it |
99+| --- | --- |
100+| Issue or pull request opened | Participants (assignees, requested reviewers, people mentioned) and watchers |
101+| Comment | Participants and watchers |
102+| Review submitted | Participants and watchers |
103+| Closed, merged or reopened | Participants and watchers |
104+| New commits pushed to a pull request | Participants only |
105+| Someone assigned | The new assignees and other participants, not watchers |
106+| Review requested | The requested reviewers and other participants, not watchers |
107+| Title or description edited, merge queue changes | Nobody |
108+
109+Rules that apply to every event:
110+
111+- 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`).
112+- A repository set to **Ignore** sends you nothing about its issues and pull requests.
113+- A muted thread (you clicked **Unsubscribe**) sends you nothing, except when that event @mentions you, assigns you or asks for your review.
114+- You must still be able to read the repository. People who lost access stop hearing about it.
115+- Agents don't receive email.
116+
117+## Watch a repository
118+
119+The **Watch** menu at the top of every repository sets how much you hear about it:
120+
121+| Menu item | Level | Effect |
122+| --- | --- | --- |
123+| **Watch: participating** | Participating and @mentions | The default: only threads you take part in |
124+| **Watch: all activity** | All activity | Every new issue, pull request, comment, review and state change, plus scheduled workflow runs |
125+| **Watch: ignore** | Ignore | Nothing about this repository's issues and pull requests |
126+
127+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**.
128+
129+With the API (GitHub's repository subscription endpoints):
130+
131+```sh
132+# Watch all activity
133+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \
134+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
135+ -d '{"subscribed": true}'
136+
137+# Ignore the repository
138+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \
139+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
140+ -d '{"ignored": true}'
141+
142+# Back to participating only
143+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/subscription \
144+ -H "Authorization: Bearer dfy_..."
145+```
146+
147+`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.
148+
149+## Subscribe to or mute an issue or pull request
150+
151+The sidebar of every issue and pull request has a **Notifications** section with one button:
152+
153+- **Subscribe** follows the thread even if you aren't taking part: "Subscribed. You'll be notified about all activity."
154+- **Unsubscribe** mutes it: "Unsubscribed. You'll still be notified if you're @mentioned, assigned or asked to review."
155+
156+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."
157+
158+## Workflow run notifications
159+
160+When a workflow run finishes:
161+
162+| Run started by | Who is notified |
163+| --- | --- |
164+| A person (a push, a pull request, a manual dispatch) | That person |
165+| An agent | People watching the repository with **All activity** (the agent's owner isn't notified directly yet) |
166+| Diffinery itself (for example a schedule) | People watching the repository with **All activity** |
167+
168+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.
169+
170+## Unsubscribe links
171+
172+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.
173+
174+| Footer link | Where | What it does |
175+| --- | --- | --- |
176+| **Mute this thread** | Issue and pull request emails | Mutes that issue or pull request, like **Unsubscribe** on the page |
177+| **Stop watching acme/website** | Emails you got because you watch the repository | Sets the repository back to participating |
178+| **Unsubscribe from emails like this** | Every email you can turn off | Turns off email for that group (for example Watching) |
179+
180+The mail app's one-click button uses the first link: muting the thread for issue and pull request emails, turning off the group for the rest.
181+
182+The links are signed, so they work without signing in and can't be altered to change someone else's settings. Opening one shows a page naming the change and the account (for example "Mute acme/website#42") with a button to confirm; after confirming you can click **Undo**. Security and account emails have no unsubscribe links.
183+
184+## Email headers
185+
186+Every email has an HTML and a plain-text version and these headers, which you can use in mail filters:
187+
188+| Header | Value |
189+| --- | --- |
190+| `From` | `notifications@diffinery.com`. The display name is the person who acted, for issue and pull request emails (`octocat (Diffinery)`), otherwise `Diffinery`. |
191+| `Subject` | Issue and pull request emails: `[acme/website] <title> (PR #42)` or `(Issue #37)`, with `Re: ` for every email after the first. Others start with `[acme/website]` or `[Diffinery]`. |
192+| `X-Diffinery-Reason` | Why you got the email (see below), like GitHub's `X-GitHub-Reason` |
193+| `X-Diffinery-Kind` | The email template, for example `thread`, `run` or `signin_new` (see [Every email Diffinery sends](#every-email-diffinery-sends)) |
194+| `X-Diffinery-Recipient` | Your username |
195+| `X-Diffinery-Repository` | `owner/repo` (or the organization) the email is about, when there is one |
196+| `List-Id` | For repository emails, `acme/website <website.acme.diffinery.com>` |
197+| `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 |
198+| `List-Unsubscribe`, `List-Unsubscribe-Post` | On emails you can turn off |
199+| `Precedence: list` | On emails you can turn off |
200+| `Auto-Submitted: auto-generated` | On every email |
201+
202+### X-Diffinery-Reason values
203+
204+| Value | Meaning (as written in the email footer) |
205+| --- | --- |
206+| `author` | You opened this thread. |
207+| `comment` | You commented (or reviewed). |
208+| `mention` | You were mentioned. |
209+| `assign` | You were assigned. |
210+| `review_requested` | Your review was requested. |
211+| `manual` | You subscribed to this thread. |
212+| `subscribed` | You are watching this repository. |
213+| `your_activity` | Something you did on Diffinery (your own updates, a repository you created). |
214+| `ci_activity` | A workflow run triggered by you, or in a repository you watch. |
215+| `security_alert` | A security notice about your account. These can't be turned off. |
216+| `invitation` | Someone invited this email address to Diffinery. |
217+| `admin` | You are an administrator (of the organization, repository or instance). |
218+| `account` | You have a Diffinery account (account and membership changes). |
219+
220+## Every email Diffinery sends
221+
222+The **Kind** column is the `X-Diffinery-Kind` header. Site administrators can preview every one of these under **Settings → Email** (see [Site administration](/docs/admin/site-administration#email)).
223+
224+| Kind | Subject (example) | Group | Reason | Sent when |
225+| --- | --- | --- | --- | --- |
226+| `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) |
227+| `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) |
228+| `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 |
229+| `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 |
230+| `passkey_removed` | `[Diffinery] A passkey was removed from your account` | Security | `security_alert` | You removed a passkey |
231+| `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 |
232+| `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) |
233+| `agent_created` | `[Diffinery] A new agent was added: review-bot` | Security | `security_alert` | You created an agent |
234+| `push_blocked` | `[acme/website] Your push was blocked: possible secrets` | Security | `security_alert` | Push protection rejected your push (for an agent's push, its owner is told) |
235+| `push_bypassed` | `[acme/website] Push protection was bypassed` | Security | `security_alert` | A repository admin pushed past push protection; sent to the pusher and the repository's admins |
236+| `account_status` | `[Diffinery] Your account was disabled` or `Your account was enabled` | Security | `account` | A site administrator disabled your account, or enabled it again |
237+| `invite` | `@octocat invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance) |
238+| `welcome` | `Welcome to Diffinery, @octocat` | Account | `account` | You created your account from an invite |
239+| `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you |
240+| `test` | `[Diffinery] Test email` | Account | `account` | You clicked **Send me a test email** (or used the API) |
241+| `invite_accepted` | `[Diffinery] @octocat accepted your invitation` | Organizations | `account` | Someone accepted an invite you created |
242+| `access_request` | `[Diffinery] newcomer@example.com asked for access` | Organizations | `admin` | A person waiting for an invite requested access (sent to site administrators) |
243+| `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"). |
244+| `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 |
245+| `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 |
246+| `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 |
247+
248+Issue and pull request emails (`thread`) come in these variants:
249+
250+| Event | Title in the email |
251+| --- | --- |
252+| Opened | `@octocat wants to merge cache-artifacts into main` (pull request) or `@octocat opened acme/website#37` (issue); includes the description, the pull request's intent and recent commits |
253+| Comment | `@octocat commented on acme/website#42`, with the comment |
254+| Review | `@octocat approved`, `requested changes on` or `reviewed acme/website#42`, with the review and its line comments |
255+| Closed | `@octocat closed acme/website#37 as completed` (or `as not planned`) |
256+| Merged | `@octocat merged acme/website#42 into main` |
257+| Reopened | `@octocat reopened acme/website#37` |
258+| Assigned | `@octocat assigned you to acme/website#37` |
259+| Review requested | `@octocat requested your review on acme/website#42` |
260+| Pushed | `@octocat pushed 2 commits to cache-artifacts`, with the commits |
261+
262+## Send a test email
263+
264+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: ...".
265+
266+With the API (needs a `write` or `admin` token that isn't limited to repositories):
267+
268+```sh
269+curl -X POST https://diffinery.com/api/v3/diffinery/emails/test -H "Authorization: Bearer dfy_..."
270+```
271+
272+```json
273+{ "status": "sent", "messageId": "..." }
274+```
275+
276+`status` is `sent`, `skipped` or `failed`, with a `detail` when it wasn't sent.
277+
278+## Notifications API
279+
280+| Method and path | Description | Token scope |
281+| --- | --- | --- |
282+| `GET /api/v3/notifications` | Unread notifications (add `?all=true` for read ones too): `id`, `kind`, `title`, `url`, `createdAt`, `readAt` | any |
283+| `PUT /api/v3/notifications` | Mark all as read; returns `205` | `write` or `admin`, all repositories |
284+| `GET /api/v3/diffinery/notification-settings` | Your settings (see below) | any |
285+| `PUT /api/v3/diffinery/notification-settings` | Change settings; send only the keys to change | `write` or `admin`, all repositories |
286+| `GET /api/v3/repos/{owner}/{repo}/subscription` | Your watch level | any |
287+| `PUT /api/v3/repos/{owner}/{repo}/subscription` | Watch (`{"subscribed": true}`) or ignore (`{"ignored": true}`) | `write` or `admin` |
288+| `DELETE /api/v3/repos/{owner}/{repo}/subscription` | Back to participating | `write` or `admin` |
289+| `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 |
290+| `POST /api/v3/diffinery/emails/test` | Send yourself a test email now | `write` or `admin`, all repositories |
291+
292+The settings object, with its defaults:
293+
294+```json
295+{
296+ "participating": { "web": true, "email": true },
297+ "watching": { "web": true, "email": true },
298+ "actions": { "web": true, "email": true, "failuresOnly": true },
299+ "repos": { "email": true },
300+ "orgs": { "email": true },
301+ "ownActivity": false,
302+ "autoWatch": true
303+}
304+```
305+
306+For example, to stop watching emails and get every workflow run result:
307+
308+```sh
309+curl -X PUT https://diffinery.com/api/v3/diffinery/notification-settings \
310+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
311+ -d '{"watching": {"email": false}, "actions": {"failuresOnly": false}}'
312+```
313+
314+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`.
315+
316+## Delivery
317+
318+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:
319+
320+| Detail | Meaning |
321+| --- | --- |
322+| `turned off <group> emails` | You turned off that group (for example `turned off watching emails`). |
323+| `no email address` | Your account has no email address. |
324+| `account disabled` | Disabled accounts only get the email saying they were disabled or enabled. |
325+| `email sending is not configured (no EMAIL binding)` | The instance can't send email; ask a site administrator. |
326+
327+## How notifications differ from GitHub
328+
329+- 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.
330+- Diffinery has no custom watch level (such as releases or discussions only).
331+- 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.
332+- Security and account emails can't be turned off.
333+- The notifications list shows your 100 most recent notifications and has no per-thread done or saved states.
334+- Being added to an organization or a repository with **Add people** always creates an in-app notification, with no setting to turn it off.
addedapps/diffinery/docs/accounts/organizations.md+243−0View file
@@ -1,0 +1,243 @@
1+---
2+title: Organizations
3+description: Create organizations, add people by username or email, manage roles and invitations, set the organization's picture, and share repositories and secrets.
4+---
5+
6+An organization is a shared account that owns repositories, agents, container images and Actions secrets 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.
7+
8+## Organization roles
9+
10+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.
11+
12+| Capability | member | admin | owner |
13+| --- | --- | --- | --- |
14+| Role on every organization repository | write | admin | admin |
15+| See the organization's private repositories | Yes | Yes | Yes |
16+| Create repositories in the organization | Yes | Yes | Yes |
17+| Create container images in the organization and pull its images | Yes | Yes | Yes |
18+| Push to, delete and change the settings of an organization image that isn't linked to a repository | Only images they created | Yes | Yes |
19+| See the **People** and **Agents** tabs | Yes | Yes | Yes |
20+| **Add people** (by username or email) | No | Yes (as member or admin) | Yes (any role) |
21+| See and revoke pending invitations | No | Yes | Yes |
22+| Change roles, remove members | No | Yes, except owners | Yes, including owners |
23+| Create and manage the organization's agents | No | Yes | Yes |
24+| Manage organization Actions secrets | No | Yes | Yes |
25+| Change the organization's picture through the API | No | Yes | Yes |
26+| Open the **Settings** tab: rename, display name, picture | No | No | Yes |
27+| Get emails when repositories are created or deleted in the organization | No | Yes | Yes |
28+
29+Site administrators can do everything an owner can in every organization, and have admin on every repository.
30+
31+A container image linked to a repository follows that repository's roles instead (read pulls, write pushes, admin deletes). See [Container registry](/docs/packages/container-registry).
32+
33+A member's role on a repository is the highest of their organization role's repository role 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](/docs/accounts/permissions) for what each repository role allows.
34+
35+## Create an organization
36+
37+Any active account can create an organization and becomes its first owner.
38+
39+### In the web UI
40+
41+1. Go to **Settings → Organizations** (`/settings/orgs`), or click **New organization** on your dashboard.
42+2. Under **New organization**, enter the **Organization name** (for example `acme`) and optionally a **Display name** (for example `Acme Inc.`).
43+3. Click **Create organization**.
44+
45+Diffinery opens the new organization's **People** tab, where you can add people right away.
46+
47+### With the API
48+
49+```sh
50+curl -X POST https://diffinery.com/api/v3/user/orgs \
51+ -H "Authorization: Bearer dfy_..." \
52+ -H "Content-Type: application/json" \
53+ -d '{"login": "acme", "name": "Acme Inc."}'
54+```
55+
56+The response (`201 Created`) describes the organization, with `"type": "Organization"` and its `html_url`.
57+
58+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.
59+
60+### Name rules
61+
62+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.
63+
64+- Starts with a letter or digit, then letters, digits, `-` and `_`, up to 100 characters. Dots aren't allowed.
65+- Not already used by a person, organization or agent: "name already taken".
66+- Not reserved (for example `api`, `settings`, `new`, `login`, `admin`, `docs`): "invalid or reserved organization name".
67+
68+## The organization page
69+
70+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:
71+
72+| Tab | Who sees it | What it shows |
73+| --- | --- | --- |
74+| **Repositories** | Everyone signed in | Repositories you can see. Members get a **New repository** button. |
75+| **People** | Everyone signed in | Members and their roles. Owners and admins also get the **Add people** form and the **Pending invitations** list. |
76+| **Agents** | Everyone signed in | The organization's agents, their maximum role and status. Owners and admins get a **Manage agents** button. |
77+| **Settings** | Owners | The organization's picture, name and display name. Others get `403 organization owners only`. |
78+| **Packages** | Everyone signed in | Container images the organization owns (`/acme/-/packages`). See [Container registry](/docs/packages/container-registry). |
79+
80+People who aren't members only see repositories that are public, or that they were added to directly.
81+
82+## Add and remove members
83+
84+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".
85+
86+### Add people
87+
88+The **Add people** form takes a username or an email address, like GitHub's "Add people" or "Invite member" dialog:
89+
90+1. Open `https://diffinery.com/acme?tab=people`.
91+2. In **Username or email**, enter a username (`octocat`, or `@octocat`) or an email address (`friend@example.com`).
92+3. Choose a **Role**: **Member** (default) or **Admin**. Owners can also choose **Owner**.
93+4. Click **Add**.
94+
95+What happens depends on who you entered:
96+
97+| You entered | Result |
98+| --- | --- |
99+| The username of a person | They become a member right away, with no invitation to accept. You see "Added octocat to acme as member. We let them know." They get an email ("You're now a member of acme", naming who added them) and an in-app notification ("<your username> added you to acme as a member"). |
100+| The email address of someone who already has an account | The same as entering their username: they are added right away. |
101+| An email address with no account | Diffinery creates an invitation and emails it. The page shows **Invitation sent to friend@example.com** with the link and a copy button: "They don't have an account yet, so we emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: opening it creates their account and passkey and adds them here." |
102+
103+A person whose account is waiting for an invite (pending) becomes active when you add them by username or email. See [Invitations](/docs/accounts/invitations) for how invitations work, including inviting from the CLI and API.
104+
105+| Message | Cause |
106+| --- | --- |
107+| "enter a username or an email address" | The field was empty. |
108+| "enter a valid email address" | The text has an `@` but isn't a complete address such as `friend@example.com`. |
109+| "no one has the username octocat; to invite someone new, enter their email address" | No account has that username. Enter their email address to invite them. |
110+| "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)). |
111+| "octocat's account is disabled" | A site administrator disabled the account. |
112+| "octocat is already a member of acme" | Change their role in the members table instead. |
113+| "friend@example.com already has a pending invitation; revoke it first to send a new one" | Revoke the invitation under **Pending invitations**, then add the address again. |
114+| "only owners can add owners" | Admins can add members and admins only. |
115+| "organization admins only" | Only owners, admins and site administrators can add people. |
116+
117+### Pending invitations
118+
119+Owners and admins see a **Pending invitations** list on the **People** tab with each invitation's email address, who invited them and when, the expiry date and the role. Accepted and expired invitations aren't listed.
120+
121+To withdraw an invitation, click **Revoke** and confirm "Revoke the invitation for friend@example.com? The link stops working." You see "Invitation revoked." Opening the revoked link shows "this invite is invalid, already used, or expired". The person isn't told.
122+
123+### Change a member's role
124+
125+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.
126+
127+### Remove a member
128+
129+Click **Remove** in the member's row and confirm "Remove octocat 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. Their personal repositories and accounts are not affected.
130+
131+Rules:
132+
133+| Rule | Message |
134+| --- | --- |
135+| Only owners can promote someone to owner, change an owner's role or remove an owner. | "only owners can manage owners" |
136+| 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" |
137+| Only owners and admins can manage members. | "organization admins only" |
138+| The username must belong to a person. | "user not found" |
139+
140+### Leave an organization
141+
142+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.
143+
144+## Organization repositories
145+
146+Any member can create a repository owned by the organization.
147+
148+- **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`).
149+- **CLI:** `diffinery repo create website --org acme` (add `--public` for a public repository; the default is private).
150+- **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`.
151+
152+Access to organization repositories comes from the organization role (members get write, owners and admins get admin), plus any collaborators added under the repository's **Settings → Collaborators**, which can include people outside the organization and agents. 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.
153+
154+## Organization secrets
155+
156+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.
157+
158+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`.
159+
160+```sh
161+# Create or update (the value is read from a hidden prompt, stdin or --body)
162+diffinery secret set NPM_TOKEN --org acme
163+
164+# List names and when they were updated (values are never returned)
165+diffinery secret list --org acme
166+
167+# Delete
168+diffinery secret delete NPM_TOKEN --org acme
169+```
170+
171+| Method and path | What it does |
172+| --- | --- |
173+| `GET /api/v3/orgs/{org}/actions/secrets` | List secret names and `updated_at` |
174+| `PUT /api/v3/orgs/{org}/actions/secrets/{name}` | Create or update; body `{"value": "..."}`; returns `204` |
175+| `DELETE /api/v3/orgs/{org}/actions/secrets/{name}` | Delete; returns `204` |
176+
177+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. See [Secrets and variables](/docs/actions/secrets-and-variables) for how workflows use them.
178+
179+## Organization agents
180+
181+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](/docs/agents/overview).
182+
183+## Organization picture
184+
185+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.
186+
187+Owners change it on the **Settings** tab, in the **Profile picture** box:
188+
189+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.
190+2. Click **Save picture**. You see "Organization picture updated."
191+3. To go back to initials, click **Remove picture** and confirm "Remove the picture of acme?". You see "Organization picture removed."
192+
193+Owners and admins (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:
194+
195+```sh
196+curl -X PUT https://diffinery.com/api/v3/orgs/acme/avatar \
197+ -H "Authorization: Bearer dfy_..." \
198+ --data-binary @logo.png
199+
200+curl -X DELETE https://diffinery.com/api/v3/orgs/acme/avatar \
201+ -H "Authorization: Bearer dfy_..."
202+```
203+
204+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 and admins can change its picture` for members, and `404 not found` when you aren't in the organization. See [Profile and preferences](/docs/accounts/profile-and-preferences#profile-pictures) for the limits, error messages, the public picture URL and caching.
205+
206+## Rename an organization
207+
208+Owners open the **Settings** tab, change **Organization name** or **Display name** and click **Save**. You see "Organization updated." Renaming changes every repository URL in the organization, so existing clones need a new remote URL:
209+
210+```sh
211+git remote set-url origin https://diffinery.com/acme-inc/website.git
212+```
213+
214+URLs with the old name stop working right away; Diffinery doesn't redirect them.
215+
216+## Organizations API
217+
218+| Method and path | Description |
219+| --- | --- |
220+| `GET /api/v3/user/orgs` | Organizations you belong to, each with your `role` |
221+| `POST /api/v3/user/orgs` | Create an organization: `{"login": "acme", "name": "Acme Inc."}`. Needs an `admin` token. |
222+| `GET /api/v3/orgs/{org}/members` | Members and their roles. Members and site administrators only; others get `404`. |
223+| `POST /api/v3/orgs/{org}/invitations` | Invite by email: `{"email": "...", "role": "member"}`. Needs an `admin` token. See [Invitations](/docs/accounts/invitations#invite-with-the-api). |
224+| `GET /api/v3/orgs/{org}/repos` | Repositories you can see in the organization |
225+| `POST /api/v3/orgs/{org}/repos` | Create a repository in the organization. Needs a `write` or `admin` token. |
226+| `GET`, `PUT`, `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | Organization secrets. Needs an `admin` token. |
227+| `PUT /api/v3/orgs/{org}/avatar` | Upload the organization's picture (raw image body). Owners and admins. |
228+| `DELETE /api/v3/orgs/{org}/avatar` | Remove the organization's picture. Owners and admins. |
229+
230+Organization objects in API responses follow GitHub's shape, with `"type": "Organization"`, `html_url` and `avatar_url`.
231+
232+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](/docs/accounts/tokens#account-level-actions).
233+
234+Adding existing people directly, changing a member's role, removing a member and revoking invitations are done in the web UI. The full list of endpoints is in the [API reference](/docs/api).
235+
236+## How organizations differ from GitHub
237+
238+- Diffinery has three organization roles: owner, admin and member. Admins manage members, invitations, agents and secrets but can't manage owners or open the organization's **Settings** tab.
239+- Members get write access to every organization repository. GitHub's default base permission is configurable; Diffinery's is fixed.
240+- There are no teams. Give extra access per repository under **Settings → Collaborators**.
241+- **Add people** works like GitHub's: enter a username or an email address. Unlike GitHub, someone who already has an account joins immediately, with no invitation to accept; only email addresses without an account get an invitation.
242+- Organization pages need you to be signed in.
243+- Organization secrets apply to every repository in the organization; there is no per-repository selection.
addedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+182−0View file
@@ -1,0 +1,182 @@
1+---
2+title: Passkeys and sign-in
3+description: How people sign in to Diffinery with passkeys, manage passkeys and browser sessions, and recover an account when a passkey is lost.
4+---
5+
6+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, adding and removing passkeys, managing signed-in browsers, the security emails you get, and how to get back in when you lose a passkey.
7+
8+Passkeys are only for the web UI. Git, the `diffinery` CLI, scripts and MCP clients use personal access tokens instead (see [Personal access tokens](/docs/accounts/tokens)). For your username, display name, profile picture and light or dark theme, see [Profile and preferences](/docs/accounts/profile-and-preferences).
9+
10+## How passkey sign-in works
11+
12+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.
13+
14+Details that matter in practice:
15+
16+| Behaviour | Value |
17+| --- | --- |
18+| 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. |
19+| Kind of passkey | Discoverable credentials (resident keys). You don't type a username: the browser offers the passkeys it holds for the site. |
20+| User verification | Required. The device must unlock the passkey with a biometric, PIN or screen lock, not just a tap. |
21+| Attestation | None requested. Diffinery accepts any authenticator that can create a discoverable, user-verifying passkey. |
22+| Time to complete the browser prompt | 2 minutes. The sign-in attempt itself expires after 5 minutes. |
23+| Browser session length | 30 days, extended by another 30 days on each day you use it. |
24+
25+Diffinery is invite-only. 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)).
26+
27+## Sign in
28+
29+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.
30+2. Click **Sign in with a passkey**.
31+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.
32+4. Unlock the passkey with your fingerprint, face, PIN or screen lock.
33+
34+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.
35+
36+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".
37+
38+## Join from an invite
39+
40+When someone invites you, Diffinery emails you a link like `https://diffinery.com/invite/<token>`. If you don't have an account yet, opening it shows a **Join Diffinery** page:
41+
42+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`).
43+2. Optionally fill in a **Display name** (up to 100 characters).
44+3. Click **Create a passkey and join**, and complete your browser's passkey prompt.
45+
46+Diffinery creates your account, saves the passkey, signs you in and shows "Welcome to Diffinery, <username>. 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.
47+
48+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](/docs/accounts/invitations) for how invites are created, how long they last and what each kind grants.
49+
50+## Manage your passkeys
51+
52+Go to **Settings → Passkeys and sessions** (`/settings/security`). The **Passkeys** box lists every passkey on your account with:
53+
54+- its name,
55+- 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,
56+- when it was added and when it was last used to sign in (or "never used to sign in").
57+
58+### Add a passkey
59+
60+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.
61+
62+1. On the device or browser you want to add, sign in and open **Settings → Passkeys and sessions**.
63+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`.
64+3. Click **Add a passkey** and complete the browser prompt.
65+
66+You see "Passkey "<name>" added." and Diffinery sends you a "A passkey was added to your account" email.
67+
68+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).
69+
70+### Remove a passkey
71+
72+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.
73+
74+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.
75+
76+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."
77+
78+## Sessions and signing out other browsers
79+
80+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.
81+
82+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.
83+
84+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.
85+
86+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.
87+
88+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](/docs/accounts/tokens#revoke-a-token)).
89+
90+## Security emails about sign-in
91+
92+These emails always send; you can't turn them off in notification settings.
93+
94+| Email | When it is sent |
95+| --- | --- |
96+| 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. |
97+| A passkey was added to your account | You add a passkey in settings, or with a sign-in link from an administrator. |
98+| A passkey was removed from your account | You remove a passkey. |
99+| Welcome to Diffinery | You create your account from an invite. |
100+| Your one-time sign-in link | A site administrator creates a sign-in link for you. |
101+
102+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.
103+
104+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.
105+
106+See [Notifications](/docs/accounts/notifications) for every email Diffinery sends.
107+
108+## Recover your account
109+
110+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.
111+
112+### Sign-in link from an administrator
113+
114+A site administrator can create a one-time sign-in link for you:
115+
116+1. The administrator opens **Settings → People and invitations**, finds you in the **Users** table and clicks **Sign-in link** (see [Site administration](/docs/admin/site-administration#sign-in-links)).
117+2. Diffinery emails you "Your one-time sign-in link". The administrator also sees the link and can send it to you another way.
118+3. Open the link (`https://diffinery.com/signin/<token>`). The **Add a passkey** page names the account it belongs to.
119+4. Optionally enter a **Name for this passkey**, click **Create a passkey** and complete the browser prompt.
120+
121+You are signed in and taken to **Settings → Passkeys and sessions** with "Passkey "<name>" added. Use it to sign in from now on."
122+
123+| Sign-in link rule | Value |
124+| --- | --- |
125+| Lifetime | 24 hours from when it was created |
126+| Uses | Once. After that, or after it expires, it shows "this sign-in link is invalid, already used, or expired". |
127+| Accounts | People only, and not disabled accounts |
128+| What it does | Adds a passkey to the account and signs that browser in. It doesn't remove your other passkeys. |
129+
130+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.
131+
132+### Email recovery through /auth/access
133+
134+An instance can also offer recovery by email. On `diffinery.com` 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.
135+
136+1. Open `https://diffinery.com/auth/access`, or click **Recover your account by email** on the sign-in page.
137+2. Enter your account's email address on the Cloudflare Access page. Cloudflare emails you a one-time PIN.
138+3. Enter the PIN. Cloudflare Access sends you back to Diffinery, which signs you in.
139+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.
140+
141+Every email-recovery sign-in sends a "New sign-in to your account" email and is recorded in the audit log.
142+
143+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.
144+
145+If the email address you verify has no account, Diffinery creates one that waits for an invite (see [Invitations](/docs/accounts/invitations#access-requests)), unless that address has an open invite or is a bootstrap site administrator.
146+
147+## Browser and device requirements
148+
149+- 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."
150+- JavaScript turned on. Without it the page shows "Passkeys need JavaScript."
151+- 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.
152+- The real site address. Passkeys are bound to `diffinery.com`, so they don't work through another hostname or a raw IP address.
153+
154+## Password managers and synced passkeys
155+
156+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.
157+
158+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.
159+
160+## How Diffinery sign-in differs from GitHub
161+
162+- 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.
163+- You can't sign up from the sign-in page. Accounts come from invites.
164+- 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.
165+- Git over HTTPS and the API never accept a passkey or browser session; they take a personal access token, much like GitHub.
166+
167+## Troubleshooting
168+
169+| Message or symptom | What it means and what to do |
170+| --- | --- |
171+| "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. |
172+| "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. |
173+| "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. |
174+| "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. |
175+| "Your account is disabled." | A site administrator disabled your account. Ask them to enable it. |
176+| "This device already has a passkey for this account." | When adding a passkey: pick a different authenticator. |
177+| "This browser does not support passkeys..." | Update your browser or use another one. |
178+| "this invite is invalid, already used, or expired" | Ask the person who invited you for a new invite. |
179+| "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. |
180+| "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**. |
181+| 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). |
182+| `git` or the CLI asks for a password | Use a personal access token, not a passkey. See [Personal access tokens](/docs/accounts/tokens). |
addedapps/diffinery/docs/accounts/permissions.md+224−0View file
@@ -1,0 +1,224 @@
1+---
2+title: Permissions
3+description: The repository roles (read, triage, write, maintain, admin), where roles come from, and how token scopes and agent policies cap them.
4+---
5+
6+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.
7+
8+## Repository roles
9+
10+Diffinery uses the same five roles as GitHub. Each role includes everything the roles before it can do.
11+
12+| Role | In short |
13+| --- | --- |
14+| `read` | View and clone; open issues and comment |
15+| `triage` | Manage issues, pull requests and labels without pushing code |
16+| `write` | Push, open and merge pull requests, run workflows |
17+| `maintain` | Also move or delete tags and edit other people's comments |
18+| `admin` | Settings, access, branch protection, secrets and webhooks |
19+
20+### What each role can do
21+
22+| Action | read | triage | write | maintain | admin |
23+| --- | --- | --- | --- | --- | --- |
24+| View code, history, issues, pull requests, workflow runs, logs and artifacts | Yes | Yes | Yes | Yes | Yes |
25+| Clone and fetch over Git, download Git LFS objects | Yes | Yes | Yes | Yes | Yes |
26+| List collaborators | Yes | Yes | Yes | Yes | Yes |
27+| Open issues, comment, and edit or close your own issues | Yes | Yes | Yes | Yes | Yes |
28+| Leave comment-only reviews and line comments on pull requests | Yes | Yes | Yes | Yes | Yes |
29+| Add and remove your own reactions | Yes | Yes | Yes | Yes | Yes |
30+| See published releases, download release assets and source archives (zip and tar) | Yes | Yes | Yes | Yes | Yes |
31+| See commit statuses, check runs, deployments and environments | Yes | Yes | Yes | Yes | Yes |
32+| Watch the repository and subscribe to threads | Yes | Yes | Yes | Yes | Yes |
33+| Create, apply and delete labels; set assignees | No | Yes | Yes | Yes | Yes |
34+| Edit, close or reopen anyone's issue or pull request | No | Yes | Yes | Yes | Yes |
35+| Request reviewers on anyone's pull request | No | Yes | Yes | Yes | Yes |
36+| Lock and unlock conversations; comment and react on locked ones | No | Yes | Yes | Yes | Yes |
37+| Push branches and new tags; upload Git LFS objects | No | No | Yes | Yes | Yes |
38+| Create, update and delete branches and refs through the API; commit files in the web editor or API | No | No | Yes | Yes | Yes |
39+| Open pull requests | No | No | Yes | Yes | Yes |
40+| Approve or request changes on a pull request (not your own) | No | No | Yes | Yes | Yes |
41+| Merge pull requests, add them to or remove them from a merge queue | No | No | Yes | Yes | Yes |
42+| Resolve review threads | No | No | Yes | Yes | Yes |
43+| Run, cancel and re-run workflows | No | No | Yes | Yes | Yes |
44+| Report check runs and commit statuses (`POST /repos/{owner}/{repo}/check-runs`, `POST /repos/{owner}/{repo}/statuses/{sha}`) | No | No | Yes | Yes | Yes |
45+| Create, edit and delete releases and their assets; see draft releases | No | No | Yes | Yes | Yes |
46+| Create and delete deployments; report deployment statuses | No | No | Yes | Yes | Yes |
47+| Update a pull request's branch from its base (`PUT .../pulls/{number}/update-branch`) | No | No | Yes | Yes | Yes |
48+| Write Git objects through the API (blobs, trees, commits, tags, merges) | No | No | Yes | Yes | Yes |
49+| Trigger `repository_dispatch` (`POST /repos/{owner}/{repo}/dispatches`) | No | No | Yes | Yes | Yes |
50+| List Actions variables and environment variables | No | No | Yes | Yes | Yes |
51+| Start agent sandboxes | No | No | Yes | Yes | Yes |
52+| Move or delete existing tags | No | No | No | Yes | Yes |
53+| Edit or delete other people's comments | No | No | No | Yes | Yes |
54+| Dismiss a review on someone else's pull request (people only) | No | No | No | Yes | Yes |
55+| Change settings: name, description, visibility, default branch | No | No | No | No | Yes |
56+| Archive, unarchive and delete the repository | No | No | No | No | Yes |
57+| Add, change and remove collaborators; invite by email | No | No | No | No | Yes |
58+| Manage branch protection rules | No | No | No | No | Yes |
59+| List, set and delete Actions secrets; set and delete variables | No | No | No | No | Yes |
60+| Create, change and delete environments, their deployment branch policies and secrets (people only) | No | No | No | No | Yes |
61+| Set and delete environment variables | No | No | No | No | Yes |
62+| Set repository topics | No | No | No | No | Yes |
63+| Delete other people's reactions | No | No | No | No | Yes |
64+| Manage webhooks | No | No | No | No | Yes |
65+| Run self-hosted runners for the repository (`diffinery runner`) | No | No | No | No | Yes |
66+| Bypass branch protection: push past it, or merge past unmet rules by asking to (`"bypass_rules": true`), unless the rule turns on **Do not allow administrators to bypass these rules** (people only for merges) | No | No | No | No | Yes |
67+| Push past push protection with `git push -o allow-secrets=<reason>` (people only) | No | No | No | No | Yes |
68+
69+Some actions have extra conditions on top of the role:
70+
71+- 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`), and the bypass is written to the audit log as `pull_request.merge_bypassed`. See [Branch protection](/docs/repositories/branch-protection).
72+- 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).
73+- An archived repository refuses pushes and most changes with "repository is archived (read-only)", for every role. Unarchiving, the admin settings (collaborators, branch protection, webhooks, secrets and variables) and, for now, opening and editing issues, comments, reviews and review requests through the REST API and MCP still work. See [Archive a repository](/docs/repositories/overview#archive-a-repository).
74+- You can't approve or request changes on your own pull request, and you can't dismiss reviews on it.
75+- Pull requests opened by agents always need at least one approval from a person.
76+
77+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, 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](/docs/packages/container-registry).
78+
79+## Where your role comes from
80+
81+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:
82+
83+| Source | Role |
84+| --- | --- |
85+| You own the repository (a personal repository) | admin |
86+| You are an owner or admin of the organization that owns it | admin |
87+| You are a member of the organization that owns it | write |
88+| 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 |
89+| The repository is public | read |
90+| You are a site administrator | admin on every repository |
91+
92+Because the highest role wins, a collaborator entry can raise an organization member's access but can't lower it. Accounts that are pending or disabled have no role anywhere.
93+
94+### Collaborators
95+
96+Repository admins manage collaborators under **Settings → Collaborators** (`/acme/website/settings/access`):
97+
98+- **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**. Accounts get the role immediately ("octocat now has write access. We let them know."). An email address without an account gets an invitation; see [Invitations](/docs/accounts/invitations#add-people-to-a-repository).
99+- Change a collaborator's role with the **Role** menu and **Save**, or click **Remove** and confirm.
100+
101+The person gets an email when their access is granted, changed or removed, and an in-app notification when someone adds them with **Add people**. With the API:
102+
103+```sh
104+# Give octocat the triage role (GitHub's "pull" and "push" also work: they mean read and write)
105+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/octocat \
106+ -H "Authorization: Bearer dfy_..." \
107+ -H "Content-Type: application/json" \
108+ -d '{"permission": "triage"}'
109+
110+# Remove octocat
111+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/collaborators/octocat \
112+ -H "Authorization: Bearer dfy_..."
113+```
114+
115+Without a body, `PUT` gives `write`. Both need admin on the repository and a token with the `admin` scope.
116+
117+## Organization roles
118+
119+Organizations have their own three roles, which map onto repository roles for every repository the organization owns:
120+
121+| Organization role | Role on organization repositories | Can also |
122+| --- | --- | --- |
123+| member | write | Create repositories and container images in the organization |
124+| admin | admin | Manage members (except owners), invites, agents and organization secrets |
125+| owner | admin | Everything an admin can, plus manage owners and the organization's **Settings** tab (name, display name, picture) |
126+
127+See [Organizations](/docs/accounts/organizations) for details.
128+
129+## Site administrators
130+
131+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](/docs/admin/site-administration).
132+
133+A site administrator's tokens are still capped by their scopes, like everyone's.
134+
135+## Anonymous access
136+
137+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.
138+
139+| Situation | Response |
140+| --- | --- |
141+| No credentials, private repository | `401 authentication required` (Git then asks for a username and password) |
142+| No credentials, any change to a public repository (for example `POST /repos/{owner}/{repo}/issues`) | `401 authentication required` |
143+| Signed in or token, but no access to a private repository | `404 repository not found` (Diffinery doesn't reveal that it exists) |
144+| Access, but the role is too low | `403 <role> access required`, for example `403 write access required` |
145+
146+## Token scopes cap your role
147+
148+A personal access token never does more than its scope allows, whatever your role is. The cap is:
149+
150+| Token scope | Highest effective role |
151+| --- | --- |
152+| `read` | read |
153+| `write` | maintain |
154+| `admin` | admin |
155+
156+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.
157+
158+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`.
159+
160+Browser sessions (signing in with a passkey) aren't capped: in the web UI you always act with your full role.
161+
162+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`):
163+
164+| Action | Needs |
165+| --- | --- |
166+| 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 |
167+| 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 |
168+| Watch or unwatch a repository | a `write` or `admin` token, or a browser session |
169+| Upload or remove your profile picture or an organization's (`PUT`/`DELETE /api/v3/user/avatar`, `/api/v3/orgs/{org}/avatar`) | a `write` or `admin` token that isn't restricted to repositories, or a browser session |
170+| Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | an `admin` token or a browser session |
171+| 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 |
172+| Send every email template (site administrators) | an `admin` token that isn't restricted to repositories, or a browser session |
173+| Change your username, display name or appearance | a browser session; there is no API |
174+| Add or remove passkeys, sign out other browsers | a browser session; tokens can't do this |
175+
176+A token whose scope is too low gets `403 this token needs the write scope` or `403 this token needs the admin scope`.
177+
178+See [Personal access tokens](/docs/accounts/tokens) for how to create tokens.
179+
180+## Repository-restricted tokens
181+
182+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).
183+
184+## Agents
185+
186+Agents are accounts that act for a person or an organization. Their role on a repository is the lowest of:
187+
188+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.
189+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.
190+3. **The token's scope**, as for any token.
191+
192+On top of the role, these guardrails apply to agents everywhere (Git, the REST API, MCP and the web UI):
193+
194+- 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".
195+- Agents can't merge into the default branch or a protected branch, unless the rule turns on **Allow agents to merge pull requests** (`agentsMayMerge`).
196+- A pull request opened by an agent needs at least one approving review from a person, and approvals from agents never count toward required approvals.
197+- Agents can never bypass push protection, and the merge-protection bypass for admins (`"bypass_rules": true`) applies only to people.
198+- Check runs and commit statuses reported by agents never satisfy a required status check.
199+- Agents can't dismiss reviews.
200+- 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.
201+- 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](/docs/actions/environments).
202+- Commits an agent makes carry a `diffinery-agent` header naming the agent and the person it acts for.
203+
204+See [Agents](/docs/agents/overview) for creating and managing agents.
205+
206+## Job tokens and sandbox tokens
207+
208+Workflows and sandboxes get short-lived tokens of their own:
209+
210+| Token | Prefix | Where it comes from | What it can do on the repository |
211+| --- | --- | --- | --- |
212+| Job token | `djt_` | Each workflow job gets one as `GITHUB_TOKEN` (also `DIFFINERY_TOKEN`) | Act as `diffinery-actions[bot]` with `read` on the repository the job runs in. Container images follow the job's `permissions: packages`: it pulls its owner's images and pushes only to images linked to its own repository (creating them, linked, on first push) |
213+| Sandbox token | `dst_` | Each agent sandbox gets one | Act as the person or agent that started the sandbox, capped at `write`, on that repository only |
214+
215+Both work only for the repository they were issued for, and stop working when the job completes or the sandbox closes. See [Secrets and variables](/docs/actions/secrets-and-variables) and [Sandboxes](/docs/agents/sandboxes).
216+
217+## How permissions differ from GitHub
218+
219+- 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`.
220+- Opening a pull request needs `write`: its head branch must be a branch in the same repository.
221+- Organization members get `write` on every organization repository, and organization admins get `admin`. There are no teams or custom roles.
222+- Personal access tokens have three scopes (`read`, `write`, `admin`) instead of GitHub's fine-grained permissions; the scope caps your role.
223+- `GITHUB_TOKEN` in a workflow can read the repository and use the container registry; it can't push commits or call write endpoints.
224+- Agents are a separate account type with their own caps and guardrails.
addedapps/diffinery/docs/accounts/profile-and-preferences.md+267−0View file
@@ -1,0 +1,267 @@
1+---
2+title: Profile and preferences
3+description: Change your username and display name, upload or remove a profile picture for yourself or an organization, and choose light, dark or system appearance.
4+---
5+
6+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) and points to every other per-user setting under **Settings**.
7+
8+## Where your settings are
9+
10+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:
11+
12+| Page | Path | What it holds |
13+| --- | --- | --- |
14+| **Profile** | `/settings` | Profile picture, username, display name, and read-only account details |
15+| **Appearance** | `/settings/appearance` | Light, dark or system theme |
16+| **Passkeys and sessions** | `/settings/security` | Passkeys and signed-in browsers. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in). |
17+| **Access tokens** | `/settings/tokens` | Personal access tokens. See [Personal access tokens](/docs/accounts/tokens). |
18+| **Agents** | `/settings/agents` | Agent accounts you or your organizations own. See [Agents](/docs/agents/overview). |
19+| **Organizations** | `/settings/orgs` | Organizations you belong to, and creating new ones. See [Organizations](/docs/accounts/organizations). |
20+| **Notifications** | `/settings/notifications` | What you hear about in the notifications list and by email. See [Notifications](/docs/accounts/notifications). |
21+
22+Site administrators also see a **Site administration** group with **People and invitations**, **Email** and **Audit log** (see [Site administration](/docs/admin/site-administration)).
23+
24+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)).
25+
26+## Your profile
27+
28+**Settings → Profile** (`/settings`) has two boxes: **Profile picture** (see [Profile pictures](#profile-pictures)) and a form with:
29+
30+| Field | Editable | Notes |
31+| --- | --- | --- |
32+| **Username** | Yes | Your login, the owner part of your repository URLs (`https://diffinery.com/octocat/...`) and your `@mention` handle |
33+| **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. |
34+| **Email** | No | Your account's email address. Notification emails go here. It can't be changed in Settings. |
35+| **Account type** | No | `user` for a person |
36+| **Joined** | No | When the account was created |
37+| **Site admin** | No | `yes` for site administrators, otherwise `no` |
38+
39+Change the username or display name and click **Save profile**. You see "Profile saved."
40+
41+Your public profile page is `https://diffinery.com/octocat`. 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.
42+
43+### Change your username
44+
45+A username starts with a letter or digit, then uses letters, digits, `-` and `_`. 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.
46+
47+Renaming changes the URL of every repository you own. Update existing clones:
48+
49+```sh
50+git remote set-url origin https://diffinery.com/new-name/website.git
51+```
52+
53+After a rename, `https://diffinery.com/old-name` returns 404, and the old name is free for someone else to take. Your picture moves with you: Diffinery forgets cached copies of the picture under the old name.
54+
55+| Message | What to do |
56+| --- | --- |
57+| "invalid or reserved login" | The name breaks the rules above (for example it contains a dot) or is reserved. Pick another. |
58+| "login already taken" | Another person, organization or agent uses it. Pick another. |
59+
60+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`.
61+
62+## Profile pictures
63+
64+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.
65+
66+### Upload your picture in the web UI
67+
68+1. Go to **Settings → Profile** (`/settings`).
69+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."
70+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."
71+4. Click **Save picture**. The status shows "Uploading…", then the page reloads with "Profile picture updated."
72+
73+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.
74+
75+### What Diffinery does with a picture
76+
77+| Step | Detail |
78+| --- | --- |
79+| 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." |
80+| Size check | At most 5 MB (5,242,880 bytes) and at most 3072 by 3072 pixels |
81+| 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. |
82+| Orientation | The EXIF orientation of a JPEG (as written by phone cameras) is applied, so the picture isn't sideways |
83+| Resize | The square is resized to 64, 128 and 256 pixels |
84+| Encode | Each size is saved as WebP (quality 80). The original upload isn't kept. |
85+| Replace | The previous picture's files are deleted |
86+
87+Transparent areas of a PNG or WebP stay transparent.
88+
89+### Limits for profile pictures
90+
91+| Limit | Value |
92+| --- | --- |
93+| Formats | JPEG, PNG, WebP |
94+| File size | 5 MB |
95+| Dimensions | 3072 by 3072 pixels |
96+| Stored sizes | 64, 128 and 256 pixels square, WebP |
97+
98+### Remove your picture
99+
100+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.
101+
102+### Initials when there is no picture
103+
104+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 `?`.
105+
106+In the web UI, people and organizations appear as circles and agents as rounded squares.
107+
108+### Organization pictures
109+
110+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:
111+
112+1. Open the organization, click **Settings**.
113+2. Under **Upload a new picture**, choose a file and click **Save picture**. You see "Organization picture updated."
114+3. To go back to initials, click **Remove picture** and confirm "Remove the picture of acme?". You see "Organization picture removed."
115+
116+Through the API, organization owners and admins (and site administrators) can change the picture; see the next section.
117+
118+### Agent pictures
119+
120+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`).
121+
122+### Profile picture API
123+
124+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.
125+
126+| Method and path | Who | Response |
127+| --- | --- | --- |
128+| `PUT /api/v3/user/avatar` | You (the token's account) | `200` with `{"avatar_url": "..."}` |
129+| `DELETE /api/v3/user/avatar` | You | `200` with `{"avatar_url": "..."}` (the initials URL) |
130+| `PUT /api/v3/orgs/{org}/avatar` | Owners and admins of the organization, site administrators | `200` with `{"avatar_url": "..."}` |
131+| `DELETE /api/v3/orgs/{org}/avatar` | Owners and admins of the organization, site administrators | `200` with `{"avatar_url": "..."}` |
132+
133+The token needs the `write` or `admin` scope and must not be restricted to repositories. A browser session works too.
134+
135+Upload a picture:
136+
137+```sh
138+curl -X PUT https://diffinery.com/api/v3/user/avatar \
139+ -H "Authorization: Bearer dfy_..." \
140+ --data-binary @me.jpg
141+```
142+
143+```json
144+{ "avatar_url": "https://diffinery.com/api/avatars/octocat?v=1760350000" }
145+```
146+
147+The `v` value is the new picture's version. Upload an organization's picture:
148+
149+```sh
150+curl -X PUT https://diffinery.com/api/v3/orgs/acme/avatar \
151+ -H "Authorization: Bearer dfy_..." \
152+ --data-binary @logo.png
153+```
154+
155+Remove it again:
156+
157+```sh
158+curl -X DELETE https://diffinery.com/api/v3/user/avatar -H "Authorization: Bearer dfy_..."
159+```
160+
161+```json
162+{ "avatar_url": "https://diffinery.com/api/avatars/octocat" }
163+```
164+
165+The `diffinery api` CLI command sends text bodies only, so use `curl` (or another HTTP client) to upload a picture.
166+
167+Errors:
168+
169+| Status | Message | Cause |
170+| --- | --- | --- |
171+| 401 | `sign in required` | No token was sent |
172+| 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 |
173+| 403 | `only organization owners and admins can change its picture` | You are a member of the organization, but not an owner or admin |
174+| 404 | `not found` | The organization doesn't exist, or you aren't a member |
175+| 413 | `That picture is larger than 5 MB. Use a smaller file.` | The body is over 5 MB |
176+| 422 | `Send the picture as the request body.` | The request had no body |
177+| 422 | `Use a JPEG, PNG or WebP image.` | The body isn't JPEG, PNG or WebP |
178+| 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 |
179+| 422 | `That picture is 4000×3000 pixels; the limit is 3072×3072.` | The picture is larger than 3072 by 3072 pixels |
180+
181+Error bodies are JSON with a `message` field, like every API error.
182+
183+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.
184+
185+### The avatar URL
186+
187+Every person, organization and agent has a picture at:
188+
189+```text
190+https://diffinery.com/api/avatars/{login}
191+```
192+
193+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.
194+
195+| Query parameter | Values | Default |
196+| --- | --- | --- |
197+| `s` | Size in pixels. Diffinery serves the smallest stored size at least this big: `64`, `128` or `256`. Larger values get `256`. | `128` |
198+| `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 |
199+
200+```sh
201+# The 64-pixel picture of octocat
202+curl -o octocat.webp "https://diffinery.com/api/avatars/octocat?s=64"
203+```
204+
205+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.
206+
207+### Caching
208+
209+| URL | `Cache-Control` | When a new picture shows up |
210+| --- | --- | --- |
211+| With the current `v` | `public, max-age=86400, immutable` | Never changes; a new picture gets a new `v` |
212+| Without `v` (or with an old `v`) | `public, max-age=600, stale-while-revalidate=86400` | Within about 10 minutes |
213+
214+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.
215+
216+## Appearance
217+
218+**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."
219+
220+| Theme option | Description in the page | Behaviour |
221+| --- | --- | --- |
222+| **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. |
223+| **Light** | "Always light." | Always the light theme |
224+| **Dark** | "Always dark." | Always the dark theme |
225+
226+To change it:
227+
228+1. Go to **Settings → Appearance**.
229+2. Under **Theme**, pick **Sync with system**, **Light** or **Dark**.
230+3. Click **Save appearance**. You see "Appearance saved."
231+
232+### How the appearance setting is stored and applied
233+
234+- 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.
235+- 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.
236+- 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.
237+- In other browsers, pages you open while signed out follow the system setting until you sign in.
238+- 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.
239+- Emails aren't affected: they follow the light or dark mode of your mail app.
240+
241+There is no API or CLI command for the appearance setting.
242+
243+## Notification preferences
244+
245+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](/docs/accounts/notifications#notification-settings).
246+
247+## How profiles differ from GitHub
248+
249+- There is no bio, location, website, company, pronouns or status field. The profile has a username, a display name and a picture.
250+- Your email address comes from your invitation and isn't editable in Settings. Diffinery has no list of secondary email addresses.
251+- Profile pictures are uploaded to Diffinery. There is no Gravatar support (`gravatar_id` is always empty in API responses).
252+- Diffinery has an API to upload and remove profile pictures for users and organizations; GitHub doesn't.
253+- 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.
254+- After a username change, GitHub redirects your repositories' old web and Git URLs. Diffinery doesn't: URLs with the old name stop working right away.
255+
256+## Troubleshooting
257+
258+| Message or symptom | What to do |
259+| --- | --- |
260+| "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. |
261+| "Use a JPEG, PNG or WebP image." | Convert the picture to JPEG, PNG or WebP. |
262+| "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. |
263+| "Choose a picture to upload." | Pick a file before clicking **Save picture**. |
264+| 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. |
265+| `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**. |
266+| 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. |
267+| "login already taken" or "invalid or reserved login" | See [Change your username](#change-your-username). |
addedapps/diffinery/docs/accounts/tokens.md+271−0View file
@@ -1,0 +1,271 @@
1+---
2+title: Personal access tokens
3+description: Create, scope, restrict, use and revoke Diffinery personal access tokens (dfy_) for Git, the REST API, the CLI and MCP clients.
4+---
5+
6+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.
7+
8+## What a token is
9+
10+| Property | Value |
11+| --- | --- |
12+| Format | `dfy_` followed by 40 random characters (letters, digits, `-` and `_`) |
13+| Shown | Once, when you create it. Diffinery stores only a hash. |
14+| Prefix | The first 10 characters (for example `dfy_Ab12Cd`) are kept so you can recognize the token in lists and emails |
15+| Scopes | `read`, `write` or `admin` (default `write`) |
16+| Repository restriction | Optional list of repositories; default is every repository you can access |
17+| Lifetime | 1 to 366 days (default 90) |
18+| Last used | Recorded at most once an hour |
19+
20+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.
21+
22+## Scopes
23+
24+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.
25+
26+| Scope | Highest role | Typical use |
27+| --- | --- | --- |
28+| `read` | read | Clone and fetch, download Git LFS objects, read the API, CI that only downloads |
29+| `write` | maintain | Push, issues, pull requests, merging, running workflows; the right choice for most Git and CLI use |
30+| `admin` | admin | Everything you can do, including repository settings, collaborators, branch protection, secrets, webhooks, self-hosted runners and creating more tokens |
31+
32+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."
33+
34+### Read tokens never write
35+
36+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`.
37+
38+The scope is checked before your role on the repository. Use a `write` token for anything that opens, comments, pushes or merges, and an `admin` token for repository settings.
39+
40+### Account-level actions
41+
42+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.
43+
44+| Action | Token needs | Restricted tokens |
45+| --- | --- | --- |
46+| Create a repository (`POST /api/v3/user/repos`, `POST /api/v3/orgs/{org}/repos`, `diffinery repo create`) | `write` or `admin` | Refused |
47+| Mark notifications read (`PUT /api/v3/notifications`), change notification settings, send yourself a test email | `write` or `admin` | Refused |
48+| Watch or unwatch a repository (`PUT`/`DELETE /api/v3/repos/{owner}/{repo}/subscription`) | `write` or `admin` | Allowed for the listed repositories |
49+| Upload or remove a profile picture (`/api/v3/user/avatar`, `/api/v3/orgs/{org}/avatar`) | `write` or `admin` | Refused |
50+| Create a token (`POST /api/v3/diffinery/tokens`, `diffinery token create`) | `admin` | Allowed |
51+| Revoke a token (`DELETE /api/v3/diffinery/tokens/{id}`, `diffinery token revoke`) | `admin` | Refused |
52+| Create an organization (`POST /api/v3/user/orgs`) | `admin` | Refused |
53+| Invite people (`POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations`, `diffinery invite`) | `admin` | Refused |
54+| List, set and delete organization secrets (`/api/v3/orgs/{org}/actions/secrets`, `diffinery secret ... --org`) | `admin` | Refused |
55+| Create an agent, create a token for an agent, disable an agent (`/api/v3/diffinery/agents`, `diffinery agent create`) | `admin` | Refused |
56+| Send every email template (site administrators) | `admin` | Refused |
57+| Manage passkeys and browser sessions; change your username, display name or appearance | Not possible with a token; use the browser | |
58+
59+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`.
60+
61+See [Permissions](/docs/accounts/permissions) for what each role allows.
62+
63+## Create a token
64+
65+### In the web UI
66+
67+1. Go to **Settings → Access tokens** (`/settings/tokens`).
68+2. Under **Create a token**, fill in:
69+ - **Name**: what the token is for, for example `laptop`, `ci` or `claude-code` (up to 80 characters).
70+ - **Expires in (days)**: 1 to 366. The default is 90.
71+ - **Scope**: **Read**, **Write** (default) or **Admin**.
72+ - **Restrict to repositories** (optional): pick one or more repositories. Leave it empty for every repository you can access.
73+3. Click **Create token**.
74+
75+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".
76+
77+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.
78+
79+### With the CLI
80+
81+Install the CLI (needs Node.js 22 or later) and log in with an existing token:
82+
83+```sh
84+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
85+diffinery login
86+```
87+
88+Then create a token. The CLI must be logged in with an `admin`-scoped token:
89+
90+```sh
91+# A write token for CI, valid for 30 days, only for acme/website
92+diffinery token create --name ci --scope write --expires-days 30 -R acme/website
93+```
94+
95+| Option | Description |
96+| --- | --- |
97+| `-n, --name NAME` | Token name. Required. |
98+| `-s, --scope SCOPE` | `read`, `write` (default) or `admin`. Repeatable, or comma-separated. |
99+| `-e, --expires-days DAYS` | Days until it expires, 1 to 366 (default 90). |
100+| `-R, --repo OWNER/NAME` | Restrict the token to this repository. Repeatable. |
101+| `--json` | Print `token`, `id` and `expiresAt` as JSON. |
102+
103+The token is printed on standard output (so you can pipe it), and the ID and expiry date on standard error.
104+
105+### With the API
106+
107+`POST /api/v3/diffinery/tokens`, with an `admin`-scoped token or from a browser session:
108+
109+```sh
110+curl -X POST https://diffinery.com/api/v3/diffinery/tokens \
111+ -H "Authorization: Bearer dfy_..." \
112+ -H "Content-Type: application/json" \
113+ -d '{"name": "ci", "scopes": ["write"], "repositories": ["acme/website"], "expires_in_days": 30}'
114+```
115+
116+| Field | Type | Description |
117+| --- | --- | --- |
118+| `name` | string | Token name |
119+| `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. |
120+| `repositories` | array of strings | `owner/name` of each repository to restrict to. Omit for every repository. An unknown repository returns `422 unknown repository <name>`. |
121+| `expires_in_days` | number | 1 to 366 (values outside are clamped). Default 90. |
122+
123+Response (`201 Created`):
124+
125+```json
126+{ "token": "dfy_...", "id": "9b2f0c1d4e5a6b7c8d9e0f1a2b3c4d5e", "expiresAt": "2027-01-11T09:30:00.000Z" }
127+```
128+
129+A token without the `admin` scope gets `403 creating tokens requires an admin-scoped token or a browser session`. The CLI checks scope names before sending and exits with `unknown scope "<name>" (read, write or admin)`.
130+
131+## Restrict a token to repositories
132+
133+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. Account-level reads (such as `GET /api/v3/user`, listing your tokens or reading your notifications) still work, but account-level changes don't: creating repositories or organizations, inviting people, managing agents, organization secrets, notification settings and profile pictures, and 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).
134+
135+Restrict tokens you hand to CI systems, agents and third-party tools to the repositories they need.
136+
137+## List your tokens
138+
139+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.
140+
141+```sh
142+diffinery token list
143+```
144+
145+```sh
146+curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/diffinery/tokens
147+```
148+
149+The API returns an array of objects with `id`, `name`, `prefix`, `scopes`, `repoIds` (`null` when unrestricted), `expiresAt`, `lastUsedAt` and `createdAt`.
150+
151+## Expiry and reminder emails
152+
153+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`.
154+
155+About a week before a token expires, Diffinery emails you "Your token "<name>" expires on <date>", 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.
156+
157+## Revoke a token
158+
159+- **Web UI:** click **Revoke** next to the token on **Settings → Access tokens** and confirm. You see "Token revoked."
160+- **CLI:** `diffinery token revoke <id>` (the ID is in `diffinery token list`).
161+- **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.
162+
163+A revoked token stops working immediately and can't be restored. Anything still using it gets `401 invalid, expired or revoked token`.
164+
165+## Use a token
166+
167+### Git over HTTPS
168+
169+Use any username and the token as the password:
170+
171+```sh
172+git clone https://diffinery.com/acme/website.git
173+# Username: octocat (anything works)
174+# Password: dfy_...
175+```
176+
177+To avoid typing it, let the CLI answer Git's credential prompts for `diffinery.com`:
178+
179+```sh
180+diffinery login # paste the token once; saved in ~/.config/diffinery/config.json (mode 0600)
181+diffinery auth setup-git # git uses the saved token for https://diffinery.com
182+```
183+
184+`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](/docs/repositories/git).
185+
186+### curl and other HTTP clients
187+
188+Send the token as a bearer token:
189+
190+```sh
191+curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/user
192+```
193+
194+Diffinery accepts these `Authorization` forms:
195+
196+| Form | Example |
197+| --- | --- |
198+| Bearer | `Authorization: Bearer dfy_...` |
199+| Token (GitHub style) | `Authorization: token dfy_...` |
200+| 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. |
201+
202+Git uses Basic authentication; the CLI, MCP clients and most scripts use Bearer. They are equivalent.
203+
204+### The CLI
205+
206+`diffinery login` saves the server URL and token in `~/.config/diffinery/config.json` (or under `$XDG_CONFIG_HOME`). The environment variables `DIFFINERY_TOKEN` and `DIFFINERY_URL` override the saved values, which is handy in CI:
207+
208+```sh
209+export DIFFINERY_TOKEN=dfy_...
210+diffinery whoami
211+```
212+
213+`diffinery logout` removes the saved token. See [CLI reference](/docs/reference/cli).
214+
215+### MCP clients
216+
217+Point the client at `https://diffinery.com/mcp` with the token in an `Authorization` header. `diffinery mcp-config` prints a ready-made snippet:
218+
219+```json
220+{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" } } } }
221+```
222+
223+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](/docs/agents/mcp) and [Agents](/docs/agents/overview).
224+
225+## Agent tokens
226+
227+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](/docs/agents/overview).
228+
229+## Job tokens and sandbox tokens
230+
231+Two other kinds of token exist. You don't create them yourself:
232+
233+- **Job tokens** (`djt_`) are given to each workflow job as `GITHUB_TOKEN` and `DIFFINERY_TOKEN`. They act as `diffinery-actions[bot]` with read access to the repository the job runs in, plus container registry access according to the job's `permissions: packages` (it pulls its owner's packages and pushes only to packages linked to its own repository), and stop working when the job finishes. See [Secrets and variables](/docs/actions/secrets-and-variables).
234+- **Sandbox tokens** (`dst_`) are given to each agent sandbox. They act as whoever started the sandbox, capped at write, on that repository only, and stop working when the sandbox closes. See [Sandboxes](/docs/agents/sandboxes).
235+
236+## Security emails about tokens
237+
238+| Email | Sent to | When |
239+| --- | --- | --- |
240+| 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 |
241+| A token was created for the agent <agent> | The agent's owner, or its organization's owners and admins | A token is created for an agent |
242+| Your token "<name>" expires on <date> | The same people | About 7 days before expiry (once per token, for tokens with a lifetime over 8 days) |
243+
244+These can't be turned off. If you get one for a token you didn't create, revoke it right away.
245+
246+## How tokens differ from GitHub
247+
248+- 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.
249+- Every token expires, after at most 366 days.
250+- Tokens can only be created with the API or CLI when you already hold an `admin`-scoped token; the first one comes from the web UI.
251+- Git accepts a token as the password with any username, as on GitHub.
252+
253+## Troubleshooting
254+
255+| Message | Cause and fix |
256+| --- | --- |
257+| `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. |
258+| `401 unrecognised token; create one under Settings → Access tokens` | The credential doesn't start with `dfy_` (or `djt_`, `dst_`), for example a GitHub token or a password. Use a Diffinery token from **Settings → Access tokens**. |
259+| `401 authentication required` | No token was sent to a private repository. Configure Git credentials or add the `Authorization` header. |
260+| `404 repository not found` | You have no access, or the token is restricted to other repositories. |
261+| `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. |
262+| `403 write access required` (or another role) | Your role, or the token's scope, is too low. For `admin` actions, use an `admin` token. |
263+| `403 this token needs the write scope` | A `read` token tried to change something. Use a `write` or `admin` token. |
264+| `403 this token needs the admin scope` | Account-level actions such as inviting, creating organizations or agents, organization secrets and revoking tokens need an `admin` token. |
265+| `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. |
266+| `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. |
267+| `403 creating tokens requires an admin-scoped token or a browser session` | Use an `admin` token or the web UI to create tokens. |
268+| `403 this needs a write or admin token` | Watching or unwatching a repository needs a `write` or `admin` token. |
269+| `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. |
270+| 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. |
271+| Git keeps asking for a password | Your credential helper has an old token. Run `diffinery auth setup-git`, or update the stored credential. |
addedapps/diffinery/docs/actions/caching-and-artifacts.md+401−0View file
@@ -1,0 +1,401 @@
1+---
2+title: Caching and artifacts
3+description: Speed up workflows with actions/cache, and pass files between jobs or out of a run with actions/upload-artifact and actions/download-artifact.
4+---
5+
6+Diffinery runners have built-in versions of `actions/cache`, `actions/upload-artifact` and `actions/download-artifact`. A cache keeps files such as dependency downloads between runs. An artifact keeps files produced by one run, so later jobs in that run can use them and you can download them afterwards. This page covers what each one supports today and how they differ from GitHub.
7+
8+## Built-in actions
9+
10+The runner recognises these actions by name and runs its own implementation. The version after `@` is ignored, so `actions/cache@v3` and `actions/cache@v4` behave the same, and nothing is downloaded from GitHub.
11+
12+| Action | What it does |
13+| --- | --- |
14+| `actions/cache` | Restores a cache entry, then saves one in a post step when the job ends |
15+| `actions/cache/restore` | Restores a cache entry only |
16+| `actions/cache/save` | Saves a cache entry only |
17+| `actions/upload-artifact` | Uploads files as a named artifact of the run |
18+| `actions/download-artifact` | Downloads a named artifact of the same run into the workspace |
19+
20+The `cache` input of `actions/setup-node`, `actions/setup-python` and `actions/setup-go` uses the same cache service. See [Dependency caching with setup actions](#dependency-caching-with-setup-actions).
21+
22+These built-ins work on hosted and [self-hosted runners](/docs/actions/runners#self-hosted-runners) alike. Both talk to `https://diffinery.com` with the job's token.
23+
24+## Caching dependencies
25+
26+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:
27+
28+```yaml
29+name: CI
30+on: [push, pull_request]
31+
32+jobs:
33+ test:
34+ runs-on: ubuntu-latest
35+ steps:
36+ - uses: actions/checkout@v4
37+ - uses: actions/cache@v4
38+ id: npm-cache
39+ with:
40+ path: ~/.npm
41+ key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
42+ restore-keys: |
43+ npm-${{ runner.os }}-
44+ - run: npm ci
45+ - run: npm test
46+```
47+
48+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. The next run with the same `package-lock.json` restores it.
49+
50+### Inputs for actions/cache
51+
52+| Input | Required | Default | Description |
53+| --- | --- | --- | --- |
54+| `path` | Yes | | Files and directories to cache, one per line. Supports globs and `!` exclusions. Relative paths are relative to the workspace; `~/` expands to the home directory. |
55+| `key` | Yes | | Key to save the entry under and to look up first. At most 512 characters. |
56+| `restore-keys` | No | | Prefixes to try, one per line, in order, when `key` has no exact match. |
57+| `lookup-only` | No | `false` | Check whether an entry exists without downloading it. |
58+| `fail-on-cache-miss` | No | `false` | Fail the step when neither `key` nor any restore key matches. |
59+| `save-always` | No | `false` | Save in the post step even when the job failed or was cancelled. |
60+
61+`actions/cache/restore` takes `path`, `key`, `restore-keys`, `lookup-only` and `fail-on-cache-miss`. `actions/cache/save` takes `path` and `key`. Other inputs, such as `enableCrossOsArchive` or `upload-chunk-size`, are ignored.
62+
63+If `key` or `path` is missing, the step fails with `Input required and not supplied: key` or `Input required and not supplied: path`.
64+
65+### Outputs for actions/cache
66+
67+| Output | Value |
68+| --- | --- |
69+| `cache-hit` | `true` only when an entry matching `key` exactly was found, otherwise `false`. A restore-key match gives `false`. |
70+| `cache-primary-key` | The `key` input. Set by `actions/cache` and `actions/cache/restore`. |
71+| `cache-matched-key` | The key of the entry that was restored, or an empty string on a miss. Set by `actions/cache` and `actions/cache/restore`. |
72+
73+Use `cache-hit` to skip work when the exact entry was restored:
74+
75+```yaml
76+ - uses: actions/cache@v4
77+ id: deps
78+ with:
79+ path: node_modules
80+ key: modules-${{ hashFiles('package-lock.json') }}
81+ - if: steps.deps.outputs.cache-hit != 'true'
82+ run: npm ci
83+```
84+
85+### How keys and restore keys match
86+
87+When a restore step runs, Diffinery looks up an entry in this order:
88+
89+1. An entry whose key equals `key` exactly.
90+2. For each line of `restore-keys`, in order, the newest entry whose key starts with that line. The first restore key with any match wins.
91+
92+```yaml
93+key: build-${{ github.ref_name }}-${{ hashFiles('**/go.sum') }}
94+restore-keys: |
95+ build-${{ github.ref_name }}-
96+ build-
97+```
98+
99+With this configuration, a run first looks for the exact key, then for the newest entry saved on the same branch, then for the newest `build-` entry from any branch.
100+
101+Keys are case-sensitive. Each restore key is trimmed of surrounding spaces, and because restore keys are sent to the server as a comma-separated list, a restore key can't contain a comma. Keep commas out of keys you intend to match by prefix.
102+
103+### When the cache is saved
104+
105+`actions/cache` saves in a post step that runs after all other steps of the job:
106+
107+- It runs only if the job succeeded so far. With `save-always: true` it runs even after a failure or cancellation.
108+- It's skipped when the restore found an exact match for `key`, since that entry already exists.
109+- 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.`
110+
111+Entries are immutable. If another job saved the same key first, the save logs `Cache entry with key <key> already exists; not saving.` and does nothing. To refresh an entry, change its key, for example by including a hash of the lock file.
112+
113+`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:`:
114+
115+```yaml
116+ - uses: actions/cache/restore@v4
117+ id: restore
118+ with:
119+ path: .build-cache
120+ key: build-${{ github.sha }}
121+ restore-keys: build-
122+ - run: make build
123+ - uses: actions/cache/save@v4
124+ if: always()
125+ with:
126+ path: .build-cache
127+ key: ${{ steps.restore.outputs.cache-primary-key }}
128+```
129+
130+### Archive format and paths
131+
132+The runner creates a gzip-compressed tar archive of the matched paths, stored with their absolute paths, and extracts it to the same absolute paths on restore. As a result:
133+
134+- A cache saved on one machine restores into the same directories on another. If a self-hosted runner has a different home directory or workspace path than a hosted runner, an entry saved by one restores into paths the other doesn't use. Include `runner.environment` or a label in the key if you mix runner types.
135+- The step log shows the archive size as `Cache Size: ~<n> MB (<bytes> B)` on both save and restore.
136+- `.git` directories are skipped when a glob pattern walks a directory tree.
137+
138+### Scope
139+
140+A cache entry belongs to the repository. Every workflow, branch, tag and event in the repository can restore any entry, including runs triggered by `pull_request`. There is no per-branch isolation.
141+
142+> **Important:** Because any branch can save an entry that other branches then restore, don't cache anything you later execute without verification in a sensitive job, such as a release build. Give release jobs keys that other branches don't produce, and don't use broad restore keys there.
143+
144+Different repositories never share entries, even within one organization.
145+
146+### Size and eviction
147+
148+| Limit | Value |
149+| --- | --- |
150+| Key length | 512 characters. A longer key is rejected with `422 cache key too long`, and the save logs a warning instead of failing the job. |
151+| One entry | Each entry is uploaded in a single request, which Diffinery's front end refuses above 100 MB. Keep each compressed archive under 100 MB. |
152+| Repository total | 10 GB. When a save pushes the total over 10 GB, Diffinery deletes the least recently used entries until it's under again. |
153+
154+Restoring an entry counts as using it. Entries that no workflow restores are the first to go when the repository reaches its limit.
155+
156+There is no API or web page for listing or deleting cache entries today. To stop restoring an entry, change the key prefix in your workflow.
157+
158+### Errors never fail the job
159+
160+Cache problems are reported as warnings and the job carries on, as on GitHub:
161+
162+- `Failed to look up cache: <reason>` or `Failed to restore cache: <reason>` on restore. The step behaves like a miss.
163+- `Failed to save cache: <reason>` on save, for example when the archive is too large.
164+
165+The exception is `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>`.
166+
167+## Dependency caching with setup actions
168+
169+`actions/setup-node`, `actions/setup-python` and `actions/setup-go` can restore and save a dependency cache for you. They use the same service, scope and limits as `actions/cache`. The key is built from a fixed prefix and the hash of your lock files, there are no restore keys, and the save happens in a post step only when the job succeeds and the exact key wasn't found.
170+
171+| Action | Input | Cached directory | Lock files hashed by default | Key on a hosted runner |
172+| --- | --- | --- | --- | --- |
173+| `actions/setup-node` | `cache: npm`, `pnpm` or `yarn` | `npm config get cache`, `pnpm store path`, or the Yarn cache folder | `**/package-lock.json` and `**/npm-shrinkwrap.json`, `**/pnpm-lock.yaml`, or `**/yarn.lock` | `node-cache-Linux-x64-<manager>-<hash>` |
174+| `actions/setup-python` | `cache: pip` | `pip cache dir` | `**/requirements*.txt` and `**/pyproject.toml` | `setup-python-Linux-x64-python-<version>-pip-<hash>` |
175+| `actions/setup-go` | `cache` (on by default) | `GOMODCACHE` and `GOCACHE` | `**/go.sum` | `setup-go-Linux-amd64-go-<version>-<hash>` |
176+
177+All three accept `cache-dependency-path` to hash different files, one pattern per line. Each sets the `cache-hit` output.
178+
179+```yaml
180+ - uses: actions/setup-node@v4
181+ with:
182+ node-version: 22
183+ cache: pnpm
184+ cache-dependency-path: apps/web/pnpm-lock.yaml
185+```
186+
187+Differences to be aware of:
188+
189+- If `cache` is set and no file matches the lock file patterns, setup-node and setup-python fail with `Dependencies lock file is not found in <workspace>. Supported file patterns: <patterns>`.
190+- `actions/setup-go` skips the cache with `go.sum not found; skipping dependency cache.` when there's no `go.sum` at the workspace root and no `cache-dependency-path`. Set `cache: false` to turn it off.
191+- `actions/setup-python` caches pip only. `cache: pipenv` or `cache: poetry` fails with `Caching for '<manager>' is not supported by the Diffinery setup-python built-in (only pip).`
192+- `actions/setup-node` with any value other than `npm`, `pnpm` or `yarn` fails with `Caching for '<manager>' is not supported`.
193+
194+## Artifacts
195+
196+An artifact is a named archive of files that belongs to a workflow run. Jobs in the same run can download it, and anyone with read access to the repository can download it from the run page or the API.
197+
198+### Upload an artifact
199+
200+```yaml
201+ - run: npm run build
202+ - uses: actions/upload-artifact@v4
203+ with:
204+ name: site
205+ path: |
206+ dist/
207+ !dist/**/*.map
208+ if-no-files-found: error
209+```
210+
211+| Input | Required | Default | Description |
212+| --- | --- | --- | --- |
213+| `name` | No | `artifact` | Artifact name. It can't contain `"`, `:`, `<`, `>`, `\|`, `*`, `?`, `\`, `/`, carriage return or line feed. |
214+| `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. |
215+| `if-no-files-found` | No | `warn` | `warn` logs a warning, `error` fails the step, `ignore` logs a plain message. |
216+| `include-hidden-files` | No | `false` | Include files and directories whose names start with `.`. |
217+
218+`retention-days`, `overwrite`, `compression-level` and other inputs are accepted but have no effect. See [Differences from GitHub](#differences-from-github).
219+
220+The step logs the number of files and the root directory, then `Artifact '<name>' uploaded (<bytes> bytes compressed).`
221+
222+### What goes into the archive
223+
224+The runner collects the matching files, then archives them relative to the deepest directory that contains all of them. This matches `actions/upload-artifact`:
225+
226+| `path` | Files matched | Paths inside the artifact |
227+| --- | --- | --- |
228+| `dist/` | `dist/index.html`, `dist/assets/app.js` | `index.html`, `assets/app.js` |
229+| `dist/index.html` | `dist/index.html` | `index.html` |
230+| `dist/` and `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` | `dist/index.html`, `reports/junit.xml` |
231+
232+Only files are uploaded; empty directories are dropped. Hidden files are left out unless `include-hidden-files: true`.
233+
234+The archive is a gzip-compressed tar file (`.tar.gz`), not a zip file.
235+
236+### Download an artifact in a later job
237+
238+Give `actions/download-artifact` the artifact's `name`. The job must run after the job that uploaded it, so list that job in `needs`:
239+
240+```yaml
241+jobs:
242+ build:
243+ runs-on: ubuntu-latest
244+ steps:
245+ - uses: actions/checkout@v4
246+ - run: npm ci && npm run build
247+ - uses: actions/upload-artifact@v4
248+ with:
249+ name: site
250+ path: dist/
251+
252+ deploy:
253+ needs: build
254+ runs-on: ubuntu-latest
255+ steps:
256+ - uses: actions/download-artifact@v4
257+ id: get-site
258+ with:
259+ name: site
260+ path: public
261+ - run: ls -R ${{ steps.get-site.outputs.download-path }}
262+```
263+
264+| Input | Required | Default | Description |
265+| --- | --- | --- | --- |
266+| `name` | Yes | | Name of the artifact to download. |
267+| `path` | No | the workspace | Directory to extract into, relative to the workspace. It's created if missing. |
268+
269+The step sets the output `download-path` to the absolute directory it extracted into, and logs `Artifact '<name>' downloaded to <path>`.
270+
271+`name` is required. Without it, the step fails with:
272+
273+```text
274+download-artifact without `name` (download all / `pattern`) is not supported on Diffinery runners yet; specify `name`.
275+```
276+
277+To download several artifacts, add one step per name.
278+
279+### Which artifacts a job can see
280+
281+`actions/download-artifact` reads artifacts from the job's own run only:
282+
283+- Jobs of a [reusable workflow](/docs/actions/reusable-workflows) are part of the caller's run, so the caller and the called workflow can exchange artifacts.
284+- The `run-id`, `repository` and `github-token` inputs aren't supported, so a job can't download artifacts from another run or another repository. To pass files between runs, use a cache entry with a known key, a [package](/docs/packages/container-registry), or the [API](#download-artifacts-with-the-api) from a `run:` step with a personal token stored as a secret.
285+- A re-run is a new run, so artifacts uploaded by the original run aren't visible to it.
286+
287+### Uploading the same name twice
288+
289+Artifact names are unique within a run. Uploading a name that already exists in the run replaces the earlier artifact without an error, even without `overwrite: true`. In a matrix, give each job its own name:
290+
291+```yaml
292+ strategy:
293+ matrix:
294+ node: [20, 22]
295+ steps:
296+ - run: npm test -- --reporter junit > junit.xml
297+ - uses: actions/upload-artifact@v4
298+ with:
299+ name: junit-node-${{ matrix.node }}
300+ path: junit.xml
301+```
302+
303+### Size
304+
305+Each artifact is uploaded in a single request, which Diffinery's front end refuses above 100 MB. Keep each compressed artifact under 100 MB. If a build output is larger, split it into several artifacts or push it to the [container registry](/docs/packages/container-registry), which accepts larger layers from hosted runners.
306+
307+## Download artifacts from the web
308+
309+Open the run at `https://diffinery.com/<owner>/<repo>/actions/runs/<run id>`. When the run has artifacts, an **Artifacts** box lists each one with its size and upload time. Select a name to download it as `<name>.tar.gz`.
310+
311+Extract it with tar:
312+
313+```sh
314+tar -xzf site.tar.gz -C site
315+```
316+
317+You need read access to the repository. For a public repository, anyone can download.
318+
319+## Download artifacts with the API
320+
321+List a run's artifacts:
322+
323+```http
324+GET /api/v3/repos/acme/website/actions/runs/{run_id}/artifacts
325+Authorization: Bearer dfy_...
326+```
327+
328+```json
329+{
330+ "total_count": 1,
331+ "artifacts": [
332+ {
333+ "name": "site",
334+ "size_in_bytes": 482113,
335+ "archive_download_url": "https://diffinery.com/acme/website/actions/runs/{run_id}/artifacts/site",
336+ "expired": false,
337+ "created_at": "2026-10-12T09:41:07Z",
338+ "updated_at": "2026-10-12T09:41:07Z",
339+ "workflow_run": { "id": "{run_id}" }
340+ }
341+ ]
342+}
343+```
344+
345+Artifacts are sorted by name. The list takes `per_page` (default 30, at most 100) and `page`, and sends a `Link` header for the next page, so `octokit.paginate` works; `total_count` counts every artifact of the run. Unlike GitHub, entries have no `id` or `expires_at` field, `expired` is always `false`, `workflow_run.id` is Diffinery's run id (a string), and there is no endpoint to delete an artifact or to list artifacts across runs.
346+
347+`archive_download_url` returns the gzip-compressed tar file directly, with no redirect. Send the same token:
348+
349+```sh
350+curl -fsSL -H "Authorization: Bearer $DIFFINERY_TOKEN" \
351+ "https://diffinery.com/acme/website/actions/runs/$RUN_ID/artifacts/site" \
352+ -o site.tar.gz
353+tar -xzf site.tar.gz
354+```
355+
356+A token with read access to the repository is enough. Find run IDs with `diffinery run list` or the runs API; see [the API reference](/docs/api) and [the CLI reference](/docs/reference/cli).
357+
358+| Status | Body | Meaning |
359+| --- | --- | --- |
360+| 404 | `{"message":"artifact not found"}` | The run has no artifact with that name. |
361+| 410 | `{"message":"artifact expired"}` | The artifact is listed but its file is no longer stored. |
362+
363+## Differences from GitHub
364+
365+| Area | GitHub Actions | Diffinery |
366+| --- | --- | --- |
367+| Cache scope | Per branch, with fallback to the default branch | Whole repository, every branch and event |
368+| Cache management | Web page and API to list and delete entries | None; change the key to stop using an entry |
369+| Cache size per repository | 10 GB | 10 GB, least recently used entries evicted |
370+| Single cache entry or artifact | Uploaded in chunks, several GB | One request, under 100 MB compressed |
371+| Artifact format | Zip file | gzip-compressed tar file |
372+| `retention-days`, `overwrite` | Supported | Ignored; a same-name upload in the run always replaces |
373+| `artifact-id`, `artifact-url` outputs | Set | Not set |
374+| Download all artifacts, `pattern`, `merge-multiple` | Supported | Not supported; `name` is required |
375+| Download from another run or repository | `run-id`, `repository`, `github-token` | Not supported |
376+| Third-party actions that use the cache or artifact toolkit | Work | Don't work; see below |
377+
378+### Third-party actions and BuildKit
379+
380+Diffinery runners don't set `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_CACHE_URL` or `ACTIONS_RESULTS_URL`. Actions that use the `@actions/cache` or `@actions/artifact` npm packages directly, rather than through the built-ins above, fail or skip caching. The same applies to Docker BuildKit's `type=gha` cache backend.
381+
382+For Docker layer caching, keep the cache in an image in the [container registry](/docs/packages/container-registry) instead, for example with BuildKit's `type=registry` cache pointing at a tag such as `diffinery.com/acme/website:buildcache`.
383+
384+See [Docker in workflows](/docs/actions/docker) for signing in to the registry from a job, and [Compatibility with GitHub Actions](/docs/actions/compatibility) for everything else that differs.
385+
386+## Troubleshooting
387+
388+| Symptom | Cause and fix |
389+| --- | --- |
390+| `Cache not found for input keys: ...` on every run | The save never happened. Check the post step's log: the job may have failed (use `save-always: true`), the paths may not exist, or the archive may be over 100 MB. |
391+| `Cache entry with key ... already exists; not saving.` | Entries are immutable. Include something that changes, such as `hashFiles(...)` or `github.run_id`, in the key, and use `restore-keys` to pick up the latest. |
392+| `Failed to save cache: PUT ... -> 413 ...` | The compressed archive is over 100 MB. Cache fewer paths, or split them into several entries with different keys. |
393+| `Failed to save cache: ... 422 cache key too long` | Shorten the key to 512 characters or fewer. |
394+| `cache-hit` is `false` although files were restored | A restore key matched, not `key`. That's expected; `cache-hit` is `true` only for an exact match. |
395+| Restored files appear in an unexpected directory | Entries store absolute paths. A runner with a different home or workspace directory restores into the original paths. Add the runner type to the key. |
396+| `The artifact name is not valid: ...` | Remove `/`, `\`, `:` and the other characters listed in [Upload an artifact](#upload-an-artifact). |
397+| `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`. |
398+| `Failed to upload artifact '<name>': ... 413 ...` | The compressed artifact is over 100 MB. Split it into several artifacts. |
399+| `Unable to download artifact '<name>': ... 404 artifact not found` | No job in this run has uploaded that name yet. Add the uploading job to `needs`, and check the name's spelling and case. |
400+| An artifact contains another matrix job's files | Matrix jobs uploaded the same name and the last one won. Include matrix values in the name. |
401+| A third-party action logs that the cache service is unavailable | It uses the cache toolkit directly. Replace it with `actions/cache` or the setup action's `cache` input. |
addedapps/diffinery/docs/actions/compatibility.md+388−0View file
@@ -1,0 +1,388 @@
1+---
2+title: Compatibility with GitHub Actions
3+description: What works, what works differently and what isn't supported when you run GitHub Actions workflows on Diffinery, with a checklist for porting workflows.
4+---
5+
6+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.
7+
8+## At a glance
9+
10+| Area | Status on Diffinery | Details |
11+| --- | --- | --- |
12+| Workflow syntax | Supported, with a few keys accepted but ignored | [Workflow syntax](#workflow-syntax) |
13+| Triggers | `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release`, `repository_dispatch`, `workflow_call` | [Triggers](#triggers) |
14+| Expressions and contexts | Supported, with small differences in values | [Contexts and environment variables](#contexts-and-environment-variables) |
15+| Hosted runners | Linux x64 only (Ubuntu 24.04) | [Runners](#runners) |
16+| Self-hosted runners | Supported, through `diffinery runner` | [Runners](/docs/actions/runners) |
17+| Matrices, `needs`, outputs, job summaries | Supported | [Jobs and steps](#jobs-and-steps) |
18+| `actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go` | Built into the runner, most inputs supported | [Built-in actions](#built-in-actions) |
19+| `actions/cache`, `actions/upload-artifact`, `actions/download-artifact` | Built into the runner, with limits | [Caching and artifacts](/docs/actions/caching-and-artifacts) |
20+| `actions/github-script` | Not supported yet | [actions/github-script](#actionsgithub-script) |
21+| Other JavaScript, composite and Docker actions | Supported, downloaded from GitHub | [Other actions](#other-actions) |
22+| Job containers and service containers | Supported | [Docker in workflows](/docs/actions/docker) |
23+| Reusable workflows | Supported, except a matrix on the calling job | [Reusable workflows](/docs/actions/reusable-workflows) |
24+| Secrets and variables | Repository, organization and environment secrets; repository and environment variables | [Secrets and variables](/docs/actions/secrets-and-variables) |
25+| Environments | Supported: branch policies, environment secrets and variables, deployments. No required reviewers or wait timers. | [Secrets, variables and environments](#secrets-variables-and-environments) |
26+| `GITHUB_TOKEN` | Read-only on its own repository, whatever `permissions` says | [GITHUB_TOKEN and permissions](#github_token-and-permissions) |
27+| OIDC | Supported, with Diffinery as the issuer | [OIDC](/docs/actions/oidc) |
28+| Windows and macOS hosted runners | Not supported yet | [Runners](#runners) |
29+
30+## Workflow files
31+
32+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](/docs/actions/overview#where-workflow-files-live).
33+
34+Check a workflow before you push it:
35+
36+```sh
37+diffinery workflow validate .github/workflows/ci.yml
38+```
39+
40+Errors fail the file; warnings, such as `unknown job key '<key>'` or `unknown event '<name>'`, are printed but the workflow still runs.
41+
42+## Triggers
43+
44+| Event | Status | Differences |
45+| --- | --- | --- |
46+| `push` | Supported | `commits` in the payload lists only the pushed head commit. Path filters pass when a push creates a branch. |
47+| `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. |
48+| `pull_request_target` | Supported | Same activity types as `pull_request`. Runs also while the pull request conflicts. |
49+| `merge_group` | Supported | `checks_requested`. |
50+| `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`. |
51+| `schedule` | Supported | Checked every 5 minutes, so a schedule starts within about 5 minutes after it's due. Runs have the actor `diffinery-scheduler`. |
52+| `workflow_run` | Supported | Activity types `requested` and `completed` (not `in_progress`), `workflows` is required, and chains go at most 3 levels deep. |
53+| `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. |
54+| `repository_dispatch` | Supported | `POST /repos/{owner}/{repo}/dispatches` answers `204`. The payload has `action`, `branch` and `client_payload`, but no `repository` or `sender`. |
55+| `workflow_call` | Supported | See [Reusable workflows](/docs/actions/reusable-workflows). |
56+| 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`.) |
57+| A name that isn't a GitHub event | Warning | `unknown event '<name>'`. |
58+
59+Details for each event are in [Events that trigger workflows](/docs/actions/events).
60+
61+## Workflow syntax
62+
63+Every key of the GitHub Actions workflow syntax parses. These keys behave differently:
64+
65+| Key | What happens on Diffinery |
66+| --- | --- |
67+| `jobs.<id>.runs-on` | Can't use the `matrix` context: `runs-on: ${{ matrix.os }}` evaluates to `null`, and the job fails with the "No Diffinery runner matches runs-on" message. The `group` key is ignored. |
68+| `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 aren't supported, `name` and `url` can't use `needs`, and `url` can't use step outputs. See [environment](/docs/actions/workflow-syntax#environment). |
69+| `jobs.<id>.concurrency` | Accepted and ignored. Workflow-level `concurrency` works. |
70+| `jobs.<id>.continue-on-error` | Accepted, no effect. A failing job fails the run. Use step-level `continue-on-error`, which works. |
71+| `jobs.<id>.snapshot` | Accepted and ignored. |
72+| `jobs.<id>.permissions`, `permissions` | Only `id-token` and `packages` have an effect. See [GITHUB_TOKEN and permissions](#github_token-and-permissions). |
73+| `jobs.<id>.timeout-minutes` | Default and maximum 360. Larger values are capped at 360. |
74+| `jobs.<id>.strategy.matrix` | At most 256 jobs per matrix. |
75+| `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`. |
76+| `concurrency` | Several runs can wait in a group; GitHub keeps only the newest pending one. See [Concurrency](/docs/actions/overview#concurrency). |
77+| `defaults.run.shell`, `jobs.<id>.steps[*].shell` | `cmd` isn't supported. `pwsh` and `powershell` need PowerShell, which hosted runners don't have. |
78+
79+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](/docs/actions/workflow-syntax).
80+
81+## Runners
82+
83+| `runs-on` | GitHub | Diffinery |
84+| --- | --- | --- |
85+| `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04` | Hosted Ubuntu VM | Hosted Ubuntu 24.04 container, x64. All three labels get the same image. |
86+| `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. |
87+| `windows-*`, `macos-*` | Hosted Windows or macOS VM | Not supported yet. 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.` |
88+| Larger runners and runner groups | Supported | Not available. `group` is ignored. |
89+| `[self-hosted, ...]` | Self-hosted runner | Self-hosted runner started with `diffinery runner`, matched on every label. |
90+
91+What's different about the hosted image:
92+
93+- It's much smaller than GitHub's image. Git, Git LFS, curl, jq, build-essential, Python 3.12, Node.js 22 with npm, pnpm and yarn, and Docker are preinstalled. Go, Java, Ruby, Rust, .NET, PowerShell, browsers and cloud CLIs aren't. See [The Ubuntu 24.04 image](/docs/actions/runners#the-ubuntu-2404-image).
94+- Jobs run as `root` and `sudo` isn't installed. Drop `sudo` from commands.
95+- The apt package lists are removed. Run `apt-get update` before `apt-get install`.
96+- Every job starts in a fresh container. Nothing carries over between jobs except caches and artifacts.
97+
98+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](/docs/actions/runners#self-hosted-runners).
99+
100+## Jobs and steps
101+
102+These work the same as on GitHub: `needs`, job outputs, `if` conditions, status check functions, matrices with `include` and `exclude`, `fail-fast`, `max-parallel`, step `continue-on-error` and `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`.
103+
104+Differences:
105+
106+| Behaviour | GitHub | Diffinery |
107+| --- | --- | --- |
108+| A job whose `needs` includes a skipped job | Skipped | Runs; a skipped job counts as a success. |
109+| `failure()` in a job's `if` | Looks at all upstream jobs | Looks only at the jobs in the job's own `needs`. |
110+| Default shell for `run` | `bash -e {0}` | `bash --noprofile --norc -eo pipefail {0}`, so a failing command in a pipeline fails the step. |
111+| `::add-matcher::` problem matchers | Turn matching output into annotations | Not supported; the line is printed as is. |
112+| Annotations (`::error file=...`) | Shown on pull request files | Shown in the job log only. |
113+| `::set-env` and `::add-path` | Disabled | Disabled, with the same message. |
114+| `github.run_number` | Counts runs of one workflow | Counts runs of every workflow in the repository. |
115+
116+See [Expressions and contexts](/docs/actions/expressions) and [Workflow syntax](/docs/actions/workflow-syntax) for the details.
117+
118+## Built-in actions
119+
120+The runner has its own implementation of these actions. They're matched by name, whatever the version after `@`, and nothing is downloaded:
121+
122+- `actions/checkout`
123+- `actions/setup-node`, `actions/setup-python`, `actions/setup-go`
124+- `actions/cache`, `actions/cache/restore`, `actions/cache/save`
125+- `actions/upload-artifact`, `actions/download-artifact`
126+- `actions/github-script` (fails with a message; see below)
127+
128+Built-in actions run on the runner, not inside a job container. In a job with `container:`, they work on the shared workspace.
129+
130+### actions/checkout
131+
132+Checks out a repository on the same Diffinery instance into the workspace.
133+
134+| Input | Support | Notes |
135+| --- | --- | --- |
136+| `repository` | Supported | `owner/name` of a repository on Diffinery, not on GitHub. Default: the workflow's repository. |
137+| `ref` | Supported | Branch, tag, full ref or 40-character commit SHA. Default: the commit that triggered the run, or the default branch of another repository. A short SHA is treated as a branch or tag name. |
138+| `token` | Supported | Default: the job's token, which can only read the workflow's own repository. To check out another repository, pass a personal or agent token from a secret. |
139+| `fetch-depth` | Supported | Default `1`. `0` fetches all branches and tags with full history. |
140+| `fetch-tags` | Supported | Default `false`. |
141+| `path` | Supported | Relative to the workspace. |
142+| `clean` | Supported | Default `true`: runs `git clean -ffdx` and `git reset --hard HEAD` on an existing checkout. |
143+| `submodules` | Supported | `true` or `recursive`. Submodules are fetched with the same token; a failure gives the warning `Failed to update submodules (best effort).` |
144+| `persist-credentials` | Supported | Default `true`: the token stays in the local Git config so later `git` commands authenticate. A post step removes it at the end of the job. |
145+| `lfs` | Not supported | `lfs: true` logs `Git LFS is not supported by the Diffinery checkout built-in; LFS pointers will not be resolved.` Run `git lfs pull` in a later step; see [Use LFS files in workflows](/docs/repositories/git-lfs#use-lfs-files-in-workflows). |
146+| `sparse-checkout` | Not supported | Logs `sparse-checkout is not supported by the Diffinery checkout built-in; doing a full checkout.` |
147+| `filter` | Not supported | Ignored. Partial clone filters aren't supported yet. |
148+| `ssh-key`, `ssh-known-hosts`, `ssh-strict`, `ssh-user`, `set-safe-directory`, `show-progress`, `github-server-url`, `sparse-checkout-cone-mode` | Ignored | Checkout always uses HTTPS with a token. Every directory is already a Git safe directory on hosted runners. |
149+
150+Outputs: `commit` (the checked-out SHA) and `ref`.
151+
152+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.
153+
154+Errors:
155+
156+- `Failed to fetch ref '<ref>' from <owner>/<repo>. Check that the ref exists and the token has read access.`
157+- `A branch or tag with the name '<ref>' could not be found` (with `fetch-depth: 0`).
158+- `Checked out <sha> but expected <sha>.`
159+
160+### actions/setup-node
161+
162+| Input | Support | Notes |
163+| --- | --- | --- |
164+| `node-version` | Supported | A version or range (`22`, `20.x`, `>=20.10`), `lts/*`, `lts/<codename>`, `latest`, `current` or `node`. |
165+| `node-version-file` | Supported | `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`. |
166+| `check-latest` | Supported | Download the newest matching version even when the preinstalled one satisfies the request. |
167+| `registry-url`, `scope` | Supported | Writes an `.npmrc` that reads `NODE_AUTH_TOKEN`, and sets `NPM_CONFIG_USERCONFIG`. |
168+| `cache`, `cache-dependency-path` | Supported | `npm`, `pnpm` or `yarn`. See [Dependency caching with setup actions](/docs/actions/caching-and-artifacts#dependency-caching-with-setup-actions). |
169+| `architecture`, `always-auth`, `token`, `mirror`, `mirror-token`, `package-manager-cache` | Ignored | |
170+
171+Hosted runners have Node.js 22.22.0. A request that it satisfies, or that names major version 22, 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>.`
172+
173+Outputs: `node-version` (for example `v22.22.0`) and `cache-hit`.
174+
175+### actions/setup-python
176+
177+| Input | Support | Notes |
178+| --- | --- | --- |
179+| `python-version` | Supported | One CPython version or range, such as `3.12`, `3.11.9` or `3.x`. Only stable releases are installed. |
180+| `python-version-file` | Supported | `.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. |
181+| `cache`, `cache-dependency-path` | Partly | `pip` only. Other values fail with `Caching for '<manager>' is not supported by the Diffinery setup-python built-in (only pip).` |
182+| PyPy and GraalPy versions | Not supported | Fails with `'<version>' is not supported by the Diffinery setup-python built-in.` |
183+| `architecture`, `check-latest`, `token`, `update-environment`, `allow-prereleases`, `freethreaded` | Ignored | |
184+
185+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.
186+
187+Outputs: `python-version`, `python-path` and `cache-hit`.
188+
189+### actions/setup-go
190+
191+| Input | Support | Notes |
192+| --- | --- | --- |
193+| `go-version` | Supported | A version or range, `stable` or `oldstable`. |
194+| `go-version-file` | Supported | `go.mod` or `go.work` (`toolchain` line first, then `go`), `.tool-versions` (`golang` or `go`), or a file whose first line is the version. |
195+| `check-latest` | Supported | |
196+| `cache`, `cache-dependency-path` | Supported | On by default. Skipped with `go.sum not found; skipping dependency cache.` when there's no `go.sum`. |
197+| `architecture`, `token` | Ignored | |
198+
199+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.`
200+
201+Outputs: `go-version` and `cache-hit`.
202+
203+### actions/cache and artifacts
204+
205+`actions/cache`, `actions/cache/restore`, `actions/cache/save`, `actions/upload-artifact` and `actions/download-artifact` support most of their inputs. The main differences are that caches are shared by every branch of the repository, each cache entry or artifact must be under 100 MB compressed, artifacts are `.tar.gz` files, and `download-artifact` needs a `name` and only reads the current run. [Caching and artifacts](/docs/actions/caching-and-artifacts) has the full list.
206+
207+### actions/github-script
208+
209+`actions/github-script` isn't supported yet. The step fails with:
210+
211+```text
212+actions/github-script is not supported on Diffinery runners yet. Use a `run:` step with `node` or `curl` against $GITHUB_API_URL instead.
213+```
214+
215+Replace it with a `run:` step. For example, instead of reading a pull request with `github.rest.pulls.get`:
216+
217+```yaml
218+ - name: Read the pull request
219+ env:
220+ TOKEN: ${{ secrets.GITHUB_TOKEN }}
221+ run: |
222+ curl -fsSL -H "Authorization: Bearer $TOKEN" \
223+ "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls/${{ github.event.pull_request.number }}" \
224+ | jq '{title, state}'
225+```
226+
227+The job token can only read. For calls that write, such as adding a comment or a label, store a personal or agent token as a secret and use that instead. See [the REST API reference](/docs/reference/rest-api) for the endpoints Diffinery has.
228+
229+## Other actions
230+
231+Any other `uses: owner/repo[/path]@ref` is downloaded from GitHub (`codeload.github.com`) when the step runs and executed according to its `action.yml` or `action.yaml`.
232+
233+### JavaScript actions
234+
235+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.
236+
237+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.
238+
239+### Composite actions
240+
241+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).`
242+
243+### Docker container actions
244+
245+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](/docs/actions/docker#docker-container-actions).
246+
247+### Local actions
248+
249+`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?`
250+
251+### Actions stored on Diffinery
252+
253+`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](/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:
254+
255+```yaml
256+ - uses: actions/checkout@v4
257+ with:
258+ repository: acme/my-action
259+ ref: v1
260+ path: .actions/my-action
261+ token: ${{ secrets.ACTIONS_READ_TOKEN }}
262+ - uses: ./.actions/my-action
263+```
264+
265+`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 repositories.
266+
267+### Unsupported action runtimes
268+
269+Any other `runs.using` value fails the step with `Unsupported action runtime 'runs.using: <value>' in <action>`.
270+
271+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>'`.
272+
273+### Actions that call the GitHub API
274+
275+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 read-only calls to endpoints Diffinery has work (see [REST API](/docs/reference/rest-api)). Calls to the GraphQL API fail, because GraphQL isn't supported yet. Actions that write to the repository (comment on pull requests, add labels, push commits or tags) fail, because the job token is read-only. Give such actions a personal or agent token from a secret, through their `token` or `github-token` input.
276+
277+### Actions that use the cache or artifact services directly
278+
279+Diffinery doesn't set `ACTIONS_RUNTIME_TOKEN`, `ACTIONS_RUNTIME_URL`, `ACTIONS_CACHE_URL` or `ACTIONS_RESULTS_URL`. Actions that use the `@actions/cache` or `@actions/artifact` packages directly, rather than through the built-in actions, can't reach a cache or artifact service. This includes Docker BuildKit's `type=gha` cache. Use the built-in actions, or the setup actions' `cache` input, instead.
280+
281+## Contexts and environment variables
282+
283+| Value | GitHub | Diffinery |
284+| --- | --- | --- |
285+| `github.server_url`, `GITHUB_SERVER_URL` | `https://github.com` | `https://diffinery.com` |
286+| `github.api_url`, `GITHUB_API_URL` | `https://api.github.com` | `https://diffinery.com/api/v3` |
287+| `github.run_id`, `GITHUB_RUN_ID` | A number | A 32-character hexadecimal string |
288+| `github.repository_id` | A number | Diffinery's repository ID, a string |
289+| `github.ref_protected` | `true` for protected branches | Always `false` |
290+| `github.event.repository.private` | The repository's visibility | Always `true` (`release` payloads have no `private` field) |
291+| `github.actor` for scheduled runs | The last person who edited the cron | `diffinery-scheduler` |
292+| `github.token`, `secrets.GITHUB_TOKEN` | The job token | The job token, also available as `secrets.DIFFINERY_TOKEN` |
293+| `github.actor_id`, `github.repository_owner_id`, `github.workflow_sha` | Set | Not set |
294+| `runner.os`, `RUNNER_OS` | `Linux`, `Windows` or `macOS` | Always `Linux`, also on self-hosted macOS machines |
295+| `runner.name`, `RUNNER_NAME` | The runner's name | `diffinery` |
296+| `runner.environment`, `RUNNER_ENVIRONMENT` | `github-hosted` or `self-hosted` | `diffinery-hosted`, also on self-hosted runners |
297+| `DIFFINERY` | Not set | `true` |
298+
299+`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:
300+
301+```yaml
302+ - if: github.server_url == 'https://diffinery.com'
303+ run: echo "Running on Diffinery"
304+```
305+
306+The complete list is in [Expressions and contexts](/docs/actions/expressions).
307+
308+## GITHUB_TOKEN and permissions
309+
310+Every job gets a token, available as `secrets.GITHUB_TOKEN`, `secrets.DIFFINERY_TOKEN` and `github.token`. On Diffinery it:
311+
312+- has read access to the workflow's own repository only, whatever `permissions` says. It can clone and read through the API, but it can't push, comment, label or merge, and it can't read other repositories, even public ones.
313+- works with the [container registry](/docs/packages/container-registry) according to the `packages` permission.
314+- can request OIDC tokens when `id-token: write` is granted.
315+- stops working when the job completes.
316+
317+Other `permissions` scopes are accepted and have no effect. For anything that writes to a repository, store a personal access token or an agent token as a secret. See [GITHUB_TOKEN and the job token](/docs/actions/secrets-and-variables#github_token-and-the-job-token).
318+
319+## Secrets, variables and environments
320+
321+| Feature | Status |
322+| --- | --- |
323+| Repository secrets and variables | Supported |
324+| Organization secrets | Supported (CLI and API) |
325+| Organization variables | Not available |
326+| Environments with deployment branch policies (protected branches only, or branch and tag patterns) | Supported, through the REST API. See [Environments](/docs/actions/environments). |
327+| 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. |
328+| Environment protection rules: required reviewers, wait timers | Not supported yet. The API refuses them with `required reviewers and wait timers are not supported yet`. |
329+| Setting secrets with a sealed-box `encrypted_value` | Not supported yet. Send the plain `value` over HTTPS; it's encrypted at rest. |
330+| Secrets in `pull_request` runs | Provided, because every pull request comes from a branch of the same repository |
331+| Secret names starting with `GITHUB_` | Rejected for repository secrets, as on GitHub |
332+
333+See [Secrets and variables](/docs/actions/secrets-and-variables).
334+
335+## Reusable workflows and OIDC
336+
337+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](/docs/actions/reusable-workflows#differences-from-github) in Reusable workflows.
338+
339+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](/docs/actions/oidc#differences-from-github) in OIDC.
340+
341+## Porting checklist
342+
343+Work through this list for each workflow you move from GitHub. Most workflows need only the first two items.
344+
345+1. Run `diffinery workflow validate` on each file and fix any errors. Read the warnings for ignored keys.
346+2. Push and watch the first run with `diffinery run watch` or on the **Actions** tab.
347+3. Replace `runs-on: windows-*` and `macos-*` with Linux, or with `[self-hosted, ...]` labels and a [self-hosted runner](/docs/actions/runners#self-hosted-runners).
348+4. Replace `runs-on: ${{ matrix.os }}` with separate jobs, one per runner type.
349+5. Remove `sudo`, and add `apt-get update` before `apt-get install`.
350+6. Install tools the hosted image doesn't have (Go, Java, Ruby, Rust, PowerShell, cloud CLIs) with a setup action or `apt-get`.
351+7. Replace `actions/github-script` with `run:` steps using `curl` or `node`.
352+8. Find every step that writes with `GITHUB_TOKEN` (git push, tags, comments, labels, merges) and give it a personal or agent token from a secret.
353+9. Give `actions/checkout` of another repository a token from a secret.
354+10. Recreate your environments, their branch policies, secrets and variables through the REST API (see [Environments](/docs/actions/environments)), and remove any reliance on environment approvals or wait timers.
355+11. Add `git lfs pull` after `actions/checkout` if the job needs LFS files.
356+12. Change `download-artifact` steps without `name` into one step per artifact, and make artifact names unique across matrix jobs.
357+13. Keep each cache entry and artifact under 100 MB compressed.
358+14. Replace actions that use the cache or artifact toolkit directly, and BuildKit `type=gha` caches.
359+15. Replace `ghcr.io` with `diffinery.com` in image names and logins; see [Container registry](/docs/packages/container-registry#migrating-from-ghcrio).
360+16. Replace hard-coded `github.com` and `api.github.com` URLs with `${{ github.server_url }}` and `${{ github.api_url }}`.
361+17. Update OIDC trust policies in your cloud accounts to the Diffinery issuer and subject format.
362+18. Move job-level `concurrency` to the workflow level if you rely on it, and replace job-level `continue-on-error` with step-level `continue-on-error` or `if: always()` on dependent jobs.
363+19. Check jobs that depend on jobs which can be skipped: on Diffinery they run instead of being skipped.
364+20. Move actions from private GitHub repositories into a Diffinery repository and use them as local actions.
365+
366+[Migrate from GitHub](/docs/migrate-from-github#workflows) covers moving the rest of the repository.
367+
368+## Troubleshooting
369+
370+| Message | Cause and fix |
371+| --- | --- |
372+| `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. A `matrix` value in `runs-on` causes this too. |
373+| `actions/github-script is not supported on Diffinery runners yet. ...` | Replace the step with a `run:` step. See [actions/github-script](#actionsgithub-script). |
374+| `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. |
375+| `Can't find 'action.yml' or 'action.yaml' under '<dir>'. ...` | Check out the repository before using a local action, and check the path. |
376+| `Unsupported action runtime 'runs.using: <value>' in <action>` | The action uses a runtime other than `node*`, `composite` or `docker`. |
377+| ``The `cmd` shell is not supported on Linux runners.`` | Use `bash` or `sh`. |
378+| `sudo: command not found` | Jobs run as root on hosted runners. Remove `sudo`. |
379+| `Failed to fetch ref '<ref>' from <owner>/<repo>. Check that the ref exists and the token has read access.` | Checking out another repository needs a token from a secret, or the ref doesn't exist. |
380+| `Git LFS is not supported by the Diffinery checkout built-in; ...` | Add a `git lfs pull` step after the checkout. |
381+| `No go-version specified and Go is not installed on this runner.` | Set `go-version` or `go-version-file`. |
382+| `'<version>' is not supported by the Diffinery setup-python built-in.` | PyPy and GraalPy aren't available. Use CPython, or install PyPy in a step. |
383+| ``download-artifact without `name` ... is not supported on Diffinery runners yet; specify `name`.`` | Add `name`, one step per artifact. |
384+| `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. |
385+| `A matrix may produce at most 256 jobs.` | Split the matrix. |
386+| `Composite action nesting is too deep (max 10).` | Flatten nested composite actions. |
387+| A `git push` with `GITHUB_TOKEN` is refused | The job token is read-only. Use a personal or agent token from a secret. |
388+| A job runs although a job it needs was skipped | A skipped job counts as a success on Diffinery. Check `needs.<job>.result == 'success'` in `if`. |
addedapps/diffinery/docs/actions/docker.md+377−0View file
@@ -1,0 +1,377 @@
1+---
2+title: Docker in workflows
3+description: Run jobs in containers, start service containers, use Docker container actions, and build and push images from Diffinery workflows.
4+---
5+
6+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.
7+
8+## Where Docker is available
9+
10+| Runner | Docker |
11+|---|---|
12+| Hosted (`runs-on: ubuntu-latest` and other hosted labels) | Always available. The Docker daemon starts the first time the job uses it. |
13+| Self-hosted (`runs-on: [self-hosted, ...]`) | Available when the machine has Docker installed and running, with the `docker` CLI on the runner's `PATH`. |
14+
15+On hosted runners:
16+
17+- The `docker` command starts the daemon on first use and prints `Starting the Docker daemon`. Jobs that never use Docker don't start it.
18+- 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.
19+- Containers use the DNS servers `1.1.1.1` and `8.8.8.8`.
20+- Bridge networks, service names, port publishing and image builds all work, as on GitHub's hosted runners.
21+
22+See [Runners](/docs/actions/runners) for the hosted image and for setting up Docker on a self-hosted runner.
23+
24+## Job containers
25+
26+`jobs.<id>.container` runs the job's steps inside a container instead of directly on the runner.
27+
28+```yaml
29+jobs:
30+ test:
31+ runs-on: ubuntu-latest
32+ container:
33+ image: node:22-bookworm
34+ env:
35+ NODE_ENV: test
36+ options: --cpus 1
37+ steps:
38+ - uses: actions/checkout@v4
39+ - run: node --version && npm ci && npm test
40+```
41+
42+`container` is either an image name (`container: node:22`) or a mapping:
43+
44+| Key | Description |
45+|---|---|
46+| `image` | Image to run. Expressions are allowed. If it evaluates to an empty string, the job runs without a container. |
47+| `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. |
48+| `env` | Environment variables for the container. |
49+| `ports` | Ports to publish, as for `docker run -p`. |
50+| `volumes` | Volumes to mount, as for `docker run -v`. |
51+| `options` | Extra `docker run` options, as one string. `--network` is not allowed. |
52+
53+How it works:
54+
55+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.
56+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."
57+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.
58+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`.
59+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).
60+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.
61+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.
62+8. When the job ends, every container the job started and its network are removed.
63+
64+The `job.container` context has `id` and `network`.
65+
66+## Service containers
67+
68+`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.
69+
70+```yaml
71+services:
72+ postgres:
73+ image: postgres:16
74+ env:
75+ POSTGRES_PASSWORD: postgres
76+ ports: ["5432:5432"]
77+ options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30
78+```
79+
80+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.
81+
82+### Reaching a service
83+
84+How a step reaches a service depends on where the step runs:
85+
86+| The job... | Reach the service at | Notes |
87+|---|---|---|
88+| 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. |
89+| 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']`. |
90+
91+```yaml
92+- run: redis-cli -p ${{ job.services.redis.ports['6379'] }} ping
93+```
94+
95+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)`.
96+
97+### Health checks
98+
99+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):
100+
101+| Message | Meaning |
102+|---|---|
103+| `Service container NAME failed its health check.` | Docker marked the container unhealthy. |
104+| `Service container NAME exited before it became healthy.` | The container stopped, for example because of a bad environment variable. |
105+| `Service container NAME did not become healthy in time (status: starting).` | It was still starting after 15 minutes. |
106+
107+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.
108+
109+### The --network option
110+
111+Every container of a job shares one network, so `--network` (or `--net`) in `options` is rejected:
112+
113+```text
114+services.cache: the --network option is not supported; every container of the job shares one network.
115+```
116+
117+## Docker container actions
118+
119+Two kinds of steps run an action in its own container:
120+
121+- `uses: docker://IMAGE` runs a public or mirrored image directly.
122+- 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.
123+
124+```yaml
125+steps:
126+ - uses: actions/checkout@v4
127+ # An image straight from a registry; `args` and `entrypoint` drive the container.
128+ - uses: docker://alpine:3.20
129+ with:
130+ entrypoint: /bin/sh
131+ args: -c "ls -la /github/workspace"
132+ # A Docker action in this repository (./.github/actions/lint/action.yml + Dockerfile).
133+ - uses: ./.github/actions/lint
134+ with:
135+ strict: true
136+```
137+
138+```yaml
139+# .github/actions/lint/action.yml
140+name: Lint
141+inputs:
142+ strict:
143+ default: "false"
144+runs:
145+ using: docker
146+ image: Dockerfile
147+ args:
148+ - --strict=${{ inputs.strict }}
149+```
150+
151+Behaviour, matching GitHub's:
152+
153+- 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.
154+- 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.
155+- 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)."
156+- 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.
157+- A non-zero exit fails the step with "Docker run failed with exit code N".
158+
159+Remote Docker actions (`uses: owner/repo@ref`) are downloaded from github.com, like other remote actions.
160+
161+## docker build, run and compose in steps
162+
163+On hosted runners the full Docker CLI is installed with the Buildx and Compose plugins, so ordinary commands work in `run:` steps:
164+
165+```yaml
166+jobs:
167+ integration:
168+ runs-on: ubuntu-latest
169+ steps:
170+ - uses: actions/checkout@v4
171+ - run: docker build -t website:test .
172+ - run: docker run --rm website:test npm test
173+ - run: docker compose -f compose.test.yml up -d --wait
174+ - run: ./scripts/integration-tests.sh
175+ - if: always()
176+ run: docker compose -f compose.test.yml logs && docker compose -f compose.test.yml down -v
177+```
178+
179+`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.
180+
181+> **Note:** BuildKit's `type=gha` cache (`cache-from: type=gha`, `cache-to: type=gha`) needs GitHub's cache service, which Diffinery doesn't provide. Remove those lines when you port a workflow.
182+
183+## Push images to the Diffinery registry
184+
185+Diffinery hosts a container registry at `diffinery.com`, 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:
186+
187+| `permissions` in the workflow or job | `GITHUB_TOKEN` can |
188+|---|---|
189+| No `permissions` block, or `write-all` | Push and pull |
190+| `packages: write` | Push and pull |
191+| `packages: read`, or `read-all` | Pull |
192+| A mapping without `packages` | Nothing |
193+
194+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.
195+
196+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,,}`.
197+
198+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.
199+
200+The registry, its permissions and its API are documented in [Container registry](/docs/packages/container-registry).
201+
202+## Examples
203+
204+### Postgres service for tests
205+
206+Steps run on the runner and reach Postgres on `localhost`:
207+
208+```yaml
209+name: Test
210+on: [push, pull_request]
211+
212+jobs:
213+ test:
214+ runs-on: ubuntu-latest
215+ services:
216+ postgres:
217+ image: postgres:16
218+ env:
219+ POSTGRES_USER: postgres
220+ POSTGRES_PASSWORD: postgres
221+ POSTGRES_DB: website_test
222+ ports: ["5432:5432"]
223+ options: >-
224+ --health-cmd "pg_isready -U postgres"
225+ --health-interval 2s
226+ --health-timeout 5s
227+ --health-retries 30
228+ env:
229+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/website_test
230+ steps:
231+ - uses: actions/checkout@v4
232+ - uses: actions/setup-node@v4
233+ with:
234+ node-version: 22
235+ cache: npm
236+ - run: npm ci
237+ - run: npm run db:migrate
238+ - run: npm test
239+```
240+
241+The same job in a container reaches the service by name instead:
242+
243+```yaml
244+jobs:
245+ test:
246+ runs-on: ubuntu-latest
247+ container: node:22-bookworm
248+ services:
249+ postgres:
250+ image: postgres:16
251+ env:
252+ POSTGRES_PASSWORD: postgres
253+ options: --health-cmd "pg_isready -U postgres" --health-interval 2s --health-retries 30
254+ env:
255+ DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
256+ steps:
257+ - uses: actions/checkout@v4
258+ - run: npm ci && npm test
259+```
260+
261+### Redis with a random host port
262+
263+```yaml
264+jobs:
265+ cache-tests:
266+ runs-on: ubuntu-latest
267+ services:
268+ redis:
269+ image: redis:7-alpine
270+ ports: ["6379"]
271+ options: --health-cmd "redis-cli ping" --health-interval 1s --health-retries 30
272+ steps:
273+ - uses: actions/checkout@v4
274+ - run: npm ci && npm test
275+ env:
276+ REDIS_URL: redis://localhost:${{ job.services.redis.ports['6379'] }}
277+```
278+
279+### Build and push an image on every push to main
280+
281+With plain Docker commands:
282+
283+```yaml
284+name: Image
285+on:
286+ push:
287+ branches: [main]
288+
289+jobs:
290+ image:
291+ runs-on: ubuntu-latest
292+ permissions:
293+ contents: read
294+ packages: write
295+ steps:
296+ - uses: actions/checkout@v4
297+ - name: Sign in to the Diffinery registry
298+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin
299+ - name: Build and push
300+ run: |
301+ image="diffinery.com/${GITHUB_REPOSITORY,,}"
302+ docker build -t "$image:${{ github.sha }}" -t "$image:latest" .
303+ docker push "$image:${{ github.sha }}"
304+ docker push "$image:latest"
305+```
306+
307+With the `docker/*` actions most ghcr.io workflows use:
308+
309+```yaml
310+jobs:
311+ image:
312+ runs-on: ubuntu-latest
313+ permissions:
314+ contents: read
315+ packages: write
316+ steps:
317+ - uses: actions/checkout@v4
318+ - run: echo "IMAGE=diffinery.com/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
319+ - uses: docker/setup-buildx-action@v3
320+ - uses: docker/login-action@v3
321+ with:
322+ registry: diffinery.com
323+ username: ${{ github.actor }}
324+ password: ${{ secrets.GITHUB_TOKEN }}
325+ - uses: docker/build-push-action@v6
326+ with:
327+ context: .
328+ push: true
329+ tags: ${{ env.IMAGE }}:${{ github.sha }}
330+```
331+
332+A later job (or another workflow of the same owner) can use the image as its job container:
333+
334+```yaml
335+ smoke:
336+ needs: image
337+ runs-on: ubuntu-latest
338+ permissions:
339+ packages: read
340+ container:
341+ image: diffinery.com/acme/website:${{ github.sha }}
342+ credentials:
343+ username: ${{ github.actor }}
344+ password: ${{ secrets.GITHUB_TOKEN }}
345+ steps:
346+ - run: /app/healthcheck
347+```
348+
349+A deploy job can then hand the image to your platform, using credentials stored as [environment](/docs/actions/environments) secrets so that only allowed branches reach them. The job records a deployment of the commit to `production`:
350+
351+```yaml
352+ deploy:
353+ needs: smoke
354+ runs-on: ubuntu-latest
355+ environment:
356+ name: production
357+ url: https://www.example.com
358+ steps:
359+ - name: Tell the platform to roll out the new image
360+ env:
361+ DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
362+ run: curl -fsS -X POST "$DEPLOY_HOOK_URL" -d "image=diffinery.com/acme/website:${{ github.sha }}"
363+```
364+
365+## Troubleshooting
366+
367+| Message | Fix |
368+|---|---|
369+| `... 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. |
370+| `... 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. |
371+| `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. |
372+| `Could not pull IMAGE (docker pull exited with N).` | The image name or tag is wrong, or it's private and needs `credentials`. |
373+| `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. |
374+| `Service container NAME failed its health check.` | Check the service log printed above the error, usually a missing environment variable such as `POSTGRES_PASSWORD`. |
375+| `the --network option is not supported` | Remove `--network` from `options`. Services are already on the job's network. |
376+| 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. |
377+| 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. |
addedapps/diffinery/docs/actions/environments.md+786−0View file
@@ -1,0 +1,786 @@
1+---
2+title: Deployments and environments
3+description: Create deployment environments, limit which branches and tags deploy to them, give them their own secrets and variables, and track deployments.
4+---
5+
6+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).
7+
8+## How it fits together
9+
10+| Concept | What it is |
11+|---|---|
12+| Environment | A name in one repository (`production`), with an optional deployment branch policy, secrets and variables. |
13+| Deployment branch policy | Which refs may use the environment: any ref (no policy), protected branches only, or a list of branch and tag patterns. |
14+| 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. |
15+| Environment variable | A plain value that jobs using the environment receive in the `vars` context. |
16+| 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. |
17+| Deployment status | The state of a deployment over time: `queued`, `pending`, `in_progress`, `success`, `failure`, `error` or `inactive`, with optional log and environment URLs. |
18+
19+A typical setup:
20+
21+1. A repository admin creates `production`, limits it to protected branches, and stores the deploy credentials as environment secrets.
22+2. The deploy job in a workflow says `environment: production`.
23+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.
24+4. When the job finishes, the deployment gets a final status (`success`, `failure` or `inactive`), and webhooks receive `deployment_status` events.
25+
26+There's no web page for environments or deployments yet. You manage them 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`.
27+
28+## Create an environment
29+
30+An environment is created in one of three ways:
31+
32+- **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.
33+- **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.
34+- **By creating a deployment** with the REST API for an environment that doesn't exist yet.
35+
36+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.
37+
38+### Environment names
39+
40+- 1 to 255 characters, with no control characters, `/` or `\`. Anything else fails with `422 invalid environment name`.
41+- Names are case-insensitive: `Production` and `production` are the same environment.
42+- URL-encode the name in API paths, for example `environments/QA%20cluster`.
43+
44+### Create one with the API
45+
46+An empty body creates the environment with no branch policy:
47+
48+```sh
49+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/staging \
50+ -H "Authorization: Bearer dfy_..."
51+```
52+
53+Create one that only protected branches may use:
54+
55+```sh
56+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \
57+ -H "Authorization: Bearer dfy_..." \
58+ -H "Content-Type: application/json" \
59+ -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}'
60+```
61+
62+The response (`200 OK`) is the environment:
63+
64+```json
65+{
66+ "id": 1,
67+ "node_id": "RW52aXJvbm1lbnQ6YWNtZS93ZWJzaXRlLzE",
68+ "name": "production",
69+ "url": "https://diffinery.com/api/v3/repos/acme/website/environments/production",
70+ "html_url": "https://diffinery.com/acme/website/deployments/activity_log?environments_filter=production",
71+ "created_at": "2026-10-13T09:00:00Z",
72+ "updated_at": "2026-10-13T09:00:00Z",
73+ "can_admins_bypass": true,
74+ "protection_rules": [{ "id": 1, "node_id": "QnJhbmNoUG9saWN5OjE", "type": "branch_policy" }],
75+ "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
76+}
77+```
78+
79+Notes on the fields:
80+
81+- `protection_rules` holds one `branch_policy` entry when the environment has a branch policy, and is empty otherwise. Required reviewers and wait timers don't exist (see [What isn't supported](#what-isnt-supported)).
82+- `can_admins_bypass` is always `true` for compatibility, but nobody bypasses the branch policy: it applies to every run, whoever started it.
83+- `html_url` is returned for compatibility. Diffinery has no web page for environments yet, so it doesn't open anything useful.
84+
85+`PUT` on an existing environment updates it. Fields you leave out keep their values. The body accepts:
86+
87+| Field | Effect |
88+|---|---|
89+| `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. |
90+| `wait_timer` | Must be `0` or absent. A positive value fails with `422 required reviewers and wait timers are not supported yet`. |
91+| `reviewers` | Must be empty, `null` or absent. A non-empty list fails with the same error. |
92+| `prevent_self_review` | Accepted and ignored. |
93+
94+### List, get and delete environments
95+
96+```sh
97+# Every environment, sorted by name (read access)
98+curl https://diffinery.com/api/v3/repos/acme/website/environments \
99+ -H "Authorization: Bearer dfy_..."
100+
101+# One environment (read access)
102+curl https://diffinery.com/api/v3/repos/acme/website/environments/production \
103+ -H "Authorization: Bearer dfy_..."
104+
105+# Delete it, with its secrets and variables (admin, people only)
106+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/staging \
107+ -H "Authorization: Bearer dfy_..."
108+```
109+
110+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`.
111+
112+## Deployment branch policies
113+
114+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:
115+
116+```text
117+feature/login is not allowed to deploy to production due to environment protection rules.
118+```
119+
120+The policy has three settings:
121+
122+| Setting | API value | Which refs may deploy |
123+|---|---|---|
124+| No policy | `"deployment_branch_policy": null` | Every branch, tag and pull request ref. |
125+| Protected branches only | `{"protected_branches": true, "custom_branch_policies": false}` | Branches that a branch protection rule matches. Tags and pull request refs never. |
126+| 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. |
127+
128+Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`.
129+
130+The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it.
131+
132+### Protected branches only
133+
134+A branch counts as protected when one of the repository's branch protection rules matches its name (see [Branch protection](/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`.
135+
136+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 started by agents receive environment secrets.
137+
138+### Selected branches and tags
139+
140+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.
141+
142+Add a pattern (admin, people only):
143+
144+```sh
145+curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \
146+ -H "Authorization: Bearer dfy_..." \
147+ -H "Content-Type: application/json" \
148+ -d '{"name": "release/*", "type": "branch"}'
149+```
150+
151+```json
152+{ "id": 1, "name": "release/*", "type": "branch", "node_id": "QnJhbmNoUG9saWN5OjEvMQ" }
153+```
154+
155+`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`.
156+
157+List and delete patterns:
158+
159+```sh
160+curl https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies \
161+ -H "Authorization: Bearer dfy_..."
162+
163+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/deployment-branch-policies/1 \
164+ -H "Authorization: Bearer dfy_..."
165+```
166+
167+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.
168+
169+Pattern syntax:
170+
171+| Pattern | Matches |
172+|---|---|
173+| `*` | Any characters except `/` |
174+| `**` | Any characters, including `/` |
175+| `?` | One character other than `/` |
176+| Anything else | Itself, case-sensitively |
177+
178+Examples:
179+
180+| Pattern | Type | Matches | Doesn't match |
181+|---|---|---|---|
182+| `main` | branch | `main` | `main-2`, `Main` |
183+| `release/*` | branch | `release/1.4` | `release/1.4/hotfix`, `release` |
184+| `release/**` | branch | `release/1.4`, `release/1.4/hotfix` | `releases/1.4` |
185+| `v*` | tag | `v1.4.0`, `v2` | `1.4.0` |
186+| `v?.*` | tag | `v1.4`, `v2.0` | `v10.0` |
187+
188+> **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.
189+
190+### The default policy when the first secret is added
191+
192+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.
193+
194+## Environment secrets and variables
195+
196+Environment secrets and variables work like repository ones, but only jobs that use the environment receive them. They are managed with the REST API. Secret values are encrypted at rest with a key specific to the repository and the environment.
197+
198+### Environment secrets
199+
200+Setting and deleting environment secrets needs a person (not an agent) with the admin role on the repository, and a token with the `admin` scope. The environment must exist.
201+
202+```sh
203+# Create or replace a secret: 201 Created when new, 204 No Content when replaced
204+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
205+ -H "Authorization: Bearer dfy_..." \
206+ -H "Content-Type: application/json" \
207+ -d '{"value": "cf_..."}'
208+
209+# List names (values are never returned)
210+curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets \
211+ -H "Authorization: Bearer dfy_..."
212+
213+# One secret's metadata
214+curl https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
215+ -H "Authorization: Bearer dfy_..."
216+
217+# Delete (204 No Content)
218+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
219+ -H "Authorization: Bearer dfy_..."
220+```
221+
222+The list returns `{"total_count": 1, "secrets": [{"name": "CLOUDFLARE_API_TOKEN", "created_at": "...", "updated_at": "..."}]}`; listing and reading need the admin role.
223+
224+Rules:
225+
226+- 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.
227+- 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.
228+- 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)).
229+- Agents can't set or delete them, whatever their role (`403 agents cannot manage secrets` for an agent with the admin role).
230+
231+### Environment variables
232+
233+| Request | Needs | Does |
234+|---|---|---|
235+| `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). |
236+| `GET /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | write role | Returns one variable, or `404 Not Found`. |
237+| `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`. |
238+| `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`). |
239+| `DELETE /api/v3/repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin role, people only | Deletes a variable (`204`). |
240+
241+```sh
242+curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \
243+ -H "Authorization: Bearer dfy_..." \
244+ -H "Content-Type: application/json" \
245+ -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}'
246+```
247+
248+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).
249+
250+### Who receives environment secrets
251+
252+A job that uses an environment receives its secrets only when all of these hold:
253+
254+1. The environment's branch policy allows the job's ref (otherwise the job fails, see [Deployment branch policies](#deployment-branch-policies)).
255+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.
256+3. If the run was started by an agent (for example by an agent's push or workflow dispatch), the environment is set to **protected branches only**. With no policy or with custom patterns, runs started by agents get no environment secrets.
257+
258+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.
259+
260+| Run | No policy | Protected branches only | Selected branches and tags |
261+|---|---|---|---|
262+| Started by a person, on an allowed branch or tag | Secrets | Secrets | Secrets |
263+| Started by an agent, on an allowed branch | No secrets | Secrets | No secrets |
264+| `pull_request` run (`refs/pull/<n>/merge`) | Job runs, no secrets | Job fails (ref not allowed) | Job fails (ref not allowed) |
265+| Ref not allowed by the policy | Not applicable | Job fails | Job fails |
266+
267+`pull_request_target` runs use the base branch as their ref, so they're treated like a run on that branch.
268+
269+### Precedence
270+
271+In a job that uses an environment:
272+
273+- 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).
274+- An environment variable replaces a repository variable with the same name.
275+- `GITHUB_TOKEN` and `DIFFINERY_TOKEN` are always the job's own token.
276+
277+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](/docs/actions/secrets-and-variables) for masking, names and repository secrets.
278+
279+## Use an environment in a job
280+
281+### Syntax
282+
283+Name the environment with a string:
284+
285+```yaml
286+jobs:
287+ deploy:
288+ runs-on: ubuntu-latest
289+ environment: production
290+ steps:
291+ - run: ./deploy.sh
292+```
293+
294+Or with a mapping that also gives the URL of the deployed site:
295+
296+```yaml
297+jobs:
298+ deploy:
299+ runs-on: ubuntu-latest
300+ environment:
301+ name: production
302+ url: https://www.example.com
303+ steps:
304+ - run: ./deploy.sh
305+```
306+
307+| Key | Description |
308+|---|---|
309+| `name` | The environment's name (required in the mapping form). Expressions are allowed. |
310+| `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. |
311+
312+Both values can use the `github`, `inputs`, `vars` and `matrix` contexts. Other contexts evaluate to empty:
313+
314+- A `name` that evaluates to an empty string fails the job with `invalid environment name ''`.
315+- 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` or `matrix` instead, or post a deployment status yourself (see [Deployment statuses](#deployment-statuses)).
316+
317+```yaml
318+jobs:
319+ deploy:
320+ runs-on: ubuntu-latest
321+ strategy:
322+ matrix:
323+ target: [staging, production]
324+ environment:
325+ name: ${{ matrix.target }}
326+ url: https://${{ matrix.target }}.example.com
327+ steps:
328+ - run: ./deploy.sh "${{ matrix.target }}"
329+```
330+
331+`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)).
332+
333+### What happens when the job starts
334+
335+When a hosted or self-hosted runner starts a job with `environment:`, Diffinery:
336+
337+1. Evaluates `name` and `url`.
338+2. Creates the environment if it doesn't exist (with no branch policy).
339+3. Checks the branch policy against the run's ref, and fails the job if the ref isn't allowed.
340+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, and `production_environment` is `true` when the environment is named `production`.
341+5. 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`.
342+6. Passes the environment's variables to the job, and its secrets when [the rules](#who-receives-environment-secrets) allow it.
343+
344+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).
345+
346+### Automatic deployment statuses
347+
348+When the job finishes, its deployment gets a final status with the same `log_url` and `environment_url`:
349+
350+| Job conclusion | Deployment status |
351+|---|---|
352+| `success` | `success` |
353+| `cancelled` or `skipped` | `inactive` |
354+| Anything else (`failure`, timed out) | `failure` |
355+
356+A `success` status also marks earlier successful deployments to the same environment as `inactive`, unless they are transient or production deployments. See [auto_inactive](#deployment-statuses).
357+
358+### Reusable workflows
359+
360+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`:
361+
362+```yaml
363+# .github/workflows/deploy.yml
364+on:
365+ workflow_call:
366+ inputs:
367+ target:
368+ type: string
369+ required: true
370+
371+jobs:
372+ deploy:
373+ runs-on: ubuntu-latest
374+ environment:
375+ name: ${{ inputs.target }}
376+ url: https://${{ inputs.target }}.example.com
377+ steps:
378+ - uses: actions/checkout@v4
379+ - run: npx wrangler deploy --env "${{ inputs.target }}"
380+ env:
381+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
382+```
383+
384+```yaml
385+# .github/workflows/release.yml
386+on:
387+ push:
388+ branches: [main]
389+
390+jobs:
391+ staging:
392+ uses: ./.github/workflows/deploy.yml
393+ with:
394+ target: staging
395+ production:
396+ needs: staging
397+ uses: ./.github/workflows/deploy.yml
398+ with:
399+ target: production
400+```
401+
402+Here `CLOUDFLARE_API_TOKEN` can be a different environment secret in `staging` and in `production`. See [Reusable workflows](/docs/actions/reusable-workflows).
403+
404+### Choosing an environment when you run a workflow manually
405+
406+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:
407+
408+```yaml
409+on:
410+ workflow_dispatch:
411+ inputs:
412+ target:
413+ type: environment
414+ required: true
415+
416+jobs:
417+ deploy:
418+ runs-on: ubuntu-latest
419+ environment: ${{ inputs.target }}
420+ steps:
421+ - run: ./deploy.sh
422+```
423+
424+Because a dispatched run can name any environment, the environment's branch policy is what keeps a branch from deploying where it shouldn't.
425+
426+### Example: staging on main, production on version tags
427+
428+1. Set up the environments (as a repository admin):
429+
430+ ```sh
431+ api=https://diffinery.com/api/v3/repos/acme/website
432+ auth="Authorization: Bearer dfy_..."
433+
434+ # staging: protected branches only (add a branch protection rule for main first)
435+ curl -X PUT "$api/environments/staging" -H "$auth" -H "Content-Type: application/json" \
436+ -d '{"deployment_branch_policy": {"protected_branches": true, "custom_branch_policies": false}}'
437+
438+ # production: only tags that match v*
439+ curl -X PUT "$api/environments/production" -H "$auth" -H "Content-Type: application/json" \
440+ -d '{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}'
441+ curl -X POST "$api/environments/production/deployment-branch-policies" -H "$auth" \
442+ -H "Content-Type: application/json" -d '{"name": "v*", "type": "tag"}'
443+
444+ # A different token for each environment
445+ curl -X PUT "$api/environments/staging/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \
446+ -H "Content-Type: application/json" -d '{"value": "cf_staging_..."}'
447+ curl -X PUT "$api/environments/production/secrets/CLOUDFLARE_API_TOKEN" -H "$auth" \
448+ -H "Content-Type: application/json" -d '{"value": "cf_production_..."}'
449+ ```
450+
451+2. Write the workflow:
452+
453+ ```yaml
454+ name: Deploy
455+ on:
456+ push:
457+ branches: [main]
458+ tags: ["v*"]
459+
460+ jobs:
461+ staging:
462+ if: github.ref == 'refs/heads/main'
463+ runs-on: ubuntu-latest
464+ environment:
465+ name: staging
466+ url: https://staging.example.com
467+ steps:
468+ - uses: actions/checkout@v4
469+ - run: npm ci && npm run build
470+ - run: npx wrangler deploy --env staging
471+ env:
472+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
473+
474+ production:
475+ if: startsWith(github.ref, 'refs/tags/v')
476+ runs-on: ubuntu-latest
477+ environment:
478+ name: production
479+ url: https://www.example.com
480+ steps:
481+ - uses: actions/checkout@v4
482+ - run: npm ci && npm run build
483+ - run: npx wrangler deploy --env production
484+ env:
485+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
486+ ```
487+
488+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.
489+
490+## Deployments API
491+
492+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.
493+
494+A workflow's `GITHUB_TOKEN` is read-only, so a step can't create deployments with it (`403 write access required`). Use `environment:` on the job, which records the deployment for you, or a personal token stored as a secret.
495+
496+### Create a deployment
497+
498+```sh
499+curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments \
500+ -H "Authorization: Bearer dfy_..." \
501+ -H "Content-Type: application/json" \
502+ -d '{"ref": "main", "environment": "production", "description": "Deploy 1.4.0", "required_contexts": ["ci/build"]}'
503+```
504+
505+| Field | Default | Description |
506+|---|---|---|
507+| `ref` | required | A branch, tag or commit SHA. Missing: `422 ref is required`. Unknown: `422 no ref found for: <ref>`. |
508+| `environment` | `production` | The environment's name. It's created if it doesn't exist. |
509+| `task` | `deploy` | Free text. Only the first 100 characters are kept. |
510+| `payload` | `{}` | Any JSON value, or a string. Returned as it was sent. |
511+| `description` | `null` | Free text. Only the first 1,000 characters are kept. |
512+| `required_contexts` | every context | See [Required contexts](#required-contexts). Up to 100 strings. |
513+| `transient_environment` | `false` | Marks the environment as temporary (it's skipped by `auto_inactive`). |
514+| `production_environment` | `true` when `environment` is `production` | Marks a production deployment (it's skipped by `auto_inactive`). |
515+| `auto_merge` | | Accepted and ignored. Diffinery never merges the default branch into `ref`. |
516+
517+The response is `201 Created` with the deployment (some user fields left out here):
518+
519+```json
520+{
521+ "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7",
522+ "id": 7,
523+ "node_id": "RGVwbG95bWVudDphY21lL3dlYnNpdGUvNw",
524+ "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8",
525+ "ref": "main",
526+ "task": "deploy",
527+ "payload": {},
528+ "original_environment": "production",
529+ "environment": "production",
530+ "description": "Deploy 1.4.0",
531+ "creator": { "login": "octocat", "type": "User" },
532+ "created_at": "2026-10-13T09:30:00Z",
533+ "updated_at": "2026-10-13T09:30:00Z",
534+ "statuses_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses",
535+ "repository_url": "https://diffinery.com/api/v3/repos/acme/website",
536+ "transient_environment": false,
537+ "production_environment": true,
538+ "performed_via_github_app": null
539+}
540+```
541+
542+### Required contexts
543+
544+Before creating a deployment, Diffinery checks the commit's checks and commit statuses, as GitHub does:
545+
546+- **`required_contexts` left out:** every check and status context reported on the commit must have passed. Contexts reported only by agents aren't counted.
547+- **A list of names:** each named context must have passed. A name that nothing reported counts as not passed.
548+- **`[]`:** nothing is checked.
549+
550+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](/docs/collaboration/checks-and-statuses).
551+
552+If a context hasn't passed, the request fails with `409`:
553+
554+```json
555+{
556+ "message": "Conflict: Commit status checks failed for main.",
557+ "errors": [
558+ { "contexts": [{ "context": "ci/build", "state": "pending" }], "resource": "Deployment", "field": "required_contexts" }
559+ ],
560+ "documentation_url": "https://docs.github.com/rest"
561+}
562+```
563+
564+`state` is `failure`, `pending` or `missing`. Wait for the checks to finish, or pass `required_contexts` with only the contexts you care about.
565+
566+Deployments that workflow jobs create for `environment:` skip this check.
567+
568+### List, get and delete deployments
569+
570+```sh
571+# Newest first; filter by sha, ref, task or environment (exact match)
572+curl "https://diffinery.com/api/v3/repos/acme/website/deployments?environment=production&per_page=10" \
573+ -H "Authorization: Bearer dfy_..."
574+
575+curl https://diffinery.com/api/v3/repos/acme/website/deployments/7 \
576+ -H "Authorization: Bearer dfy_..."
577+
578+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/deployments/7 \
579+ -H "Authorization: Bearer dfy_..."
580+```
581+
582+- The list takes `per_page` (default 30, at most 100) and `page`, and sends a `Link` header.
583+- 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`.
584+- A missing deployment returns `404 deployment not found`.
585+- 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.
586+
587+### Deployment statuses
588+
589+```sh
590+curl -X POST https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses \
591+ -H "Authorization: Bearer dfy_..." \
592+ -H "Content-Type: application/json" \
593+ -d '{"state": "success", "log_url": "https://ci.example.com/builds/812", "environment_url": "https://www.example.com", "description": "Deployed"}'
594+```
595+
596+| Field | Description |
597+|---|---|
598+| `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`. |
599+| `log_url` | Link to the deployment's output. `target_url` is accepted as an older name for it. |
600+| `environment_url` | Link to the deployed environment. |
601+| `description` | Up to 140 characters; longer text is cut. |
602+| `environment` | Moves the deployment to another environment (created if needed). The deployment's `original_environment` keeps the first name. |
603+| `auto_inactive` | Default `true`. With a `success` state, other deployments to the same environment whose latest status is `success` get an `inactive` status, except transient and production deployments. Send `false` to keep them. |
604+
605+URLs must start with `http://` or `https://` (`422 URLs must be http(s)`). The response is `201 Created`:
606+
607+```json
608+{
609+ "url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7/statuses/15",
610+ "id": 15,
611+ "node_id": "RGVwbG95bWVudFN0YXR1czphY21lL3dlYnNpdGUvMTU",
612+ "state": "success",
613+ "creator": { "login": "octocat", "type": "User" },
614+ "description": "Deployed",
615+ "environment": "production",
616+ "target_url": "https://ci.example.com/builds/812",
617+ "log_url": "https://ci.example.com/builds/812",
618+ "environment_url": "https://www.example.com",
619+ "created_at": "2026-10-13T09:34:10Z",
620+ "updated_at": "2026-10-13T09:34:10Z",
621+ "deployment_url": "https://diffinery.com/api/v3/repos/acme/website/deployments/7",
622+ "repository_url": "https://diffinery.com/api/v3/repos/acme/website",
623+ "performed_via_github_app": null
624+}
625+```
626+
627+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).
628+
629+## Endpoint summary
630+
631+All paths are under `https://diffinery.com/api/v3`. Every endpoint is also in the [API reference](/docs/api).
632+
633+| Method and path | Needs |
634+|---|---|
635+| `GET /repos/{owner}/{repo}/deployments` | read |
636+| `POST /repos/{owner}/{repo}/deployments` | write |
637+| `GET /repos/{owner}/{repo}/deployments/{id}` | read |
638+| `DELETE /repos/{owner}/{repo}/deployments/{id}` | write |
639+| `GET /repos/{owner}/{repo}/deployments/{id}/statuses` | read |
640+| `POST /repos/{owner}/{repo}/deployments/{id}/statuses` | write |
641+| `GET /repos/{owner}/{repo}/deployments/{id}/statuses/{status_id}` | read |
642+| `GET /repos/{owner}/{repo}/environments` | read |
643+| `GET /repos/{owner}/{repo}/environments/{env}` | read |
644+| `PUT /repos/{owner}/{repo}/environments/{env}` | admin, people only |
645+| `DELETE /repos/{owner}/{repo}/environments/{env}` | admin, people only |
646+| `GET /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | read |
647+| `POST /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies` | admin, people only |
648+| `DELETE /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies/{id}` | admin, people only |
649+| `GET /repos/{owner}/{repo}/environments/{env}/secrets` and `.../secrets/{name}` | admin |
650+| `PUT` and `DELETE /repos/{owner}/{repo}/environments/{env}/secrets/{name}` | admin, people only |
651+| `GET /repos/{owner}/{repo}/environments/{env}/variables` and `.../variables/{name}` | write |
652+| `POST /repos/{owner}/{repo}/environments/{env}/variables` | admin, people only |
653+| `PATCH` and `DELETE /repos/{owner}/{repo}/environments/{env}/variables/{name}` | admin, people only |
654+
655+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)`.
656+
657+## Webhooks
658+
659+Repository webhooks receive two 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` and `deployment_status` in the webhook's `events`. See [Webhooks](/docs/repositories/webhooks).
660+
661+### deployment
662+
663+Sent when a deployment is created with the REST API:
664+
665+```json
666+{
667+ "action": "created",
668+ "deployment": {
669+ "id": 7,
670+ "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8",
671+ "ref": "main",
672+ "task": "deploy",
673+ "environment": "production",
674+ "description": "Deploy 1.4.0",
675+ "payload": {},
676+ "created_at": "2026-10-13T09:30:00.000Z"
677+ },
678+ "repository": { "full_name": "acme/website" },
679+ "sender": { "login": "octocat", "type": "User" }
680+}
681+```
682+
683+Deployments that workflow jobs create for `environment:` don't send a `deployment` event; follow them with `deployment_status`.
684+
685+### deployment_status
686+
687+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):
688+
689+```json
690+{
691+ "action": "created",
692+ "deployment_status": {
693+ "id": 15,
694+ "state": "success",
695+ "description": "",
696+ "environment": "production",
697+ "environment_url": "https://www.example.com",
698+ "log_url": "https://diffinery.com/acme/website/actions/runs/01J9Z.../job/01J9Z...",
699+ "created_at": "2026-10-13T09:34:10.000Z"
700+ },
701+ "deployment": { "id": 7, "sha": "9fceb02d0ae598e95dc970b74767f19372d61af8", "ref": "main", "task": "deploy", "environment": "production" },
702+ "repository": { "full_name": "acme/website" },
703+ "sender": { "login": "octocat", "type": "User" }
704+}
705+```
706+
707+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](/docs/reference/webhook-events).
708+
709+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.
710+
711+## Agents and environments
712+
713+Environments guard deployment credentials, so agents are limited:
714+
715+- 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`.
716+- Agents can't set or delete environment secrets: an agent with the admin role gets `403 agents cannot manage secrets`.
717+- Agents can't create, change or delete environment variables: an agent with the admin role gets `403 agents cannot manage variables`.
718+- Runs started by agents 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)).
719+- Tags that an agent created or moved never match an environment's tag patterns.
720+- Agents with write access can create deployments and deployment statuses with the API (with a `write` token), and can read environments and environment variables.
721+
722+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. See [Agents](/docs/agents/overview).
723+
724+## Audit log
725+
726+Changes to environments made with the API are recorded in the audit log, without secret values:
727+
728+| Action | When |
729+|---|---|
730+| `environment.update` | An environment is created or updated with `PUT` (with the new `deployment_branch_policy`). |
731+| `environment.delete` | An environment is deleted. |
732+| `environment.branch_policy.create` | A branch or tag pattern is added. |
733+| `environment.branch_policy.delete` | A pattern is removed. |
734+| `environment.secret.set` | An environment secret is created or replaced (name only). |
735+| `environment.secret.delete` | An environment secret is deleted. |
736+
737+Environment variable changes aren't recorded. See [Audit log](/docs/security/audit-log).
738+
739+## What isn't supported
740+
741+- **Protection rules other than branch policies.** Required reviewers, wait timers and custom deployment protection rules don't exist. Setting `reviewers` or `wait_timer` fails with `422 required reviewers and wait timers are not supported yet`, so a job never waits for approval.
742+- **A web UI.** Environments, their secrets and variables, and deployments are managed with the REST API only. The web UI shows no deployments.
743+- **Reading or updating a single branch pattern.** You can list, add and delete patterns, but not `GET` or `PUT` one by id.
744+- **Sealed-box secrets.** Send `value`, not `encrypted_value`.
745+- **Organization-level environments and environment secrets.** Environments belong to one repository.
746+- **OIDC environment claims.** OIDC tokens for jobs with `environment:` have no `environment` claim, and their `sub` stays `repo:<owner>/<repo>:ref:<ref>`. See [OpenID Connect (OIDC)](/docs/actions/oidc).
747+- **`on: deployment` and `on: deployment_status` workflow triggers.** Accepted but never started.
748+
749+## Differences from GitHub
750+
751+| Area | GitHub | Diffinery |
752+|---|---|---|
753+| Managing environments | Web UI and API | API only |
754+| Required reviewers, wait timers | Supported | Refused with `422` |
755+| Admin bypass | Admins can bypass protection rules | Nobody bypasses the branch policy; `can_admins_bypass` is always `true` but has no effect |
756+| Default branch policy | All branches | All refs, until the first secret is added; then protected branches only |
757+| Protected branches | Branches with protection rules | Branches matched by a branch protection rule; the default branch counts only when a rule matches it |
758+| Pull request runs | Fork pull requests get no secrets | No `pull_request` run gets environment secrets |
759+| Agents | No equivalent | Agents can't manage environments, get environment secrets only under "protected branches only", and their tags never match tag patterns |
760+| `environment.url` | Can use `steps` outputs | `steps` references are ignored |
761+| Jobs with `environment:` | Send `deployment` and `deployment_status` events | Send `deployment_status` events only |
762+| Deployment `auto_merge` | Merges the default branch into the ref | Ignored |
763+| Branch patterns | `fnmatch` syntax with character classes | `*`, `**` and `?` only, unlike workflow filters |
764+| OIDC | `environment` claim and an environment-based `sub` | No `environment` claim |
765+
766+## Troubleshooting
767+
768+| Problem | Cause and fix |
769+|---|---|
770+| `<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. |
771+| 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. |
772+| `invalid environment name ''` | `environment.name` evaluated to an empty string, for example because it uses `needs` or `steps`. Use `inputs`, `vars`, `matrix` or a literal. |
773+| 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 name is wrong. Variables are still set. |
774+| 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. |
775+| `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. |
776+| `403 agents cannot manage secrets` | Use a person's token. |
777+| `403 agents cannot manage variables` | Agents can't change environment variables. Use a person's token. |
778+| `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. |
779+| `422 required reviewers and wait timers are not supported yet` | Remove `reviewers` and `wait_timer` from the request. Tools that copy GitHub environments often send them. |
780+| `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. |
781+| `422 enable custom_branch_policies on the environment first` | Set `custom_branch_policies: true` with `PUT` before adding patterns. |
782+| `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). |
783+| `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. |
784+| `403 write access required` when a workflow step creates a deployment | `GITHUB_TOKEN` is read-only. Use `environment:` on the job, or a personal token stored as a secret. |
785+| `403 admin access required` | Managing environments, branch patterns, environment secrets and variable changes needs the admin role and an `admin`-scoped token. |
786+| The deployment has no environment URL | `environment.url` used `steps`, or wasn't an `http(s)` URL. |
addedapps/diffinery/docs/actions/events.md+658−0View file
@@ -1,0 +1,658 @@
1+---
2+title: Events that trigger workflows
3+description: 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.
4+---
5+
6+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.
7+
8+## Supported events
9+
10+| Event | Fires when | Activity types | Filters |
11+| --- | --- | --- | --- |
12+| [`push`](#push) | A branch or tag is created or updated | | `branches`, `branches-ignore`, `tags`, `tags-ignore`, `paths`, `paths-ignore` |
13+| [`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` |
14+| [`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` |
15+| [`merge_group`](#merge_group) | The merge queue builds a merge group | `checks_requested` | `types`, `branches`, `branches-ignore` |
16+| [`workflow_dispatch`](#workflow_dispatch) | Someone starts the workflow from the UI, CLI, API or MCP | | `inputs` |
17+| [`schedule`](#schedule) | A cron schedule is due | | `cron` |
18+| [`workflow_run`](#workflow_run) | A run of another named workflow is requested or completes | `requested`, `completed` | `workflows`, `types`, `branches`, `branches-ignore` |
19+| [`release`](#release) | A release is published, unpublished, edited or deleted | `published`, `unpublished`, `created`, `edited`, `deleted`, `prereleased`, `released` | `types` |
20+| [`repository_dispatch`](#repository_dispatch) | Someone calls `POST /repos/{owner}/{repo}/dispatches` | Any `event_type` you choose | `types` |
21+| [`workflow_call`](#workflow_call) | Another workflow calls this one as a reusable workflow | | `inputs`, `outputs`, `secrets` |
22+
23+### Other GitHub events
24+
25+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`.
26+
27+## Writing on
28+
29+```yaml
30+on: push # one event, no configuration
31+```
32+
33+```yaml
34+on: [push, pull_request] # several events, no configuration
35+```
36+
37+```yaml
38+on: # events with configuration
39+ push:
40+ branches: [main]
41+ tags: ["v*"]
42+ pull_request:
43+ types: [opened, synchronize, reopened, closed]
44+ paths: ["src/**"]
45+ workflow_dispatch:
46+```
47+
48+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.
49+
50+## Filter patterns
51+
52+Branch, tag and path filters use GitHub's filter pattern syntax:
53+
54+| Pattern | Matches |
55+| --- | --- |
56+| `*` | Any characters except `/`. `feature/*` matches `feature/login` but not `feature/a/b`. |
57+| `**` | Any characters, including `/`. `feature/**` matches `feature/a/b`. |
58+| `**/` at the start | Any directories, or none. `**/README.md` matches `README.md` and `docs/README.md`. |
59+| `?` | Zero or one of the preceding character. `v1?` matches `v` and `v1`. |
60+| `+` | One or more of the preceding character. `v1+` matches `v1` and `v111`. |
61+| `[abc]`, `[0-9]` | One character from the set or range. |
62+| `\` | Escapes the next character, for example `\*` for a literal `*`. |
63+| `!pattern` | In a list, excludes what earlier patterns matched. |
64+
65+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.
66+
67+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.
68+
69+```yaml
70+on:
71+ push:
72+ branches:
73+ - "releases/**"
74+ - "!releases/**-alpha"
75+```
76+
77+This runs for `releases/10` and `releases/beta/mona`, but not `releases/10-alpha`.
78+
79+### Include and ignore filters
80+
81+- `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`.
82+- 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.
83+
84+### Path filters
85+
86+- With `paths`, the workflow runs when at least one changed file matches the list.
87+- With `paths-ignore`, the workflow runs unless every changed file matches the list.
88+- 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.)
89+- 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.
90+- Path filters are never evaluated for tag pushes.
91+- When branch or tag filters and path filters are both set, both must pass.
92+
93+## push
94+
95+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.
96+
97+```yaml
98+on:
99+ push:
100+ branches: [main, "release/**"]
101+ tags: ["v*.*.*"]
102+ paths-ignore: ["docs/**", "**.md"]
103+```
104+
105+How `branches` and `tags` combine, as on GitHub:
106+
107+| Filters set | Branch pushes | Tag pushes |
108+| --- | --- | --- |
109+| None | Run | Run |
110+| Only `branches` or `branches-ignore` | Run when the branch matches | Don't run |
111+| Only `tags` or `tags-ignore` | Don't run | Run when the tag matches |
112+| Both kinds | Run when the branch matches | Run when the tag matches |
113+| Only `paths` or `paths-ignore` | Run when the paths match | Run (paths aren't checked for tags) |
114+
115+The workflow file is read from the pushed commit, so a push that changes a workflow runs the new version.
116+
117+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.
118+
119+| Variable | Value |
120+| --- | --- |
121+| `GITHUB_SHA` | The pushed commit |
122+| `GITHUB_REF` | The pushed ref, `refs/heads/<branch>` or `refs/tags/<tag>` |
123+
124+Payload (`github.event`):
125+
126+| Field | Value |
127+| --- | --- |
128+| `ref` | The pushed ref |
129+| `before` | The commit the ref pointed to before the push (`0000000000000000000000000000000000000000` for a new ref) |
130+| `after` | The pushed commit |
131+| `created`, `deleted`, `forced` | Whether the push created the ref, deleted it (always `false` for runs) or was a force push |
132+| `head_commit` | `id`, `message`, `timestamp` (committer time) and `author` (`name`, `email`) of the pushed commit |
133+| `commits` | A list with the pushed commit only (`id`, `message`). GitHub lists up to 20 commits. |
134+| `pusher` | `name`: the login of the account that pushed |
135+| `sender` | `login`, and `type`: `Bot` for agents, `User` otherwise |
136+| `repository` | See [The repository object](#the-repository-object) |
137+
138+## pull_request
139+
140+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.
141+
142+```yaml
143+on:
144+ pull_request:
145+ types: [opened, synchronize, reopened]
146+ branches: [main]
147+ paths: ["src/**", "package.json"]
148+```
149+
150+| Type | Fires when |
151+| --- | --- |
152+| `opened` | A pull request is created (drafts included) |
153+| `synchronize` | New commits are pushed to the head branch |
154+| `reopened` | A closed pull request is reopened |
155+| `closed` | A pull request is closed or merged. Check `github.event.pull_request.merged` to tell them apart. |
156+| `edited` | The title or description changed. Not for label changes, and not when the text saved is the same. |
157+| `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` |
158+| `converted_to_draft` | An open pull request is turned back into a draft: `diffinery pr ready --undo`, or `PATCH` with `"draft": true` |
159+
160+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.
161+
162+A common use is to skip expensive jobs while a pull request is a draft and run them once it's ready:
163+
164+```yaml
165+on:
166+ pull_request:
167+ types: [opened, synchronize, reopened, ready_for_review]
168+jobs:
169+ e2e:
170+ if: ${{ !github.event.pull_request.draft }}
171+ runs-on: ubuntu-latest
172+ steps:
173+ - uses: actions/checkout@v4
174+ - run: npm ci && npm run e2e
175+```
176+
177+For `edited`, the payload has no `changes` object with the old title or body (GitHub sends one).
178+
179+`branches` and `branches-ignore` filter on the pull request's base branch.
180+
181+- 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.
182+- 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.
183+- For `closed` on a merged pull request, the run uses the merge commit instead.
184+- The run's checks are reported on the pull request's head commit, so they appear on the pull request.
185+- Every pull request comes from a branch of the same repository, so `pull_request` runs get the repository's secrets, like `push` runs.
186+
187+| Variable | Value |
188+| --- | --- |
189+| `GITHUB_SHA` | The test merge commit (for a merged pull request's `closed` event, the merge commit) |
190+| `GITHUB_REF` | `refs/pull/<n>/merge` |
191+| `GITHUB_REF_NAME` | `<n>/merge` |
192+| `GITHUB_HEAD_REF` | The head branch |
193+| `GITHUB_BASE_REF` | The base branch |
194+
195+Payload (`github.event`):
196+
197+| Field | Value |
198+| --- | --- |
199+| `action` | The activity type |
200+| `number` | The pull request number |
201+| `pull_request.number`, `pull_request.id` | The pull request number (both) |
202+| `pull_request.state` | `open` or `closed` |
203+| `pull_request.title`, `pull_request.body` | Title and description |
204+| `pull_request.draft`, `pull_request.merged` | Booleans |
205+| `pull_request.merged_at` | When it merged, or `null` |
206+| `pull_request.merged_by` | `{ "login": ... }` or `null` |
207+| `pull_request.merge_commit_sha` | The merge commit once merged, otherwise the test merge commit (or `null` while it conflicts) |
208+| `pull_request.user` | `{ "login": ... }` of the author |
209+| `pull_request.labels` | `[{ "name": ... }]` |
210+| `pull_request.assignees` | `[{ "login": ... }]` |
211+| `pull_request.head` | `ref` (branch), `sha` (head commit) and `repo` |
212+| `pull_request.base` | `ref` (branch), `sha` (base commit) and `repo` |
213+| `pull_request.html_url` | `https://diffinery.com/acme/website/pull/<n>` |
214+| `pull_request.url` | `https://diffinery.com/api/v3/repos/acme/website/pulls/<n>` |
215+| `repository` | See [The repository object](#the-repository-object) |
216+| `sender` | `{ "login": ... }` of whoever caused the event |
217+
218+## pull_request_target
219+
220+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.
221+
222+```yaml
223+on:
224+ pull_request_target:
225+ types: [opened, reopened]
226+jobs:
227+ welcome:
228+ runs-on: ubuntu-latest
229+ steps:
230+ - run: echo "Thanks for pull request #${{ github.event.number }}, ${{ github.event.pull_request.user.login }}"
231+```
232+
233+Unlike `pull_request`, it also runs while the pull request conflicts with its base. Checks are reported on the pull request's head commit.
234+
235+| Variable | Value |
236+| --- | --- |
237+| `GITHUB_SHA` | The base branch's current commit |
238+| `GITHUB_REF` | `refs/heads/<base branch>` |
239+| `GITHUB_HEAD_REF`, `GITHUB_BASE_REF` | The head and base branches |
240+
241+## merge_group
242+
243+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](/docs/collaboration/merge-queue).
244+
245+```yaml
246+on:
247+ pull_request:
248+ merge_group:
249+ types: [checks_requested]
250+ branches: [main]
251+```
252+
253+- The only activity type is `checks_requested`, which is also the default.
254+- `branches` and `branches-ignore` filter on the queue's base branch.
255+- 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.
256+- `push` workflows also run on the merge group branch; see [push](#push).
257+- A merge group commit with an invalid workflow file gets an `Invalid workflow file` run, like a push.
258+
259+| Variable | Value |
260+| --- | --- |
261+| `GITHUB_SHA` | The merge group commit |
262+| `GITHUB_REF` | `refs/heads/gh-readonly-queue/<base>/pr-<n>-<head sha>` |
263+
264+Payload (`github.event`):
265+
266+| Field | Value |
267+| --- | --- |
268+| `action` | `checks_requested` |
269+| `merge_group.head_sha` | The merge group commit |
270+| `merge_group.head_ref` | The merge group ref |
271+| `merge_group.base_sha` | The commit the group was built on |
272+| `merge_group.base_ref` | `refs/heads/<base>` |
273+| `merge_group.head_commit` | `id`, `tree_id`, `message`, `timestamp`, `author` and `committer` (`name`, `email`) |
274+| `repository`, `sender` | As for other events |
275+
276+The run's title is `Merge group for #<n>: <pull request title>`.
277+
278+## workflow_dispatch
279+
280+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](/docs/actions/overview#start-a-workflow-manually).
281+
282+```yaml
283+on:
284+ workflow_dispatch:
285+ inputs:
286+ environment:
287+ description: Target environment
288+ type: choice
289+ options: [staging, production]
290+ default: staging
291+ version:
292+ description: Version to deploy
293+ required: true
294+ replicas:
295+ type: number
296+ default: 2
297+ dry-run:
298+ type: boolean
299+ default: false
300+ target:
301+ type: environment
302+```
303+
304+Each input can have:
305+
306+| Key | Meaning |
307+| --- | --- |
308+| `description` | Label in the **Run workflow** form (the input's name is shown under it). |
309+| `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. |
310+| `default` | Used when no value is given. |
311+| `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)`. |
312+| `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>`. |
313+| `deprecationMessage` | Accepted and ignored. |
314+
315+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](/docs/actions/environments).
316+
317+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.
318+
319+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.
320+
321+| Variable | Value |
322+| --- | --- |
323+| `GITHUB_SHA` | The latest commit of the chosen ref |
324+| `GITHUB_REF` | `refs/heads/<branch>` or `refs/tags/<tag>` |
325+
326+Payload (`github.event`): `inputs`, `ref` (the full ref), `workflow` (the workflow file's path), `repository` and `sender`.
327+
328+Each accepted dispatch also sends a `workflow_dispatch` [webhook](/docs/reference/webhook-events#workflow_dispatch) event with the same `ref`, `workflow` and `inputs`, to webhooks subscribed to it.
329+
330+## schedule
331+
332+Runs the workflow on a schedule, in UTC.
333+
334+```yaml
335+on:
336+ schedule:
337+ - cron: "30 5 * * 1-5" # 05:30 UTC, Monday to Friday
338+ - cron: "0 */6 * * *" # every 6 hours
339+```
340+
341+`schedule` is a list of entries with a `cron` string. Each cron has 5 fields:
342+
343+| Field | Values |
344+| --- | --- |
345+| Minute | `0`-`59` |
346+| Hour | `0`-`23` |
347+| Day of month | `1`-`31` |
348+| Month | `1`-`12` or `JAN`-`DEC` |
349+| Day of week | `0`-`6` (Sunday is `0`; `7` also means Sunday) or `SUN`-`SAT` |
350+
351+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.
352+
353+How schedules run:
354+
355+- **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.
356+- **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.
357+- Each run uses the workflow file on the default branch at that moment, and only if it still has that exact cron string.
358+- Schedules of archived repositories don't run.
359+- The run's actor is `diffinery-scheduler`. Notifications for scheduled runs go to the repository's watchers.
360+
361+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`).
362+
363+| Variable | Value |
364+| --- | --- |
365+| `GITHUB_SHA` | The latest commit on the default branch |
366+| `GITHUB_REF` | `refs/heads/<default branch>` |
367+
368+Payload (`github.event`): `schedule` (the cron string) and `repository`.
369+
370+## workflow_run
371+
372+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.
373+
374+```yaml
375+name: Report
376+on:
377+ workflow_run:
378+ workflows: [CI]
379+ types: [completed]
380+ branches: [main]
381+jobs:
382+ report:
383+ if: ${{ github.event.workflow_run.conclusion == 'failure' }}
384+ runs-on: ubuntu-latest
385+ steps:
386+ - run: echo "CI failed on ${{ github.event.workflow_run.head_branch }}: ${{ github.event.workflow_run.html_url }}"
387+```
388+
389+- `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.
390+- `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.
391+- `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.
392+- Like on GitHub, the triggered workflow is read from the default branch and runs there, whatever branch the other run was on.
393+- Chains stop after three levels: if A triggers B, B triggers C and C triggers D, D's runs don't trigger anything.
394+
395+| Variable | Value |
396+| --- | --- |
397+| `GITHUB_SHA` | The latest commit on the default branch |
398+| `GITHUB_REF` | `refs/heads/<default branch>` |
399+
400+Payload (`github.event`):
401+
402+| Field | Value |
403+| --- | --- |
404+| `action` | `requested` or `completed` |
405+| `workflow_run.id` | The other run's ID |
406+| `workflow_run.name`, `workflow_run.path` | The other workflow's name and file path |
407+| `workflow_run.display_title` | The other run's title |
408+| `workflow_run.run_number`, `workflow_run.run_attempt` | Its number and attempt |
409+| `workflow_run.event` | The event that started it |
410+| `workflow_run.status`, `workflow_run.conclusion` | `queued` and `null` for `requested`; `completed` and the conclusion for `completed` |
411+| `workflow_run.head_branch`, `workflow_run.head_sha` | The branch and the commit it reported checks on |
412+| `workflow_run.actor`, `workflow_run.triggering_actor` | `{ "login": ... }` |
413+| `workflow_run.pull_requests` | `[{ "number": ... }]` for pull request runs, otherwise empty |
414+| `workflow_run.created_at`, `workflow_run.updated_at` | Timestamps |
415+| `workflow_run.html_url` | Link to the other run |
416+| `workflow` | `name` and `path` of the other workflow |
417+| `repository`, `sender` | As for other events |
418+
419+## release
420+
421+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](/docs/collaboration/releases).
422+
423+```yaml
424+name: Release
425+on:
426+ release:
427+ types: [published]
428+jobs:
429+ build:
430+ runs-on: ubuntu-latest
431+ steps:
432+ - uses: actions/checkout@v4
433+ - run: make dist VERSION="${{ github.event.release.tag_name }}"
434+```
435+
436+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.
437+
438+| Change | Activity types sent to workflows |
439+| --- | --- |
440+| Create a release that isn't a draft (**Publish release** on a new release) | `created`, `published`, then `released` (or `prereleased` for a pre-release) |
441+| Publish a draft (**Publish release** on a draft) | `published`, then `released` or `prereleased` |
442+| Turn a published release back into a draft (`"draft": true` in the API) | `unpublished` |
443+| Edit a published release (**Update release**) | `edited`, plus `released` or `prereleased` when the pre-release setting changed |
444+| Delete a published release | `deleted` |
445+| 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. |
446+
447+How a release run is set up:
448+
449+- 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.
450+- 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.
451+- The run's title is the release's name, or its tag name when the release has no name.
452+- The run's actor is whoever made the change. Its checks are reported on the tagged commit.
453+- `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.
454+- Invalid workflow files are skipped without an `Invalid workflow file` run.
455+
456+| Variable | Value |
457+| --- | --- |
458+| `GITHUB_SHA` | The commit the release's tag points to |
459+| `GITHUB_REF` | `refs/tags/<tag>` |
460+| `GITHUB_REF_NAME` | The tag name |
461+| `GITHUB_REF_TYPE` | `tag` |
462+
463+Payload (`github.event`):
464+
465+| Field | Value |
466+| --- | --- |
467+| `action` | The activity type |
468+| `release.id` | The release's ID, a number |
469+| `release.tag_name` | The tag, for example `v1.2.0` |
470+| `release.target_commitish` | The branch or commit the tag was created from |
471+| `release.name`, `release.body` | Title and release notes, or `null` when empty |
472+| `release.draft`, `release.prerelease` | Booleans |
473+| `release.latest` | Whether this is the repository's latest release (a Diffinery field) |
474+| `release.immutable` | Always `false` |
475+| `release.created_at`, `release.updated_at`, `release.published_at` | Timestamps (`published_at` is `null` for drafts) |
476+| `release.author` | The user object of whoever created the release (`login`, `id`, `type` and URLs) |
477+| `release.assets` | The release's assets, each with `id`, `name`, `label`, `content_type`, `size`, `download_count`, `browser_download_url` and `url` |
478+| `release.html_url` | `https://diffinery.com/acme/website/releases/tag/<tag>` |
479+| `release.url`, `release.assets_url`, `release.upload_url` | API URLs. Upload assets to `upload_url` (with the `{?name,label}` template removed). |
480+| `release.tarball_url`, `release.zipball_url` | Source archives of the tag |
481+| `release.node_id`, `release.reactions` | As on GitHub |
482+| `repository` | `id` (a number), `node_id`, `name`, `full_name`, `owner.login`, `default_branch`, `html_url` and `clone_url` |
483+| `sender` | `login`, and `type`: `User` for people, `Bot` otherwise |
484+
485+Uploading an asset needs a token with write access. The job's `GITHUB_TOKEN` is read-only, so store a personal or agent token as a secret for that:
486+
487+```yaml
488+on:
489+ release:
490+ types: [published]
491+jobs:
492+ upload:
493+ runs-on: ubuntu-latest
494+ steps:
495+ - uses: actions/checkout@v4
496+ - run: tar czf dist.tar.gz src
497+ - name: Attach dist.tar.gz to the release
498+ env:
499+ TOKEN: ${{ secrets.RELEASE_TOKEN }}
500+ UPLOAD_URL: ${{ github.event.release.upload_url }}
501+ run: |
502+ curl -fsS -X POST "${UPLOAD_URL%\{*}?name=dist.tar.gz" \
503+ -H "Authorization: Bearer $TOKEN" \
504+ -H "Content-Type: application/gzip" \
505+ --data-binary @dist.tar.gz
506+```
507+
508+## repository_dispatch
509+
510+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.
511+
512+```yaml
513+on:
514+ repository_dispatch:
515+ types: [deploy, rollback]
516+jobs:
517+ deploy:
518+ runs-on: ubuntu-latest
519+ steps:
520+ - uses: actions/checkout@v4
521+ - run: ./deploy.sh "${{ github.event.action }}" "${{ github.event.client_payload.version }}"
522+```
523+
524+- `types` lists the `event_type` values that start the workflow. Without `types`, every `event_type` does.
525+- Like on GitHub, the workflow is read from the default branch's latest commit and runs there.
526+- The run's title is the `event_type`, and its actor is the account whose token sent the request.
527+- Invalid workflow files are skipped without an `Invalid workflow file` run.
528+
529+### Send a repository_dispatch event
530+
531+You need write access to the repository, with a token that has the write scope (see [Tokens](/docs/accounts/tokens)). People and agents can both send events.
532+
533+```sh
534+curl -X POST https://diffinery.com/api/v3/repos/acme/website/dispatches \
535+ -H "Authorization: Bearer dfy_..." \
536+ -H "Content-Type: application/json" \
537+ -d '{"event_type": "deploy", "client_payload": {"version": "1.4.2", "environment": "staging"}}'
538+```
539+
540+| Field | Required | Meaning |
541+| --- | --- | --- |
542+| `event_type` | Yes | A name for the event, 1 to 100 characters. Workflows match it against `types`, and it becomes `github.event.action`. |
543+| `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: `{}`. |
544+
545+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.
546+
547+| Status | Message | Cause |
548+| --- | --- | --- |
549+| 403 | `write access required` | Your role or token scope is below write. |
550+| 403 | `repository is archived (read-only)` | Archived repositories don't accept events. |
551+| 422 | `event_type is required (at most 100 characters)` | `event_type` is missing, empty, not a string or too long. |
552+| 422 | `client_payload must be an object` | `client_payload` is an array or a scalar. |
553+| 422 | `client_payload can have at most 10 top-level properties` | Nest the values in fewer top-level properties. |
554+| 422 | `client_payload is too large` | The payload is over 64 KB as JSON. |
555+| 422 | `the repository is empty` | The default branch has no commits yet. |
556+
557+| Variable | Value |
558+| --- | --- |
559+| `GITHUB_SHA` | The latest commit on the default branch |
560+| `GITHUB_REF` | `refs/heads/<default branch>` |
561+
562+Payload (`github.event`):
563+
564+| Field | Value |
565+| --- | --- |
566+| `action` | The `event_type` |
567+| `branch` | The default branch |
568+| `client_payload` | The `client_payload` you sent |
569+
570+Unlike GitHub, and unlike the other events on this page, the `repository_dispatch` payload has no `repository` or `sender` field. Use `github.repository` and `github.actor` instead.
571+
572+## workflow_call
573+
574+Makes the workflow reusable: other workflows call it from a job with `uses:`. It never starts a run on its own.
575+
576+```yaml
577+on:
578+ workflow_call:
579+ inputs:
580+ node-version:
581+ type: string
582+ default: "22"
583+ secrets:
584+ npm-token:
585+ required: true
586+ outputs:
587+ version:
588+ value: ${{ jobs.build.outputs.version }}
589+```
590+
591+`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](/docs/actions/reusable-workflows).
592+
593+## GITHUB_SHA and GITHUB_REF by event
594+
595+| Event | `GITHUB_SHA` | `GITHUB_REF` | Workflow file read from |
596+| --- | --- | --- | --- |
597+| `push` | Pushed commit | Pushed branch or tag | Pushed commit |
598+| `pull_request` | Test merge commit (merge commit once merged) | `refs/pull/<n>/merge` | Test merge commit |
599+| `pull_request_target` | Base branch's latest commit | `refs/heads/<base>` | Base branch's latest commit |
600+| `merge_group` | Merge group commit | `refs/heads/gh-readonly-queue/<base>/pr-<n>-<head sha>` | Merge group commit |
601+| `workflow_dispatch` | Latest commit of the chosen ref | Chosen branch or tag | Chosen ref |
602+| `schedule` | Default branch's latest commit | Default branch | Default branch |
603+| `workflow_run` | Default branch's latest commit | Default branch | Default branch |
604+| `release` | The commit the release's tag points to | `refs/tags/<tag>` | The tagged commit |
605+| `repository_dispatch` | Default branch's latest commit | Default branch | Default branch |
606+
607+A re-run keeps the original run's commit, ref, event and payload.
608+
609+## The repository object
610+
611+Every payload except `repository_dispatch` has a `repository` field. For `push`, `pull_request`, `pull_request_target`, `merge_group`, `workflow_dispatch`, `schedule` and `workflow_run` it has:
612+
613+| Field | Value |
614+| --- | --- |
615+| `id` | The repository's ID |
616+| `name` | `website` |
617+| `full_name` | `acme/website` |
618+| `owner.login` | `acme` |
619+| `default_branch` | `main` |
620+| `html_url` | `https://diffinery.com/acme/website` |
621+| `clone_url` | `https://diffinery.com/acme/website.git` |
622+| `private` | Always `true`, whatever the repository's visibility |
623+
624+For `release`, `repository.id` is a number (as in the REST API), there is also a `node_id`, and there is no `private` field.
625+
626+The payload is also written to the file at `GITHUB_EVENT_PATH` for tools that read it from disk.
627+
628+## Runs started from inside a workflow
629+
630+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. `repository_dispatch` is the exception, as on GitHub: it is meant for starting workflows on purpose.
631+
632+On Diffinery the job's `GITHUB_TOKEN` has read access only, so in practice it can't push, open pull requests, publish releases or send `repository_dispatch` events in the first place (the API answers `403 write access required`). To do any of that from a job, use a personal or agent token stored as a secret. Events caused by such a token 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.
633+
634+```yaml
635+- name: Ask the deploy workflow to run
636+ env:
637+ TOKEN: ${{ secrets.DISPATCH_TOKEN }}
638+ run: |
639+ curl -fsS -X POST "${{ github.api_url }}/repos/${{ github.repository }}/dispatches" \
640+ -H "Authorization: Bearer $TOKEN" \
641+ -H "Content-Type: application/json" \
642+ -d '{"event_type": "deploy", "client_payload": {"sha": "${{ github.sha }}"}}'
643+```
644+
645+Guard against loops, for example with `paths-ignore`, `types`, or an `if:` that checks `github.actor` or the commit message.
646+
647+## Differences from GitHub
648+
649+- Only the events in [Supported events](#supported-events) start runs. Other GitHub event names parse but never fire.
650+- `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.
651+- `workflow_run` fires for `requested` and `completed`, not `in_progress`, and requires `workflows`.
652+- `release` workflows get no `branches`, `tags` or `paths` filtering, and a published release whose tag doesn't exist any more starts no runs.
653+- `repository_dispatch` payloads have no `repository` or `sender` field.
654+- `repository.private` is always `true` in payloads (except `release`, which has no `private` field).
655+- `push` payloads list only the pushed commit in `commits`.
656+- Path filters pass when a push creates a branch.
657+- `github.event.inputs` keeps input types.
658+- `schedule` is checked every 5 minutes; a schedule runs within about 5 minutes after it is due.
addedapps/diffinery/docs/actions/expressions.md+398−0View file
@@ -1,0 +1,398 @@
1+---
2+title: Expressions and contexts
3+description: The ${{ }} expression language in Diffinery workflows: operators, functions, every context property Diffinery sets, and the default environment variables.
4+---
5+
6+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.
7+
8+## Syntax
9+
10+An expression is written between `${{` and `}}`:
11+
12+```yaml
13+env:
14+ IMAGE: diffinery.com/acme/website:${{ github.sha }}
15+steps:
16+ - run: echo "Building ${{ github.ref_name }} for ${{ github.actor }}"
17+```
18+
19+- In most values, each `${{ }}` is replaced by its result converted to a string, and the text around it is kept.
20+- 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.
21+- In `if:` conditions of jobs and steps, `${{ }}` is optional: `if: github.event_name == 'push'` is the same as `if: ${{ github.event_name == 'push' }}`.
22+- `}}` inside a single-quoted string doesn't end the expression. To write a literal `${{`, use `${{ '${{' }}`.
23+- Expressions in `on:` are not evaluated.
24+
25+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.
26+
27+> **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.
28+
29+## Literals
30+
31+| Type | Examples | Notes |
32+| --- | --- | --- |
33+| null | `null` | |
34+| Boolean | `true`, `false` | |
35+| 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. |
36+| String | `'hello'`, `'it''s'` | Single quotes only; write `''` for a quote. Double quotes fail with `Unexpected double quote; string literals use single quotes`. |
37+
38+## Operators
39+
40+| Operator | Meaning |
41+| --- | --- |
42+| `( )` | Grouping |
43+| `[ ]` | Index: `github.event['pull_request']`, `matrix.include[0]` |
44+| `.` | Property: `github.event.pull_request.title` |
45+| `.*`, `[*]` | Object filter (see below) |
46+| `!` | Not |
47+| `<`, `<=`, `>`, `>=` | Comparison |
48+| `==`, `!=` | Equality |
49+| `&&` | And |
50+
51+The or operator is `||`. Precedence, from highest to lowest: property access and indexing, `!`, comparisons, equality, `&&`, `||`. There are no arithmetic operators, as on GitHub.
52+
53+`&&` 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' }}`.
54+
55+### Property access
56+
57+- Property names are case-insensitive: `github.EVENT_NAME` reads `github.event_name`.
58+- Names may contain `-`: `steps.build-app.outputs.path`, `matrix.node-version`.
59+- 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.
60+- Array indexes start at 0. An index out of range gives `null`.
61+
62+### Object filters
63+
64+`.*` collects a property from every item of an array or object and gives a list:
65+
66+```yaml
67+- run: echo "${{ toJSON(github.event.pull_request.labels.*.name) }}"
68+- if: contains(github.event.pull_request.labels.*.name, 'deploy')
69+ run: ./deploy.sh
70+```
71+
72+### Types and conversion
73+
74+Comparisons convert values the way GitHub does:
75+
76+| Rule | Example |
77+| --- | --- |
78+| 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 |
79+| Strings are compared case-insensitively. | `'Main' == 'main'` is true |
80+| A comparison with `NaN` is false. | `'abc' < 1` is false |
81+| Arrays and objects are only equal to themselves. | `fromJSON('[1]') == fromJSON('[1]')` is false |
82+
83+Truthiness: `false`, `0`, `-0`, `NaN`, `''` and `null` are falsy; everything else, including every array and object, is truthy.
84+
85+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).
86+
87+## Functions
88+
89+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>`.
90+
91+| Function | Returns |
92+| --- | --- |
93+| `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. |
94+| `startsWith(string, prefix)` | `true` when `string` starts with `prefix`, ignoring case. |
95+| `endsWith(string, suffix)` | `true` when `string` ends with `suffix`, ignoring case. |
96+| `format(string, value0, value1, ...)` | `string` with `{0}`, `{1}`, ... replaced by the values. Write `{{` and `}}` for literal braces. |
97+| `join(array, separator)` | The elements joined with `separator` (default `,`). A non-array is converted to a string. |
98+| `toJSON(value)` | `value` as JSON, indented by 2 spaces. |
99+| `fromJSON(string)` | The parsed value. Use it to turn strings into booleans, numbers, lists or objects. |
100+| `hashFiles(pattern, ...)` | A SHA-256 hash of the matching files, or `''` when none match (see below). |
101+| `success()`, `failure()`, `cancelled()`, `always()` | Status checks (see below). |
102+
103+Examples:
104+
105+```yaml
106+- run: echo "${{ format('{0}/{1}@{2}', github.repository_owner, github.event.repository.name, github.sha) }}"
107+- if: startsWith(github.ref, 'refs/tags/v')
108+ run: ./release.sh
109+- uses: actions/cache@v4
110+ with:
111+ path: ~/.npm
112+ key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
113+```
114+
115+Runtime errors from functions fail the step or job that evaluates them:
116+
117+| Message | Cause |
118+| --- | --- |
119+| `fromJSON: invalid JSON "<text>": <detail>` | The argument isn't valid JSON. |
120+| `format: invalid format string '<string>'` | A `{` isn't followed by a number and `}`. |
121+| `format: invalid format string '<string>' (unescaped '}')` | A lone `}`. Write `}}`. |
122+| `format: placeholder {<n>} has no matching argument in '<string>'` | Not enough values. |
123+
124+### hashFiles
125+
126+`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.
127+
128+`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 `''`.
129+
130+### Status check functions
131+
132+In a job's `if:`:
133+
134+| Function | True when |
135+| --- | --- |
136+| `success()` | The run hasn't been cancelled and every job in `needs` succeeded or was skipped. |
137+| `failure()` | A job in `needs` failed or timed out. |
138+| `cancelled()` | The run has been cancelled. |
139+| `always()` | Always. |
140+
141+In a step's `if:`:
142+
143+| Function | True when |
144+| --- | --- |
145+| `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. |
146+| `failure()` | An earlier step failed. |
147+| `cancelled()` | The job is being cancelled or has hit its timeout. |
148+| `always()` | Always. |
149+
150+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.
151+
152+```yaml
153+jobs:
154+ test:
155+ runs-on: ubuntu-latest
156+ steps:
157+ - run: npm test
158+ - if: failure()
159+ run: echo "::error::Tests failed"
160+ notify:
161+ needs: test
162+ if: always()
163+ runs-on: ubuntu-latest
164+ steps:
165+ - run: echo "test finished with ${{ needs.test.result }}"
166+```
167+
168+> **Important:** Two differences from GitHub at the job level. On GitHub, when a job is skipped, every job that needs it is skipped too; on Diffinery a skipped job counts as a success, so the jobs that need it run. And `failure()` only looks at the jobs listed in the job's own `needs`, not at the jobs those depend on. If a job must not run after an earlier failure further up the chain, list that job in `needs` or check its result, for example `if: needs.build.result == 'success'`.
169+
170+## Contexts
171+
172+### Where each context is available
173+
174+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:
175+
176+| Where | Available contexts |
177+| --- | --- |
178+| `run-name`, top-level `concurrency` | `github`, `inputs`, `vars` |
179+| `jobs.<id>.if` | `github`, `needs`, `inputs`, `vars`, `env` (workflow-level values, unevaluated), status functions |
180+| `jobs.<id>.runs-on`, `jobs.<id>.strategy` | `github`, `needs`, `inputs`, `vars`, `env` |
181+| `jobs.<id>.name`, `jobs.<id>.timeout-minutes` | `github`, `needs`, `inputs`, `vars`, `env`, `matrix`, `strategy` |
182+| `jobs.<id>.environment` (`name` and `url`) | `github`, `inputs`, `vars`, `matrix`. A `url` that refers to `steps.` is ignored. |
183+| `jobs.<id>.with` (reusable workflow call) | `github`, `needs`, `inputs`, `vars` |
184+| `jobs.<id>.secrets` (reusable workflow call) | `github`, `needs`, `inputs`, `vars`, `secrets` |
185+| `jobs.<id>.env`, `container`, `services` | `github`, `needs`, `inputs`, `vars`, `secrets`, `matrix`, `strategy`, `runner`, `job` |
186+| 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` |
187+| `jobs.<id>.outputs` | Same as steps |
188+| `on.workflow_call.outputs` | `github`, `jobs`, `inputs`, `vars` |
189+
190+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](/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.
191+
192+### github
193+
194+Information about the run and the event. Most properties are also environment variables (see [Default environment variables](#default-environment-variables)).
195+
196+| Property | Value |
197+| --- | --- |
198+| `github.event_name` | The event that started the run, for example `push`, `pull_request`, `release` or `repository_dispatch` |
199+| `github.event` | The event payload. See [Events that trigger workflows](/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`. |
200+| `github.sha` | The commit the run checks out. See the table in [Events that trigger workflows](/docs/actions/events#github_sha-and-github_ref-by-event). |
201+| `github.ref` | The full ref, for example `refs/heads/main`, `refs/tags/v1.2.0` (also for `release` runs) or `refs/pull/7/merge` |
202+| `github.ref_name` | The short ref: `main`, `v1.2.0`, or `7/merge` for pull requests |
203+| `github.ref_type` | `tag` for tags, `branch` otherwise |
204+| `github.ref_protected` | Always `false` |
205+| `github.head_ref` | The pull request's head branch for `pull_request` and `pull_request_target`, otherwise `''` |
206+| `github.base_ref` | The pull request's base branch for `pull_request` and `pull_request_target`, otherwise `''` |
207+| `github.repository` | `acme/website` |
208+| `github.repository_id` | The repository's ID |
209+| `github.repository_owner` | `acme` |
210+| `github.repositoryUrl` | `https://diffinery.com/acme/website.git` |
211+| `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. |
212+| `github.triggering_actor` | Same as `github.actor` |
213+| `github.run_id` | The run's ID, a 32-character hexadecimal string |
214+| `github.run_number` | The run's number, as a string |
215+| `github.run_attempt` | `1`, or higher for re-runs, as a string |
216+| `github.workflow` | The workflow's `name`, or its path when it has none |
217+| `github.workflow_ref` | `acme/website/.github/workflows/ci.yml@refs/heads/main` |
218+| `github.job` | The job's ID (inside a reusable workflow, its ID in that workflow) |
219+| `github.server_url` | `https://diffinery.com` |
220+| `github.api_url` | `https://diffinery.com/api/v3` |
221+| `github.graphql_url` | `https://diffinery.com/api/graphql`. Set for compatibility only: Diffinery has no GraphQL API yet |
222+| `github.token` | The job's token (same as `secrets.GITHUB_TOKEN`) |
223+| `github.workspace` | The workspace directory, `GITHUB_WORKSPACE` |
224+| `github.retention_days` | `90` |
225+| `github.secret_source` | `Actions` |
226+| `github.action` | `''` |
227+| `github.action_path` | Inside a composite action: the action's directory. Otherwise `''`. |
228+| `github.action_repository`, `github.action_ref` | Inside a composite action: the action's repository and ref. |
229+
230+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).
231+
232+> **Note:** `github.run_number` counts runs of all workflows in the repository, not each workflow separately as on GitHub, and `github.run_id` is a hexadecimal string, not a number.
233+
234+### env
235+
236+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.
237+
238+```yaml
239+env:
240+ STAGE: production
241+jobs:
242+ deploy:
243+ runs-on: ubuntu-latest
244+ steps:
245+ - if: env.STAGE == 'production'
246+ run: echo "Deploying to $STAGE"
247+```
248+
249+### vars
250+
251+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](/docs/actions/secrets-and-variables) and [Environments](/docs/actions/environments).
252+
253+### secrets
254+
255+| Property | Value |
256+| --- | --- |
257+| `secrets.<NAME>` | A repository secret, or an organization secret of the repository's owner. 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 started by agents, don't get environment secrets; see [environment](/docs/actions/workflow-syntax#environment)). |
258+| `secrets.GITHUB_TOKEN` | The job's token. It has read access to the repository; see [Workflow syntax](/docs/actions/workflow-syntax#permissions). |
259+| `secrets.DIFFINERY_TOKEN` | The same job token, under a Diffinery name. |
260+
261+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.
262+
263+### matrix
264+
265+The values of the current matrix combination, for example `matrix.os` and `matrix.node`. Empty for jobs without a matrix.
266+
267+### strategy
268+
269+| Property | Value |
270+| --- | --- |
271+| `strategy.fail-fast` | Whether `fail-fast` is on |
272+| `strategy.job-index` | This job's position in the matrix, from 0 |
273+| `strategy.job-total` | How many jobs the matrix produced |
274+| `strategy.max-parallel` | The effective `max-parallel` |
275+
276+### needs
277+
278+For each job in the job's `needs`:
279+
280+| Property | Value |
281+| --- | --- |
282+| `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`. |
283+| `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`. |
284+
285+### steps
286+
287+For each earlier step with an `id` in the same job (or the same composite action):
288+
289+| Property | Value |
290+| --- | --- |
291+| `steps.<id>.outputs.<name>` | Outputs the step set with `$GITHUB_OUTPUT` or `::set-output` |
292+| `steps.<id>.outcome` | `success`, `failure`, `cancelled` or `skipped`, before `continue-on-error` |
293+| `steps.<id>.conclusion` | The same, after `continue-on-error` (a failed step with `continue-on-error: true` is `success`) |
294+
295+### runner
296+
297+| Property | Value |
298+| --- | --- |
299+| `runner.os` | `Linux` |
300+| `runner.arch` | `X64` on hosted runners (`ARM64`, `ARM` or `X86` on self-hosted machines with those processors) |
301+| `runner.name` | `diffinery` |
302+| `runner.environment` | `diffinery-hosted` |
303+| `runner.temp` | A temporary directory for the job; `/workspace/_temp` on hosted runners, where every job starts in a fresh container |
304+| `runner.tool_cache` | Where setup actions install tools; `/opt/hostedtoolcache` on hosted runners |
305+| `runner.workspace` | The parent of `GITHUB_WORKSPACE`; `/workspace` on hosted runners |
306+| `runner.debug` | `1` when debug logging is on (secret or variable `ACTIONS_STEP_DEBUG` set to `true`), otherwise `''` |
307+
308+### job
309+
310+| Property | Value |
311+| --- | --- |
312+| `job.status` | `success`, `failure` or `cancelled`: the job's state so far |
313+| `job.container.id`, `job.container.network` | The job container's ID and network, when the job has a `container` |
314+| `job.services.<name>.id`, `.network`, `.ports` | Each service container's ID, network and port mappings (`ports['5432']` is the host port) |
315+
316+### inputs
317+
318+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.
319+
320+### jobs
321+
322+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.
323+
324+## Default environment variables
325+
326+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`.
327+
328+| Variable | Value |
329+| --- | --- |
330+| `CI` | `true` |
331+| `GITHUB_ACTIONS` | `true` |
332+| `DIFFINERY` | `true`. Use it to detect Diffinery in scripts. |
333+| `GITHUB_REPOSITORY` | `acme/website` |
334+| `GITHUB_REPOSITORY_OWNER` | `acme` |
335+| `GITHUB_SHA` | The commit the run is about (see [Events that trigger workflows](/docs/actions/events#github_sha-and-github_ref-by-event)) |
336+| `GITHUB_REF` | The full ref |
337+| `GITHUB_REF_NAME` | The short ref (`main`, `v1.2.0`, `7/merge`) |
338+| `GITHUB_REF_TYPE` | `branch` or `tag` |
339+| `GITHUB_HEAD_REF` | The head branch for pull request events, otherwise empty |
340+| `GITHUB_BASE_REF` | The base branch for pull request events, otherwise empty |
341+| `GITHUB_RUN_ID` | The run's ID |
342+| `GITHUB_RUN_NUMBER` | The run's number |
343+| `GITHUB_RUN_ATTEMPT` | The attempt number |
344+| `GITHUB_JOB` | The job's ID |
345+| `GITHUB_ACTOR` | Who triggered the run |
346+| `GITHUB_TRIGGERING_ACTOR` | Same as `GITHUB_ACTOR` |
347+| `GITHUB_EVENT_NAME` | The event name |
348+| `GITHUB_EVENT_PATH` | A JSON file holding the event payload, `/workspace/_temp/_github_workflow/event.json` |
349+| `GITHUB_SERVER_URL` | `https://diffinery.com` |
350+| `GITHUB_API_URL` | `https://diffinery.com/api/v3` |
351+| `GITHUB_GRAPHQL_URL` | `https://diffinery.com/api/graphql`. Set for compatibility only: Diffinery has no GraphQL API yet |
352+| `GITHUB_WORKFLOW` | The workflow's name, or its path |
353+| `GITHUB_WORKFLOW_REF` | `acme/website/.github/workflows/ci.yml@refs/heads/main` |
354+| `GITHUB_WORKSPACE` | `/workspace/website` |
355+| `GITHUB_RETENTION_DAYS` | `90` |
356+| `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 `_`) |
357+| `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_OUTPUT`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | Paths of the step's [environment files](/docs/actions/workflow-syntax#environment-files) |
358+| `RUNNER_OS` | `Linux` |
359+| `RUNNER_ARCH` | `X64` on hosted runners |
360+| `RUNNER_NAME` | `diffinery` |
361+| `RUNNER_ENVIRONMENT` | `diffinery-hosted` |
362+| `RUNNER_TEMP` | `/workspace/_temp` |
363+| `RUNNER_TOOL_CACHE` | `/opt/hostedtoolcache` |
364+| `RUNNER_WORKSPACE` | `/workspace` |
365+| `AGENT_TOOLSDIRECTORY` | Same as `RUNNER_TOOL_CACHE` |
366+
367+Set only in some cases:
368+
369+| Variable | When |
370+| --- | --- |
371+| `RUNNER_DEBUG` | `1` when the secret or variable `ACTIONS_STEP_DEBUG` is `true` |
372+| `ACTIONS_ID_TOKEN_REQUEST_URL`, `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | When the job has `permissions: id-token: write`. Used by OIDC clients; see [OIDC](/docs/actions/oidc). |
373+| `GITHUB_ACTION_PATH`, `GITHUB_ACTION_REPOSITORY`, `GITHUB_ACTION_REF` | In JavaScript and Docker actions, and in the steps of composite actions |
374+| `INPUT_<NAME>` | In JavaScript and Docker actions: each input, with the name upper-cased and spaces replaced by `_` |
375+| `STATE_<NAME>` | In an action's main and post scripts: values saved with `$GITHUB_STATE` or `::save-state` |
376+
377+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`.
378+
379+GitHub variables that Diffinery doesn't set include `GITHUB_ACTOR_ID`, `GITHUB_REPOSITORY_ID`, `GITHUB_REPOSITORY_OWNER_ID`, `GITHUB_WORKFLOW_SHA`, `GITHUB_REF_PROTECTED`, `ImageOS` and `ImageVersion`. `GITHUB_TOKEN` isn't an environment variable unless you set it, as on GitHub:
380+
381+```yaml
382+- run: gh api repos/${{ github.repository }}
383+ env:
384+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
385+```
386+
387+## Differences from GitHub
388+
389+- A skipped job counts as a success for the jobs that need it, so they run; on GitHub they're skipped. `failure()` in a job's `if:` only looks at its direct `needs`.
390+- `runs-on` can't use the `matrix` context.
391+- `jobs.<id>.environment` can't use the `needs` or `strategy` contexts, and its `url` can't use step outputs.
392+- Job-level `if:` can also read the workflow-level `env`.
393+- `hashFiles()` returns `''` outside steps and other runner-evaluated values.
394+- `github.event.inputs` keeps the types of dispatch inputs; on GitHub its values are strings.
395+- `github.run_id` is a hexadecimal string, and `github.run_number` counts runs across all workflows of the repository.
396+- On a re-run, `github.actor` is whoever started the re-run (and `github.triggering_actor` matches it).
397+- `github.ref_protected` is always `false`, and `secrets.DIFFINERY_TOKEN` is an extra name for the job token.
398+- `GITHUB_*` and `RUNNER_*` defaults can't be overridden, but `CI` and `DIFFINERY` can.
addedapps/diffinery/docs/actions/oidc.md+352−0View file
@@ -1,0 +1,352 @@
1+---
2+title: OpenID Connect (OIDC)
3+description: Let workflows authenticate to AWS, Google Cloud and your own services with short-lived OIDC tokens issued by Diffinery, instead of stored keys.
4+---
5+
6+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).
7+
8+## Requirements
9+
10+- The job needs `permissions: id-token: write`, in the workflow's top-level `permissions`, in the job's `permissions`, or through `permissions: write-all`.
11+- It works on hosted and self-hosted runners.
12+- In a reusable workflow, the **called** workflow (or its job) must grant `id-token: write`; the calling job's `permissions` isn't used.
13+
14+```yaml
15+permissions:
16+ id-token: write
17+ contents: read
18+```
19+
20+Without the permission, the variables below aren't set, and `core.getIDToken()` in an action fails because `ACTIONS_ID_TOKEN_REQUEST_URL` is missing.
21+
22+## The issuer
23+
24+| Item | Value |
25+|---|---|
26+| Issuer (`iss`) | `https://diffinery.com` |
27+| Discovery document | `https://diffinery.com/.well-known/openid-configuration` |
28+| Signing keys (JWKS) | `https://diffinery.com/.well-known/jwks` |
29+| Algorithm | `ES256` (ECDSA P-256 with SHA-256) |
30+| Token lifetime | 10 minutes |
31+
32+The discovery document:
33+
34+```json
35+{
36+ "issuer": "https://diffinery.com",
37+ "jwks_uri": "https://diffinery.com/.well-known/jwks",
38+ "response_types_supported": ["id_token"],
39+ "subject_types_supported": ["public"],
40+ "id_token_signing_alg_values_supported": ["ES256"],
41+ "claims_supported": ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"]
42+}
43+```
44+
45+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.
46+
47+## Requesting a token
48+
49+When a job has `id-token: write`, the runner sets two environment variables, the same ones GitHub sets:
50+
51+| Variable | Value |
52+|---|---|
53+| `ACTIONS_ID_TOKEN_REQUEST_URL` | `https://diffinery.com/runner/jobs/<job id>/oidc?api-version=2.0` |
54+| `ACTIONS_ID_TOKEN_REQUEST_TOKEN` | The job's token, to send as a bearer token |
55+
56+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`):
57+
58+```yaml
59+- name: Get an OIDC token
60+ run: |
61+ token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
62+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.example.com" | jq -r .value)
63+ echo "::add-mask::$token"
64+ echo "OIDC_TOKEN=$token" >> "$GITHUB_ENV"
65+```
66+
67+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.
68+
69+Errors from the endpoint:
70+
71+| Status and message | Cause |
72+|---|---|
73+| `403 this job lacks permissions: id-token: write` | The job doesn't have the permission. |
74+| `409 job is not running` | The job has finished, or hasn't started. |
75+| `401 job token required` / `401 token does not match job` | The request didn't send this job's token. |
76+| `501 OIDC is not configured on this Diffinery instance` | The instance has no signing key. This doesn't happen on diffinery.com. |
77+
78+## Claims
79+
80+Every token has exactly these claims. All values are strings except the timestamps.
81+
82+| Claim | Example | Meaning |
83+|---|---|---|
84+| `iss` | `https://diffinery.com` | Issuer. |
85+| `aud` | `sts.amazonaws.com` | The requested audience, or `https://diffinery.com` if none was given. A single string. |
86+| `sub` | `repo:acme/website:ref:refs/heads/main` | Always `repo:<owner>/<repo>:ref:<ref>`. |
87+| `iat`, `nbf` | `1760000000` | Issue time and not-before time (the same). |
88+| `exp` | `1760000600` | Expiry, 10 minutes after issue. |
89+| `repository` | `acme/website` | Repository that runs the workflow. |
90+| `repository_owner` | `acme` | Its owner (user or organization). |
91+| `ref` | `refs/heads/main` | The run's ref. See the table below. |
92+| `sha` | `9fceb02d0ae598e95dc970b74767f19372d61af8` | The commit the run checked out. |
93+| `workflow` | `Deploy` | The workflow's `name`, or its file path when it has none. |
94+| `event_name` | `push` | The event that started the run. |
95+| `run_id` | `01J9Z...` | Diffinery's run id (an opaque string, not a number). |
96+| `actor` | `octocat` | Who started the run. |
97+| `job_workflow_ref` | `acme/website/.github/workflows/deploy.yml@refs/heads/main` | The run's top-level workflow file and ref. |
98+
99+The token header is `{"alg": "ES256", "typ": "JWT", "kid": "..."}`.
100+
101+The `ref` (and therefore `sub`) for each event:
102+
103+| Event | `ref` |
104+|---|---|
105+| `push` | The pushed branch or tag, for example `refs/heads/main` or `refs/tags/v1.2.0` |
106+| `pull_request` | `refs/pull/<number>/merge` |
107+| `pull_request_target` | The base branch, for example `refs/heads/main` |
108+| `merge_group` | The merge queue's temporary branch |
109+| `workflow_dispatch` | The branch or tag it was started on |
110+| `schedule`, `workflow_run`, `repository_dispatch` | The default branch |
111+| `release` | The release's tag, for example `refs/tags/v1.4.0` |
112+
113+> **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`.
114+
115+## Jobs that use an environment
116+
117+Tokens don't change when a job has `environment:`. There's no `environment` claim, and `sub` stays `repo:<owner>/<repo>:ref:<ref>`; GitHub's `repo:<owner>/<repo>:environment:<name>` form is never issued. A trust policy written for GitHub that matches `sub` against `...:environment:production` never matches a Diffinery token.
118+
119+To get the same protection, combine two checks:
120+
121+1. In your cloud provider, trust only the refs that may deploy, for example `repo:acme/website:ref:refs/heads/main`.
122+2. In Diffinery, give the deploy job `environment: production` and set that environment's deployment branch policy to the same refs, so no other branch can run the job (see [Deployments and environments](/docs/actions/environments)).
123+
124+Unlike environment secrets, an OIDC token is issued to any job that has `id-token: write`, whatever its environment and whoever started the run. The `sub`, `ref` and `event_name` claims are what tell your provider where the request came from.
125+
126+## AWS
127+
128+AWS accepts Diffinery tokens through an IAM OIDC identity provider and a role whose trust policy names it.
129+
130+1. Create the identity provider (once per AWS account), with provider URL `https://diffinery.com` and audience `sts.amazonaws.com`:
131+
132+ ```sh
133+ aws iam create-open-id-connect-provider \
134+ --url https://diffinery.com \
135+ --client-id-list sts.amazonaws.com
136+ ```
137+
138+2. Create a role with this trust policy. Replace the account id, and narrow `sub` to the repositories and branches that may deploy:
139+
140+ ```json
141+ {
142+ "Version": "2012-10-17",
143+ "Statement": [
144+ {
145+ "Effect": "Allow",
146+ "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/diffinery.com" },
147+ "Action": "sts:AssumeRoleWithWebIdentity",
148+ "Condition": {
149+ "StringEquals": {
150+ "diffinery.com:aud": "sts.amazonaws.com",
151+ "diffinery.com:sub": "repo:acme/website:ref:refs/heads/main"
152+ }
153+ }
154+ }
155+ ]
156+ }
157+ ```
158+
159+ To allow every branch and tag of the repository, use `"StringLike": { "diffinery.com:sub": "repo:acme/website:*" }` together with the `aud` check. Attach the permissions the workflow needs to the role.
160+
161+3. Use it from a workflow:
162+
163+ ```yaml
164+ name: Deploy to AWS
165+ on:
166+ push:
167+ branches: [main]
168+
169+ permissions:
170+ id-token: write
171+ contents: read
172+
173+ jobs:
174+ deploy:
175+ runs-on: ubuntu-latest
176+ steps:
177+ - uses: actions/checkout@v4
178+ - uses: aws-actions/configure-aws-credentials@v4
179+ with:
180+ role-to-assume: arn:aws:iam::123456789012:role/diffinery-website-deploy
181+ aws-region: eu-west-1
182+ - run: pip install awscli # the AWS CLI isn't preinstalled on hosted runners
183+ - run: aws sts get-caller-identity
184+ - run: aws s3 sync ./dist s3://acme-website --delete
185+ ```
186+
187+Without the action, exchange the token yourself:
188+
189+```sh
190+token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
191+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
192+aws sts assume-role-with-web-identity \
193+ --role-arn arn:aws:iam::123456789012:role/diffinery-website-deploy \
194+ --role-session-name "diffinery-$GITHUB_RUN_ID" \
195+ --web-identity-token "$token"
196+```
197+
198+## Google Cloud
199+
200+Google Cloud accepts Diffinery tokens through Workload Identity Federation.
201+
202+1. Create a pool and an OIDC provider for the Diffinery issuer. The attribute condition limits which tokens are accepted at all:
203+
204+ ```sh
205+ gcloud iam workload-identity-pools create diffinery \
206+ --location=global --display-name="Diffinery"
207+
208+ gcloud iam workload-identity-pools providers create-oidc diffinery \
209+ --location=global \
210+ --workload-identity-pool=diffinery \
211+ --issuer-uri=https://diffinery.com \
212+ --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref,attribute.event_name=assertion.event_name" \
213+ --attribute-condition="assertion.repository_owner == 'acme'"
214+ ```
215+
216+2. Let one repository impersonate a service account (use your project number):
217+
218+ ```sh
219+ gcloud iam service-accounts add-iam-policy-binding deployer@acme-prod.iam.gserviceaccount.com \
220+ --role=roles/iam.workloadIdentityUser \
221+ --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/diffinery/attribute.repository/acme/website"
222+ ```
223+
224+3. Use it from a workflow:
225+
226+ ```yaml
227+ permissions:
228+ id-token: write
229+ contents: read
230+
231+ jobs:
232+ deploy:
233+ runs-on: ubuntu-latest
234+ steps:
235+ - uses: actions/checkout@v4
236+ - uses: google-github-actions/auth@v2
237+ with:
238+ workload_identity_provider: projects/123456789012/locations/global/workloadIdentityPools/diffinery/providers/diffinery
239+ service_account: deployer@acme-prod.iam.gserviceaccount.com
240+ - uses: google-github-actions/setup-gcloud@v2
241+ - run: gcloud run deploy website --source . --region europe-west1
242+ ```
243+
244+## Your own service on Cloudflare Workers
245+
246+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:
247+
248+```ts
249+import { createRemoteJWKSet, jwtVerify } from "jose";
250+
251+const JWKS = createRemoteJWKSet(new URL("https://diffinery.com/.well-known/jwks"));
252+
253+export default {
254+ async fetch(request: Request): Promise<Response> {
255+ const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
256+ if (!token) return new Response("missing token", { status: 401 });
257+ try {
258+ const { payload } = await jwtVerify(token, JWKS, {
259+ issuer: "https://diffinery.com",
260+ audience: "https://deploy.acme.workers.dev",
261+ algorithms: ["ES256"],
262+ });
263+ if (payload.repository !== "acme/website" || payload.ref !== "refs/heads/main" || payload.event_name !== "push") {
264+ return new Response("not allowed", { status: 403 });
265+ }
266+ // ... do the deploy for payload.sha ...
267+ return Response.json({ ok: true, sha: payload.sha });
268+ } catch {
269+ return new Response("invalid token", { status: 401 });
270+ }
271+ },
272+};
273+```
274+
275+The workflow requests a token with the same audience and sends it:
276+
277+```yaml
278+permissions:
279+ id-token: write
280+
281+jobs:
282+ deploy:
283+ runs-on: ubuntu-latest
284+ steps:
285+ - run: |
286+ token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
287+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://deploy.acme.workers.dev" | jq -r .value)
288+ echo "::add-mask::$token"
289+ curl -fsS -X POST https://deploy.acme.workers.dev/ -H "Authorization: Bearer $token"
290+```
291+
292+## Verifying tokens yourself
293+
294+Any service can verify a Diffinery token. Check, in this order:
295+
296+1. The header's `alg` is `ES256`. Reject anything else.
297+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`.
298+3. `iss` is exactly `https://diffinery.com`.
299+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.
300+5. `exp` is in the future and `nbf` is in the past (allow a little clock skew).
301+6. Your authorization rules: `repository`, `ref`, `event_name`, and if needed `sha` or `actor`. Match `repository` exactly, not by prefix.
302+
303+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:
304+
305+```text
306+pip install "pyjwt[crypto]"
307+```
308+
309+```text
310+import jwt
311+
312+jwks = jwt.PyJWKClient("https://diffinery.com/.well-known/jwks")
313+
314+def verify(token: str) -> dict:
315+ key = jwks.get_signing_key_from_jwt(token).key
316+ claims = jwt.decode(
317+ token,
318+ key,
319+ algorithms=["ES256"],
320+ audience="https://deploy.example.com",
321+ issuer="https://diffinery.com",
322+ )
323+ if claims["repository"] != "acme/website" or claims["ref"] != "refs/heads/main":
324+ raise PermissionError("not allowed")
325+ return claims
326+```
327+
328+## Differences from GitHub
329+
330+| Area | GitHub | Diffinery |
331+|---|---|---|
332+| Issuer | `https://token.actions.githubusercontent.com` | `https://diffinery.com` |
333+| Algorithm | RS256 | ES256 |
334+| `sub` | Varies by event (for example `repo:o/r:pull_request`, `repo:o/r:environment:prod`) | Always `repo:<owner>/<repo>:ref:<ref>`; pull requests use `refs/pull/<n>/merge` |
335+| Claims | Many, including `repository_id`, `workflow_ref`, `ref_type`, `environment`, `runner_environment` | Only the claims listed in [Claims](#claims) |
336+| Jobs with `environment:` | `environment` claim, and `sub` is `repo:o/r:environment:<name>` | No `environment` claim; `sub` is unchanged (see [Jobs that use an environment](#jobs-that-use-an-environment)) |
337+| `run_id` | A number | An opaque string |
338+| `job_workflow_ref` | The reusable workflow's file when the job is in one | Always the run's top-level workflow |
339+| `permissions` | A job's `permissions` replaces the workflow's | `id-token: write` at either level (or `write-all`) grants it |
340+| Token lifetime | About 5 to 10 minutes | 10 minutes |
341+
342+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 rewrite `sub` patterns to the `repo:<owner>/<repo>:ref:<ref>` form.
343+
344+## Troubleshooting
345+
346+| Problem | Fix |
347+|---|---|
348+| `Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable` from an action | Add `permissions: id-token: write` to the workflow or the job (in a reusable workflow, to the called workflow). |
349+| 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. |
350+| AWS: `No OpenIDConnect provider found in your account for https://diffinery.com` | Create the identity provider with URL `https://diffinery.com`. |
351+| Google Cloud: `The given credential is rejected by the attribute condition` | The `attribute-condition` on the provider doesn't match the token's claims. |
352+| Your service rejects the token as expired | Tokens last 10 minutes. Request a new one right before you use it. |
addedapps/diffinery/docs/actions/overview.md+413−0View file
@@ -1,0 +1,413 @@
1+---
2+title: Actions overview
3+description: 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.
4+---
5+
6+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.
7+
8+## Key concepts
9+
10+| Term | Meaning |
11+| --- | --- |
12+| 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](/docs/actions/workflow-syntax). |
13+| 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](/docs/actions/events). |
14+| 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. |
15+| 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. |
16+| Step | One command (`run:`) or one action (`uses:`) inside a job. Steps run in order in the same workspace. |
17+| 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](/docs/actions/runners). |
18+| Check | The result of one job, reported on the commit the run is about. Branch protection can require checks to pass before merging. |
19+| 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](/docs/actions/environments). |
20+
21+Workflows are compatible with GitHub Actions: the same triggers, expressions, matrices, `needs`, outputs, caches, artifacts, secrets, environments, OIDC, `actions/checkout`, `actions/setup-node`, `actions/setup-python`, `actions/setup-go`, and remote JavaScript, composite and Docker actions. Differences are called out on each page and collected in [GitHub Actions compatibility](/docs/actions/compatibility).
22+
23+## Your first workflow
24+
25+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.
26+
27+1. In a clone of `acme/website`, create `.github/workflows/ci.yml`:
28+
29+ ```yaml
30+ name: CI
31+ on:
32+ push:
33+ branches: [main]
34+ pull_request:
35+ jobs:
36+ test:
37+ runs-on: ubuntu-latest
38+ steps:
39+ - uses: actions/checkout@v4
40+ - uses: actions/setup-node@v4
41+ with:
42+ node-version: 22
43+ cache: npm
44+ - run: npm ci
45+ - run: npm test
46+ - name: Write a summary
47+ run: echo "### Tests passed on \`${{ github.ref_name }}\`" >> "$GITHUB_STEP_SUMMARY"
48+ ```
49+
50+2. Check the file before you push. The CLI uses the same parser as the server:
51+
52+ ```sh
53+ diffinery workflow validate .github/workflows/ci.yml
54+ ```
55+
56+3. Commit and push:
57+
58+ ```sh
59+ git add .github/workflows/ci.yml
60+ git commit -m "Add CI"
61+ git push origin main
62+ ```
63+
64+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.
65+
66+5. Or follow it from your terminal:
67+
68+ ```sh
69+ diffinery run list -R acme/website --limit 1
70+ diffinery run watch <run-id> -R acme/website
71+ ```
72+
73+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.
74+
75+If you don't have the CLI yet, install it (needs Node.js 22 or later) and log in with a token:
76+
77+```sh
78+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
79+diffinery login
80+```
81+
82+## Where workflow files live
83+
84+Diffinery reads workflow files from two directories at the root of the repository:
85+
86+- `.github/workflows/`
87+- `.diffinery/workflows/`
88+
89+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.
90+
91+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.
92+
93+Which version of a file runs depends on the event:
94+
95+| Event | Workflow files are read from |
96+| --- | --- |
97+| `push` | The pushed commit |
98+| `pull_request` | The pull request's test merge commit (the head merged into the base) |
99+| `pull_request_target` | The base branch's current commit |
100+| `merge_group` | The merge group commit |
101+| `workflow_dispatch` | The branch or tag you choose |
102+| `schedule`, `workflow_run`, `repository_dispatch` | The default branch's current commit |
103+| `release` | The commit the release's tag points to |
104+
105+The workflow list in the **Actions** sidebar and `GET /repos/{owner}/{repo}/actions/workflows` show the workflows on the default branch.
106+
107+## How a run executes
108+
109+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.
110+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)).
111+3. **The run is orchestrated durably** by a Cloudflare Workflow. It resolves the job graph from `needs` into levels, starts every job of a level, waits for all of them to finish, then moves on to the next level. The orchestration survives restarts of the platform, and each wait for a job lasts at most 6 hours.
112+4. **Each job is evaluated** just before it starts: its `if:` condition, its `runs-on` labels and its matrix. A job whose condition is false is recorded as `skipped`. A matrix job becomes one job per combination (at most 256), started up to `max-parallel` at a time.
113+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.
114+6. **The run finishes** when every level is done. Its conclusion is `failure` if any job failed or timed out, otherwise `cancelled` if the run was cancelled or any job was cancelled, otherwise `success`. Skipped jobs don't make a run fail.
115+
116+### Timeouts
117+
118+| What | Default | Maximum |
119+| --- | --- | --- |
120+| A job (`jobs.<id>.timeout-minutes`) | 360 minutes | 360 minutes. Larger values are capped at 360. |
121+| A step (`steps[*].timeout-minutes`) | None (the job's timeout applies) | The job's timeout |
122+| Waiting for a self-hosted runner, plus running the job | | 6 hours, after which the job is marked timed out |
123+
124+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**.
125+
126+### Run and job states
127+
128+| Status | Meaning |
129+| --- | --- |
130+| `queued` | The run or job has been created and hasn't started. |
131+| `waiting` | (Jobs) The job is waiting for its container to start, or for a self-hosted runner to claim it. |
132+| `in_progress` | The run is executing, or a runner has picked up the job. |
133+| `completed` | Finished. See the conclusion. |
134+
135+| Conclusion | Meaning |
136+| --- | --- |
137+| `success` | Every step succeeded (steps with `continue-on-error: true` may have failed). |
138+| `failure` | A step failed, the job could not start, or (for runs) a job failed. |
139+| `cancelled` | Someone cancelled the run, a newer run in the same concurrency group replaced it, or a sibling matrix job failed with `fail-fast` on. |
140+| `skipped` | The job's `if:` condition was false. |
141+| `timed_out` | (Jobs) Diffinery waited 6 hours for the job without a result. |
142+
143+## The Actions tab
144+
145+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.
146+
147+### Runs list
148+
149+`https://diffinery.com/acme/website/actions` shows the newest runs first, 25 per page.
150+
151+- 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.
152+- 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.
153+- 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.
154+
155+The list updates live while you watch it.
156+
157+### Run page
158+
159+Click a run to open `https://diffinery.com/acme/website/actions/runs/<run-id>`. It shows:
160+
161+- 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.
162+- 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.
163+- **Artifacts**, when jobs uploaded any, with their size and age. Click a name to download it as a `.tar.gz` archive.
164+- **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.
165+
166+### Job page and logs
167+
168+Click a job to open its log at `https://diffinery.com/acme/website/actions/runs/<run-id>/job/<job-id>`.
169+
170+- 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.
171+- While the job runs, new lines stream in live.
172+- 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**.
173+- **Job summary** renders the Markdown that steps wrote to `$GITHUB_STEP_SUMMARY`.
174+- **Outputs** lists the job's outputs.
175+- **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.
176+
177+Secret values, and values registered with `::add-mask::`, are replaced by `***` before log lines leave the runner.
178+
179+## Start a workflow manually
180+
181+A workflow with an `on: workflow_dispatch` trigger can be started on demand. You need write access.
182+
183+```yaml
184+name: Deploy
185+on:
186+ workflow_dispatch:
187+ inputs:
188+ environment:
189+ description: Where to deploy
190+ type: choice
191+ options: [staging, production]
192+ default: staging
193+ dry-run:
194+ type: boolean
195+ default: true
196+jobs:
197+ deploy:
198+ runs-on: ubuntu-latest
199+ steps:
200+ - uses: actions/checkout@v4
201+ - run: ./deploy.sh "${{ inputs.environment }}" "${{ inputs.dry-run }}"
202+```
203+
204+Input types, defaults and validation are described in [Events that trigger workflows](/docs/actions/events#workflow_dispatch).
205+
206+### In the web UI
207+
208+1. Open the **Actions** tab and click the workflow in the sidebar.
209+2. Expand **Run workflow** (shown when the workflow on the default branch has a `workflow_dispatch` trigger).
210+3. Pick the branch or tag under **Use workflow from**.
211+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 `*`.
212+5. Click **Run workflow**. You land on the new run's page.
213+
214+The form is built from the input definitions on the default branch; the run itself uses the workflow file at the ref you pick.
215+
216+### With the CLI
217+
218+```sh
219+diffinery workflow run deploy.yml -R acme/website --ref main \
220+ -f environment=production -f dry-run=false
221+```
222+
223+| Flag | Meaning |
224+| --- | --- |
225+| `-r, --ref REF` | Branch or tag to run on. Default: the repository's default branch. |
226+| `-f, --field KEY=VALUE` | An input value. Repeat for more inputs. |
227+| `-R, --repo OWNER/NAME` | Repository. Default: inferred from the `origin` remote. |
228+| `--json` | Print the API response as JSON. |
229+
230+The command prints the run ID and URL, and suggests `diffinery run watch <run-id>` to follow it.
231+
232+### With the API
233+
234+Use a token with the write scope (see [Tokens](/docs/accounts/tokens)):
235+
236+```sh
237+curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/workflows/deploy.yml/dispatches \
238+ -H "Authorization: Bearer dfy_..." \
239+ -H "Content-Type: application/json" \
240+ -d '{"ref": "main", "inputs": {"environment": "production", "dry-run": false}}'
241+```
242+
243+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:
244+
245+```json
246+{ "run_id": "3f9c2a7d0b8e4c1f9a6d5e2b7c4a1f08", "html_url": "https://diffinery.com/acme/website/actions/runs/3f9c2a7d0b8e4c1f9a6d5e2b7c4a1f08" }
247+```
248+
249+| Status | Message | Cause |
250+| --- | --- | --- |
251+| 403 | `write access is required to run workflows` | Your role or token scope is below write. |
252+| 404 | `workflow deploy.yml not found at main` | No such file at that ref, or the file is invalid (the errors follow a colon). |
253+| 422 | `unknown ref <ref>` | The branch or tag doesn't exist. |
254+| 422 | `workflow does not have a workflow_dispatch trigger` | Add `on: workflow_dispatch` to the file at that ref. |
255+| 422 | `input '<name>' is required` | A required input is missing or empty and has no default. |
256+| 422 | `input '<name>' must be one of <a>, <b>` | A `choice` input got a value that is not in `options`. |
257+
258+### With MCP
259+
260+The `dispatch_workflow` tool takes `owner`, `repo`, `workflow`, an optional `ref` (default: the default branch) and an optional `inputs` object. See [MCP tools](/docs/reference/mcp-tools).
261+
262+## Follow runs and read logs
263+
264+### From the CLI
265+
266+| Command | What it does |
267+| --- | --- |
268+| `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). |
269+| `diffinery run view <run-id>` | Shows a run and its jobs, with job IDs and failure reasons. |
270+| `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. |
271+| `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`. |
272+| `diffinery run rerun <run-id>` | Re-runs a run (see [Re-run a run](#re-run-a-run)). |
273+| `diffinery run cancel <run-id>` | Cancels a queued or running run. |
274+| `diffinery workflow list` | Lists the workflows on the default branch, whether each is `active` or `invalid`, and their triggers. |
275+| `diffinery workflow validate [files...]` | Checks workflow files locally (see [Workflow syntax](/docs/actions/workflow-syntax#validate-workflow-files)). |
276+
277+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](/docs/reference/cli).
278+
279+A typical loop for an agent or a script after pushing:
280+
281+```sh
282+run=$(diffinery run list -R acme/website --commit "$(git rev-parse HEAD)" --json | jq -r '.[0].id')
283+diffinery run watch "$run" -R acme/website || {
284+ job=$(diffinery run view "$run" -R acme/website --json | jq -r '.jobs[] | select(.conclusion == "failure") | .id' | head -n1)
285+ diffinery run logs "$job" -R acme/website | tail -n 100
286+}
287+```
288+
289+### From the API
290+
291+| Request | Returns |
292+| --- | --- |
293+| `GET /api/v3/repos/{owner}/{repo}/actions/workflows` | Workflows on the default branch, with `state` (`active` or `invalid`), `errors` and `triggers`. |
294+| `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. |
295+| `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`. |
296+| `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`. |
297+| `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}` | One run with its `jobs`. |
298+| `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. |
299+| `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/jobs` | The run's jobs, paginated with `per_page` (default 30) and `page`. |
300+| `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. |
301+| `GET /api/v3/repos/{owner}/{repo}/actions/jobs/{job_id}` | One job, with its steps, outputs, failure and summary. |
302+| `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. |
303+| `GET /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | The run's artifacts with `archive_download_url`. |
304+| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` | Cancels the run (`202`). Needs write. |
305+| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` | Re-runs the run (`201`, `{"run_id": ...}`). Needs write. |
306+| `POST /api/v3/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | Same as `rerun` (see below). |
307+
308+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, not numbers. 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](/docs/api).
309+
310+### From MCP
311+
312+Agents can use `list_workflow_runs`, `get_workflow_run`, `get_job_logs` (the last 400 lines by default, set `tail` for more), `rerun_workflow`, `cancel_workflow` and `dispatch_workflow`. See [MCP tools](/docs/reference/mcp-tools).
313+
314+## Cancel a run
315+
316+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.
317+
318+Cancelling stops every unfinished job's container and marks those jobs `cancelled` with the reason `Cancelled by <login>`. Jobs in later levels that have no `if:` condition are skipped; a job with `if: always()` or `if: cancelled()` still runs, as on GitHub. The run's conclusion is `cancelled`.
319+
320+## Re-run a run
321+
322+Click **Re-run all jobs** on a completed run's page, run `diffinery run rerun <run-id>`, call `POST /actions/runs/{run_id}/rerun`, or use the `rerun_workflow` MCP tool. You need write access.
323+
324+A re-run is a new run with:
325+
326+- the same workflow file (read again from the original commit), commit, ref, event, event payload and inputs,
327+- the same run number and an attempt number one higher (shown as `attempt 2`, and available as `github.run_attempt`),
328+- a new run ID, and you as the actor.
329+
330+It fails with `422 workflow file no longer valid at this commit` if the file can't be loaded from that commit any more.
331+
332+> **Note:** Diffinery always re-runs every job. `diffinery run rerun --failed` and `POST .../rerun-failed-jobs` are accepted for compatibility and behave like a full re-run. On GitHub a re-run keeps the same run ID; on Diffinery it gets a new one.
333+
334+## Job summaries
335+
336+Append Markdown to the file named by `$GITHUB_STEP_SUMMARY` in any step:
337+
338+```yaml
339+- name: Report coverage
340+ run: |
341+ {
342+ echo "## Coverage"
343+ echo ""
344+ echo "| File | Lines |"
345+ echo "| --- | --- |"
346+ echo "| src/app.ts | 94% |"
347+ } >> "$GITHUB_STEP_SUMMARY"
348+```
349+
350+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}`.
351+
352+## Status checks on commits and pull requests
353+
354+Every job reports a check on the commit the run is about:
355+
356+- For `push`, `merge_group`, `workflow_dispatch`, `schedule`, `workflow_run`, `release` and `repository_dispatch` runs, that is the run's commit.
357+- 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.
358+
359+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.
360+
361+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](/docs/repositories/branch-protection).
362+
363+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](/docs/collaboration/checks-and-statuses).
364+
365+How Diffinery decides a required check when several reporters use the same name:
366+
367+- When Diffinery Actions reports a check with that name, only its result counts. Another reporter can't satisfy or override it.
368+- 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).
369+- Results that agents report never satisfy a required check.
370+- Of the reports that count, any failure fails the check, and any unfinished one keeps it pending.
371+- Check runs created by Diffinery Actions can't be changed through the REST API.
372+
373+> **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.
374+
375+## Concurrency
376+
377+Set `concurrency` at the top of a workflow to make runs in the same group exclusive:
378+
379+```yaml
380+concurrency:
381+ group: deploy-${{ github.ref }}
382+ cancel-in-progress: true
383+```
384+
385+- 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.
386+- 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`.
387+- 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.
388+
389+Differences from GitHub: GitHub keeps at most one pending run per group and cancels older pending ones; Diffinery lets several runs wait and starts them as the group frees up. Job-level `concurrency` (`jobs.<id>.concurrency`) is accepted but not enforced. If the group expression fails to evaluate, the run has no concurrency group.
390+
391+## Notifications
392+
393+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](/docs/accounts/notifications).
394+
395+## Troubleshooting
396+
397+| Symptom | Cause and fix |
398+| --- | --- |
399+| 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`. |
400+| 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. |
401+| 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. |
402+| `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](/docs/actions/runners). |
403+| 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. |
404+| `Could not start a runner: ...` | The job's container failed to start. Re-run the run. |
405+| `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. |
406+| `This runner is being replaced by a new version. The job keeps running for up to 14 minutes before it is cancelled.` | Diffinery is rolling out a new runner version. Jobs that finish within 14 minutes are unaffected; longer ones are cancelled and need a re-run. |
407+| `Error evaluating 'if': ...` | The job's `if:` expression failed at runtime. See [Expressions](/docs/actions/expressions). |
408+| A step can't reach a secret | Secrets are configured in the repository or organization settings. See [Secrets and variables](/docs/actions/secrets-and-variables). |
409+| `git push` from a job fails | The job's `GITHUB_TOKEN` has read access to the repository only. Push with a personal or agent token stored as a secret. |
410+| `<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](/docs/actions/environments). |
411+| `invalid environment name '<name>'` | The job's `environment` name is empty, longer than 255 characters, or contains `/`, `\` or control characters. |
412+| An `on: release` workflow runs three times | Creating a published release sends `created`, `published` and `released`. Add `types: [published]`. See [release](/docs/actions/events#release). |
413+| 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. |
addedapps/diffinery/docs/actions/reusable-workflows.md+405−0View file
@@ -1,0 +1,405 @@
1+---
2+title: Reusable workflows
3+description: Call one workflow from another with jobs.<id>.uses, pass inputs and secrets, read outputs, and share workflows across repositories.
4+---
5+
6+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).
7+
8+## A minimal example
9+
10+The called workflow declares its inputs, secrets and outputs under `on.workflow_call`:
11+
12+```yaml
13+# .github/workflows/build.yml
14+name: Build
15+on:
16+ workflow_call:
17+ inputs:
18+ node-version:
19+ type: string
20+ default: "22"
21+ run-e2e:
22+ type: boolean
23+ default: false
24+ secrets:
25+ NPM_TOKEN:
26+ required: true
27+ outputs:
28+ version:
29+ description: The version that was built
30+ value: ${{ jobs.build.outputs.version }}
31+
32+jobs:
33+ build:
34+ runs-on: ubuntu-latest
35+ outputs:
36+ version: ${{ steps.meta.outputs.version }}
37+ steps:
38+ - uses: actions/checkout@v4
39+ - uses: actions/setup-node@v4
40+ with:
41+ node-version: ${{ inputs.node-version }}
42+ - run: npm ci
43+ env:
44+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
45+ - id: meta
46+ run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
47+ - if: ${{ inputs.run-e2e }}
48+ run: npm run test:e2e
49+```
50+
51+The caller uses it as a job:
52+
53+```yaml
54+# .github/workflows/ci.yml
55+name: CI
56+on: push
57+
58+jobs:
59+ build:
60+ uses: ./.github/workflows/build.yml
61+ with:
62+ run-e2e: true
63+ secrets:
64+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
65+
66+ report:
67+ needs: build
68+ runs-on: ubuntu-latest
69+ steps:
70+ - run: echo "Built version ${{ needs.build.outputs.version }}"
71+```
72+
73+## Calling a workflow
74+
75+A calling job has `uses` instead of `runs-on` and `steps`. It accepts these keys:
76+
77+| Key | Description |
78+|---|---|
79+| `uses` | The workflow to call (required). See the formats below. |
80+| `with` | Values for the called workflow's inputs. |
81+| `secrets` | A mapping of secrets, or `inherit`. |
82+| `needs` | Jobs to wait for, as for any job. |
83+| `if` | Skip the call (and every job inside it) when false. |
84+| `name` | Display name, used as the prefix of the jobs inside the call. |
85+| `strategy` | Accepted, but a `matrix` isn't supported yet (see [Matrix on the calling job](#matrix-on-the-calling-job)). |
86+| `permissions`, `concurrency` | Accepted but have no effect on a calling job. |
87+
88+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.
89+
90+### Local workflows
91+
92+```yaml
93+uses: ./.github/workflows/build.yml
94+```
95+
96+The path starts with `./`, is relative to the repository root 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`).
97+
98+### Workflows in other repositories
99+
100+```yaml
101+uses: acme/shared-workflows/.github/workflows/deploy.yml@v2
102+```
103+
104+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.
105+
106+Which repositories you can call:
107+
108+| Called repository | Can be called from |
109+|---|---|
110+| Public | Any repository on Diffinery |
111+| Private | Only repositories with the **same owner** (the same user or organization) |
112+
113+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`.
114+
115+Inside a workflow called from another repository, a local `./` reference resolves in that other repository, at the commit the called workflow came from.
116+
117+> **Important:** A branch `<ref>` is resolved each time Diffinery plans or starts a job of the run, so if the branch moves during a run, later jobs can get a newer version of the called workflow. Pin cross-repository calls to a tag or a commit SHA when that matters.
118+
119+## Inputs
120+
121+Declare inputs under `on.workflow_call.inputs`. Each input must have a `type`:
122+
123+| Key | Description |
124+|---|---|
125+| `type` | `string`, `boolean` or `number` (required). |
126+| `required` | `true` to make the call fail when the input is missing or empty. |
127+| `default` | Value used when the caller doesn't pass one. Expressions are allowed. |
128+| `description` | Free text. |
129+
130+The caller passes values with `with`. Rules:
131+
132+- 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>'`.
133+- `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.
134+- Values are converted to the declared type: a `boolean` input is true for `true` or `"true"`; a `number` input goes through `Number()`.
135+- A missing optional input with no default is `""`, `false` or `0` depending on its type.
136+- A missing required input fails the jobs inside the call with `jobs.<id>: input '<name>' is required by <uses>`.
137+
138+Inside the called workflow, read inputs with `${{ inputs.<name> }}`.
139+
140+## Secrets
141+
142+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)).
143+
144+Pass specific secrets with a mapping. Each value is an expression evaluated against the caller's secrets:
145+
146+```yaml
147+secrets:
148+ DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
149+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
150+```
151+
152+Or pass everything the caller has with `inherit`:
153+
154+```yaml
155+secrets: inherit
156+```
157+
158+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.
159+
160+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.
161+
162+Secrets for a cross-repository call always come from the **calling** repository and its organization, never from the repository that holds the workflow.
163+
164+## Outputs
165+
166+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`:
167+
168+```yaml
169+on:
170+ workflow_call:
171+ outputs:
172+ image:
173+ value: ${{ jobs.publish.outputs.image }}
174+```
175+
176+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.
177+
178+`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.
179+
180+## Contexts inside a called workflow
181+
182+| Context | Value inside the called workflow |
183+|---|---|
184+| `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. |
185+| `inputs` | The resolved inputs. |
186+| `secrets` | The passed secrets and `GITHUB_TOKEN`, plus the environment's secrets in a job with `environment:`. |
187+| `vars` | The calling repository's variables, plus the environment's variables in a job with `environment:`. |
188+| `env` | The called workflow's own `env` (workflow and job level). The caller's `env` is not passed. |
189+| `needs` | Job ids as written inside the called workflow. |
190+
191+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:` and a `token:` from a secret: the job's `GITHUB_TOKEN` can only read its own repository.
192+
193+`permissions` that control the job token (`id-token` and `packages`) are read from the called workflow itself: its workflow-level `permissions` or the job's own. The calling job's `permissions` isn't consulted.
194+
195+## Environments in called workflows
196+
197+A job inside a called workflow can deploy to an [environment](/docs/actions/environments) with `environment:`, and the environment's name and `url` can use the called workflow's `inputs`:
198+
199+```yaml
200+jobs:
201+ deploy:
202+ runs-on: ubuntu-latest
203+ environment:
204+ name: ${{ inputs.environment }}
205+ url: https://${{ inputs.environment }}.example.com
206+ steps:
207+ - run: ./deploy.sh
208+```
209+
210+How it works:
211+
212+- 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.
213+- 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.
214+- 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.
215+- 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 started by agents only from environments set to protected branches only).
216+
217+## How calls appear in runs and checks
218+
219+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>`.
220+
221+Ordering follows GitHub:
222+
223+- Jobs inside the call that have no `needs` wait for the calling job's `needs`.
224+- A job that `needs` the calling job waits for every job inside the call.
225+- If the calling job's `if` is false, or its `needs` failed, every job inside the call is reported as skipped.
226+
227+## Nesting
228+
229+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:
230+
231+```text
232+jobs.a/b/c/d/e: reusable workflows can be nested at most 4 levels deep
233+```
234+
235+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`.
236+
237+## Matrix on the calling job
238+
239+A `strategy.matrix` on a job that calls a reusable workflow isn't supported yet. The run fails with:
240+
241+```text
242+jobs.deploy: a matrix on a job that calls a reusable workflow is not supported yet
243+```
244+
245+Put the matrix on the jobs inside the called workflow instead, passing the list as an input:
246+
247+```yaml
248+# caller
249+jobs:
250+ test:
251+ uses: ./.github/workflows/test.yml
252+ with:
253+ versions: '["20", "22"]'
254+
255+# .github/workflows/test.yml
256+on:
257+ workflow_call:
258+ inputs:
259+ versions:
260+ type: string
261+ required: true
262+jobs:
263+ test:
264+ runs-on: ubuntu-latest
265+ strategy:
266+ matrix:
267+ node: ${{ fromJSON(inputs.versions) }}
268+ steps:
269+ - uses: actions/setup-node@v4
270+ with:
271+ node-version: ${{ matrix.node }}
272+ - run: node --version
273+```
274+
275+Or write one calling job per value.
276+
277+## Example: a shared deploy pipeline
278+
279+An organization keeps its deploy workflow in `acme/shared-workflows` (private) and calls it from `acme/website` (same owner, so allowed):
280+
281+```yaml
282+# acme/shared-workflows: .github/workflows/deploy.yml
283+name: Deploy
284+on:
285+ workflow_call:
286+ inputs:
287+ environment:
288+ type: string
289+ required: true
290+ dry-run:
291+ type: boolean
292+ default: false
293+ secrets:
294+ CLOUDFLARE_API_TOKEN:
295+ required: true
296+ outputs:
297+ url:
298+ value: ${{ jobs.deploy.outputs.url }}
299+
300+jobs:
301+ deploy:
302+ runs-on: ubuntu-latest
303+ environment: ${{ inputs.environment }} # acme/website's staging or production environment
304+ outputs:
305+ url: ${{ steps.deploy.outputs.url }}
306+ steps:
307+ - uses: actions/checkout@v4 # checks out acme/website, the caller
308+ - run: npm ci && npm run build
309+ - id: deploy
310+ env:
311+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
312+ run: |
313+ if [ "${{ inputs.dry-run }}" = "true" ]; then
314+ npx wrangler deploy --dry-run
315+ else
316+ npx wrangler deploy --env "${{ inputs.environment }}"
317+ fi
318+ echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"
319+```
320+
321+```yaml
322+# acme/website: .github/workflows/release.yml
323+name: Release
324+on:
325+ push:
326+ branches: [main]
327+
328+jobs:
329+ test:
330+ runs-on: ubuntu-latest
331+ steps:
332+ - uses: actions/checkout@v4
333+ - run: npm ci && npm test
334+
335+ staging:
336+ needs: test
337+ uses: acme/shared-workflows/.github/workflows/deploy.yml@v2
338+ with:
339+ environment: staging
340+ secrets: inherit
341+
342+ production:
343+ needs: staging
344+ uses: acme/shared-workflows/.github/workflows/deploy.yml@v2
345+ with:
346+ environment: production
347+ secrets:
348+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
349+
350+ announce:
351+ needs: production
352+ runs-on: ubuntu-latest
353+ steps:
354+ - run: echo "Live at ${{ needs.production.outputs.url }}"
355+```
356+
357+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.
358+
359+## Differences from GitHub
360+
361+| Area | Diffinery |
362+|---|---|
363+| Where workflows come from | Only repositories on the same Diffinery instance. |
364+| Private repositories | Callable only from repositories with the same owner. |
365+| Matrix on the calling job | Not supported yet. |
366+| `permissions` on the calling job | Ignored. The called workflow's own `permissions` control `id-token` and `packages`. |
367+| `concurrency` | Only the top-level workflow's `concurrency` applies. A called workflow's `concurrency` and a calling job's `concurrency` are ignored. |
368+| Number of calls per run | At most 20. |
369+| OIDC `job_workflow_ref` claim | The top-level workflow's reference, not the called workflow's. See [OpenID Connect (OIDC)](/docs/actions/oidc). |
370+| Branch references | Re-resolved whenever a job of the run is planned or started. |
371+
372+## Errors
373+
374+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`.
375+
376+| Message | Fix |
377+|---|---|
378+| `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 `./`. |
379+| `a job that calls a reusable workflow ('uses') cannot have 'steps'` (or `'runs-on'`) | Move the steps into the called workflow. |
380+| `input '<name>' must declare a 'type'` | Add `type: string`, `boolean` or `number` to the input under `on.workflow_call.inputs`. |
381+| `input '<name>': invalid type '<t>' (expected one of string, boolean, number)` | `choice` and `environment` exist only for `workflow_dispatch`. |
382+| `workflow_call output '<name>' must be a mapping with a 'value'` | Give each output a `value:` expression. |
383+
384+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:
385+
386+| Message | Fix |
387+|---|---|
388+| `jobs.<id>.uses: <uses> not found` | The file doesn't exist at that path and ref. |
389+| `jobs.<id>.uses: unknown ref <ref> in <owner>/<repo>` | The branch, tag or SHA doesn't exist in the called repository. |
390+| `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. |
391+| `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`. |
392+| `jobs.<id>.uses: <uses> is invalid: ...` | The called workflow has a syntax error; the details follow. |
393+| `jobs.<id>.with.<key>: <uses> has no input named '<key>'` | Remove the key from `with`, or declare the input. |
394+| `jobs.<id>: a matrix on a job that calls a reusable workflow is not supported yet` | Move the matrix into the called workflow. |
395+| `jobs.<id>: reusable workflows can be nested at most 4 levels deep` | Flatten the chain of calls. |
396+| `a run can call at most 20 reusable workflows` | Combine calls. |
397+
398+Errors in the **values passed** fail the jobs inside the call when they're about to start:
399+
400+| Message | Fix |
401+|---|---|
402+| `jobs.<id>: input '<name>' is required by <uses>` | Pass the input with `with`, or give it a default. |
403+| `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. |
404+
405+For the full workflow syntax, see [Workflow syntax](/docs/actions/workflow-syntax). For expressions, see [Expressions and contexts](/docs/actions/expressions).
addedapps/diffinery/docs/actions/runners.md+352−0View file
@@ -1,0 +1,352 @@
1+---
2+title: Runners
3+description: Where Diffinery workflow jobs run: hosted Linux runners in Cloudflare Containers, and self-hosted runners you start with diffinery runner.
4+---
5+
6+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.
7+
8+## How runs-on picks a runner
9+
10+Diffinery reads the job's `runs-on` labels (case-insensitive) and decides:
11+
12+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).
13+2. Otherwise, if every label is one Diffinery hosts, the job runs on a hosted runner.
14+3. Otherwise the job fails at once with:
15+
16+```text
17+No Diffinery runner matches runs-on: windows-latest. Linux (ubuntu-*) runners are hosted; anything else needs runs-on: [self-hosted, ...] and a registered runner.
18+```
19+
20+| Label in `runs-on` | Runs on |
21+|---|---|
22+| `ubuntu-latest`, `ubuntu-24.04`, `ubuntu-22.04`, or any other `ubuntu-*` label | Hosted runner (always the same Ubuntu 24.04, x64 image) |
23+| `linux` | Hosted runner |
24+| `x64`, `amd64` | Hosted runner |
25+| `diffinery`, or any `diffinery-*` label | Hosted runner |
26+| `self-hosted` (plus any labels you add) | A self-hosted runner offering all the labels |
27+| Anything else, for example `windows-latest`, `macos-latest`, `macos-14`, `gpu` without `self-hosted` | The job fails with the message above |
28+
29+Things to know:
30+
31+- 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`.
32+- `runs-on` can be a string, a list, or a mapping with `labels`. A `group` in the mapping form is ignored; only `labels` count.
33+- `runs-on` is evaluated once per job, before the matrix is expanded, so it can use `github`, `inputs`, `vars` and `needs` but **not** `matrix`. `runs-on: ${{ matrix.os }}` evaluates to `null` and fails with "No Diffinery runner matches runs-on: null". Use a literal label (hosted runners are all the same Linux image anyway) or split the job.
34+
35+```yaml
36+jobs:
37+ test:
38+ runs-on: ubuntu-latest # hosted
39+ steps:
40+ - run: echo "hosted"
41+ gpu:
42+ runs-on: [self-hosted, linux, gpu] # waits for a runner started with --labels linux,gpu
43+ steps:
44+ - run: nvidia-smi
45+```
46+
47+## Hosted runners
48+
49+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](/docs/actions/caching-and-artifacts)).
50+
51+### Machine
52+
53+| Property | Value |
54+|---|---|
55+| Platform | Cloudflare Containers, instance type `standard-2` (see Cloudflare's Containers documentation for its vCPU, memory and disk) |
56+| Architecture | `linux/amd64` (`runner.arch` is `X64`) |
57+| Operating system | Ubuntu 24.04 (`runner.os` is `Linux`) |
58+| User | `root` (there is no `sudo` command; you don't need it) |
59+| Network | Outbound internet access; no inbound connections |
60+| Workspace | `/workspace/<repository name>` (`GITHUB_WORKSPACE`) |
61+| Temp directory | `/workspace/_temp` (`RUNNER_TEMP`) |
62+| Tool cache | `/opt/hostedtoolcache` (`RUNNER_TOOL_CACHE`, `AGENT_TOOLSDIRECTORY`) |
63+| Locale | `LANG=C.UTF-8`, `LC_ALL=C.UTF-8` |
64+| Concurrency | Up to 100 hosted job containers run at once across diffinery.com |
65+
66+### The Ubuntu 24.04 image
67+
68+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:
69+
70+| Group | Installed |
71+|---|---|
72+| Base tools | `ca-certificates`, `curl`, `git`, `git-lfs`, `jq`, `zip`, `unzip`, `xz-utils`, `tar`, `gzip`, `pkg-config`, `openssh-client`, `gnupg` |
73+| Build tools | `build-essential` (gcc, g++, make and the C library headers) |
74+| Python | Ubuntu's `python3` (3.12), `python3-pip`, `python3-venv`, `python-is-python3` (so `python` runs `python3`) |
75+| Node.js | Node.js 22.22.0 with npm, plus corepack shims activated for pnpm 10.28.0 and yarn 1.22.22 |
76+| Docker | Docker Engine (`docker-ce`), the Docker CLI, `containerd.io`, the Buildx plugin, the Compose plugin, `iptables` |
77+
78+Image settings that affect your steps:
79+
80+- `git config --system safe.directory '*'` is set, and Git LFS is installed system-wide.
81+- `PIP_BREAK_SYSTEM_PACKAGES=1` is set, so `pip install` works without a virtual environment, as on GitHub's hosted runners.
82+- `COREPACK_ENABLE_DOWNLOAD_PROMPT=0` is set, so corepack never stops to ask.
83+- The apt package lists are removed to keep the image small. Run `apt-get update` before `apt-get install`.
84+
85+Anything else (Go, Java, Ruby, Rust, .NET, PowerShell, browsers, cloud CLIs) is **not** preinstalled. Install it in a step:
86+
87+```yaml
88+steps:
89+ - uses: actions/setup-go@v5 # downloads Go from go.dev
90+ with:
91+ go-version: "1.23"
92+ - run: apt-get update && apt-get install -y --no-install-recommends openjdk-17-jdk-headless
93+ - run: pip install awscli
94+```
95+
96+`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](/docs/actions/compatibility) for their supported inputs.
97+
98+> **Note:** Because jobs run as `root` and `sudo` isn't installed, `sudo apt-get install ...` fails with `sudo: command not found`. Drop the `sudo`.
99+
100+### Docker on hosted runners
101+
102+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:
103+
104+- **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 a few seconds; jobs that never touch Docker don't pay for it. If the daemon can't start, the step fails with `The Docker daemon did not start:` followed by the end of the daemon's log.
105+- **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.
106+- **DNS.** The daemon gives containers the DNS servers `1.1.1.1` and `8.8.8.8`.
107+- **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](/docs/packages/container-registry).
108+- **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.
109+
110+The full guide is [Docker in workflows](/docs/actions/docker).
111+
112+### Environment variables
113+
114+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.
115+
116+### Limits and timeouts
117+
118+| Limit | Value | What happens |
119+|---|---|---|
120+| 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." |
121+| 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." |
122+| Step timeout | `steps[*].timeout-minutes`, no default | The step fails with "The step has timed out after N minutes." |
123+| Matrix size | 256 jobs per matrix | The job fails with "A matrix may produce at most 256 jobs." |
124+| Log line length | 16,384 characters | Longer lines are cut and end with `…` |
125+| Platform updates | When Diffinery deploys a new runner version, a running job keeps going for up to 14 minutes | The log shows "This runner is being replaced by a new version. The job keeps running for up to 14 minutes before it is cancelled." |
126+
127+If the container stops unexpectedly (for example it runs out of memory), the job fails with `The runner stopped unexpectedly (exit code N).`
128+
129+### Windows and macOS
130+
131+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, ...]`.
132+
133+## Self-hosted runners
134+
135+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:
136+
137+- **macOS**: build and sign iOS or macOS apps with Xcode on a Mac.
138+- **GPUs**: run CUDA tests or model evaluation on a machine with a GPU.
139+- **Private networks**: deploy to or test against servers that Cloudflare can't reach (a database behind a VPN, an on-premises cluster).
140+- **Long-lived caches and big toolchains**: the runner's work directory and tool cache persist between jobs.
141+
142+### Requirements
143+
144+- **Node.js 22 or later** on the machine.
145+- **The `diffinery` CLI.** Install it with:
146+
147+ ```sh
148+ curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
149+ ```
150+
151+- **The runner agent**, a single Node.js file named `agent.mjs`. Build it from a checkout of the Diffinery source:
152+
153+ ```sh
154+ pnpm install
155+ pnpm --filter @diffinery/runner build # produces containers/runner/dist/agent.mjs
156+ ```
157+
158+ 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.
159+- **A token with the `admin` scope**, belonging to a person who has the **admin** role on each repository the runner serves. Claiming jobs requires admin access; a weaker role or token scope gets `403 admin access required`. Agents can't run self-hosted runners, because an agent's role is capped at write. Create the token under **Settings → Access tokens**, and restrict it to the repositories the runner serves (see [Personal access tokens](/docs/accounts/tokens)).
160+- **Bash** (the default shell for `run:` steps; `sh` is used when bash is missing), `git` and `tar`, which the built-in actions use.
161+- **Docker**, only if jobs use `container:`, `services:` or Docker actions.
162+
163+### Start a runner
164+
165+1. Log in once, or set `DIFFINERY_TOKEN` (and `DIFFINERY_URL` if you don't use `https://diffinery.com`):
166+
167+ ```sh
168+ diffinery login --token dfy_...
169+ ```
170+
171+2. Start the runner for one or more repositories:
172+
173+ ```sh
174+ diffinery runner -R acme/website --labels macos,arm64 --agent /opt/diffinery/agent.mjs
175+ ```
176+
177+ It prints the labels it offers and waits:
178+
179+ ```text
180+ Runner my-mac offering [self-hosted, macos, arm64] for acme/website
181+ Waiting for jobs. Press Ctrl-C to stop.
182+ ```
183+
184+3. Target it from a workflow:
185+
186+ ```yaml
187+ jobs:
188+ ios:
189+ runs-on: [self-hosted, macos, arm64]
190+ steps:
191+ - uses: actions/checkout@v4
192+ - run: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' test
193+ ```
194+
195+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`.
196+
197+### diffinery runner options
198+
199+| Flag | Short | Default | Description |
200+|---|---|---|---|
201+| `--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. |
202+| `--labels a,b` | `-l` | none | Extra labels this runner offers, comma separated. `self-hosted` is always offered. |
203+| `--name NAME` | `-n` | The machine's host name | Runner name. Shown on claimed jobs and used for the default work directory. |
204+| `--once` | | off | Run at most one job, then exit with its exit code. Prints "No jobs waiting." and exits 0 if nothing is waiting. |
205+| `--agent PATH` | `-a` | `DIFFINERY_RUNNER_AGENT` | Path to the runner agent (`agent.mjs`). Required one way or the other. |
206+| `--work-dir DIR` | `-w` | `~/.cache/diffinery/runner/<name>` (or `$XDG_CACHE_HOME/diffinery/runner/<name>`) | Working directory for jobs. |
207+| `--interval SECONDS` | | `5` | Seconds between polls when idle. |
208+| `--quiet` | `-q` | off | Don't echo step output locally. Logs still go to Diffinery. |
209+
210+The full generated reference is at [CLI reference](/docs/reference/cli).
211+
212+### Labels and job matching
213+
214+- 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.
215+- 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]`.
216+- `runs-on: [self-hosted]` alone can be taken by any runner serving the repository.
217+- When several jobs are waiting, a runner takes the oldest run's jobs first.
218+- Labels have no meaning to Diffinery beyond matching. Choose names that describe the machine (`macos`, `arm64`, `gpu`, `vpn-prod`).
219+
220+### How jobs are claimed
221+
222+1. When a self-hosted job is ready to start, Diffinery marks it **waiting** and creates a queued check on the commit.
223+2. Each runner polls `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` with its name and labels, every `--interval` seconds per repository while idle. The response is `204 No Content` when nothing matches, or the job with a fresh job token:
224+
225+ ```http
226+ POST /api/v3/repos/acme/website/actions/runners/claim
227+ Authorization: Bearer dfy_...
228+ Content-Type: application/json
229+
230+ {"name": "my-mac", "labels": ["macos", "arm64"]}
231+ ```
232+
233+ ```json
234+ {"job_id": "…", "run_id": "…", "name": "ios", "token": "djt_…", "api_url": "https://diffinery.com"}
235+ ```
236+
237+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.
238+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).
239+
240+A job that no runner claims fails after 6 hours. While a job waits, the run page shows it as queued.
241+
242+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 (wrong token, missing admin access, or a mistyped repository).
243+
244+### What a job sees on a self-hosted runner
245+
246+- The job runs **directly on the machine**, as the user that started `diffinery runner`, with that user's files, network and permissions.
247+- 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.
248+- The workspace is `<work dir>/<repository name>` and is **not** wiped between jobs. `actions/checkout` cleans it by default (`clean: true`).
249+- 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.
250+- `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.
251+- 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.
252+- `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.
253+
254+### Run as a service with systemd
255+
256+On Linux, run the runner as a systemd service under a dedicated user so it starts at boot and restarts after failures.
257+
258+1. Create a user and put the CLI and agent where it can read them:
259+
260+ ```sh
261+ sudo useradd --system --create-home --shell /bin/bash diffinery-runner
262+ sudo install -m 0755 ~/.local/bin/diffinery /usr/local/bin/diffinery
263+ sudo install -D -m 0644 containers/runner/dist/agent.mjs /opt/diffinery/agent.mjs
264+ ```
265+
266+2. Store the token in a file only root can read:
267+
268+ ```sh
269+ echo 'DIFFINERY_TOKEN=dfy_...' | sudo tee /etc/diffinery-runner.env >/dev/null
270+ sudo chmod 600 /etc/diffinery-runner.env
271+ ```
272+
273+3. Create `/etc/systemd/system/diffinery-runner.service`:
274+
275+ ```text
276+ [Unit]
277+ Description=Diffinery self-hosted runner
278+ After=network-online.target
279+ Wants=network-online.target
280+
281+ [Service]
282+ User=diffinery-runner
283+ WorkingDirectory=/home/diffinery-runner
284+ EnvironmentFile=/etc/diffinery-runner.env
285+ Environment=DIFFINERY_URL=https://diffinery.com
286+ Environment=DIFFINERY_RUNNER_AGENT=/opt/diffinery/agent.mjs
287+ ExecStart=/usr/bin/node /usr/local/bin/diffinery runner -R acme/website --labels linux,gpu --name gpu-box-1 --quiet
288+ Restart=always
289+ RestartSec=10
290+
291+ [Install]
292+ WantedBy=multi-user.target
293+ ```
294+
295+ 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.
296+
297+4. Start it and follow its output:
298+
299+ ```sh
300+ sudo systemctl daemon-reload
301+ sudo systemctl enable --now diffinery-runner
302+ journalctl -u diffinery-runner -f
303+ ```
304+
305+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.
306+
307+For a clean machine per job, run `diffinery runner --once` inside a fresh virtual machine or container and throw it away afterwards; `--once` exits with the job's exit code.
308+
309+### Docker on self-hosted runners
310+
311+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:
312+
313+```text
314+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.
315+```
316+
317+or, when the CLI exists but the daemon isn't running:
318+
319+```text
320+Service containers (jobs.<id>.services) need Docker, and the Docker daemon on this runner is not reachable: ...
321+```
322+
323+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](/docs/actions/docker) and [Container registry](/docs/packages/container-registry).
324+
325+### macOS, Windows and GPU machines
326+
327+- **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.
328+- **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`).
329+- **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 }`.
330+
331+### Security considerations
332+
333+A self-hosted runner runs code from your repository's workflows on your machine. Before you start one:
334+
335+- **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.
336+- **Jobs run as the runner's user.** A job can read anything that user can, including the runner's own token (from the CLI's config file `~/.config/diffinery/config.json` or the service's environment) and other repositories' checkouts in the work directory. Use a dedicated OS user with no other access, and a token restricted to the repositories the runner serves.
337+- **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](/docs/actions/environments).
338+- **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 `--once` in a throwaway VM.
339+- **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.
340+
341+## Troubleshooting
342+
343+| Symptom | Cause and fix |
344+|---|---|
345+| `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`. |
346+| `No Diffinery runner matches runs-on: null` | `runs-on` uses `matrix`, which isn't available there. Use a literal label. |
347+| 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. |
348+| `403 admin access required` | The runner's token or account lacks admin on the repository, or the token isn't `admin`-scoped. |
349+| `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`. |
350+| `sudo: command not found` on a hosted runner | Jobs run as root; remove `sudo`. |
351+| `E: Unable to locate package ...` | Run `apt-get update` first; the image ships without package lists. |
352+| `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. |
addedapps/diffinery/docs/actions/secrets-and-variables.md+355−0View file
@@ -1,0 +1,355 @@
1+---
2+title: Secrets and variables
3+description: Store credentials as encrypted secrets and settings as variables for workflows, per repository, organization or environment, and who can manage them.
4+---
5+
6+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.
7+
8+## What you can store
9+
10+| Kind | Scope | Manage in the web UI | Manage with the CLI and API | Who can manage |
11+|---|---|---|---|---|
12+| Repository secret | One repository | Yes | Yes | People with the admin role on the repository |
13+| Organization secret | Every repository the organization owns | No | Yes | Organization owners and admins, and site admins (people only) |
14+| Repository variable | One repository | Yes | Yes | People with the admin role on the repository |
15+| Environment secret | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository |
16+| Environment variable | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository |
17+
18+There are no organization-level variables. 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](/docs/actions/environments).
19+
20+## Names
21+
22+- 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 variables API accepts other names, which expressions may not be able to read, so keep to the same format.
23+- Names are stored in uppercase: saving `api_key` creates `API_KEY`. In expressions, `secrets.api_key` and `secrets.API_KEY` both find it.
24+- Repository and environment secrets, and environment variables, can't start with `GITHUB_`. Saving one fails with `invalid secret name` (or `invalid variable name`).
25+- 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.
26+
27+## Repository secrets
28+
29+### Secrets in the web UI
30+
31+1. Open the repository and go to **Settings → Secrets and variables**.
32+2. Under **Repository secrets**, enter a **Name** and a **Value**.
33+3. Click **Save secret**. Setting an existing name replaces its value.
34+
35+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.
36+
37+### Secrets with the CLI
38+
39+The CLI needs a token from a person with admin access to the repository. Install it with:
40+
41+```sh
42+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
43+```
44+
45+The value comes from `--body`, from stdin when piped, or from a hidden prompt:
46+
47+```sh
48+diffinery secret set CLOUDFLARE_API_TOKEN -R acme/website # prompts for the value
49+diffinery secret set NPM_TOKEN -R acme/website --body "$NPM_TOKEN"
50+diffinery secret set DEPLOY_KEY -R acme/website < ~/.ssh/deploy_key # multi-line values from a file
51+diffinery secret list -R acme/website
52+diffinery secret delete NPM_TOKEN -R acme/website
53+```
54+
55+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](/docs/reference/cli) for every flag.
56+
57+### Secrets with the API
58+
59+```sh
60+# Create or replace a secret (204 No Content)
61+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \
62+ -H "Authorization: Bearer dfy_..." \
63+ -H "Content-Type: application/json" \
64+ -d '{"value": "npm_..."}'
65+
66+# List names (values are never returned)
67+curl https://diffinery.com/api/v3/repos/acme/website/actions/secrets \
68+ -H "Authorization: Bearer dfy_..."
69+
70+# Delete (204 No Content)
71+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \
72+ -H "Authorization: Bearer dfy_..."
73+```
74+
75+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](/docs/api) for the full endpoint list.
76+
77+## Organization secrets
78+
79+Organization secrets are available to every workflow in every repository the organization owns. They're managed with the CLI or the API; there's no web page for them.
80+
81+```sh
82+diffinery secret set SENTRY_AUTH_TOKEN --org acme
83+diffinery secret list --org acme
84+diffinery secret delete SENTRY_AUTH_TOKEN --org acme
85+```
86+
87+```sh
88+curl -X PUT https://diffinery.com/api/v3/orgs/acme/actions/secrets/SENTRY_AUTH_TOKEN \
89+ -H "Authorization: Bearer dfy_..." \
90+ -H "Content-Type: application/json" \
91+ -d '{"value": "sntrys_..."}'
92+```
93+
94+`GET /api/v3/orgs/{org}/actions/secrets` lists names and update times, and `DELETE /api/v3/orgs/{org}/actions/secrets/{name}` removes one. Only organization owners and admins (and site admins) can use these endpoints; anyone else gets `403 organization admins only`.
95+
96+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`.
97+
98+## Variables
99+
100+### Variables in the web UI
101+
102+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.
103+
104+### Variables with the CLI
105+
106+```sh
107+diffinery variable set DEPLOY_REGION -R acme/website --body eu-west-1
108+echo "https://staging.example.com" | diffinery variable set STAGING_URL -R acme/website
109+diffinery variable list -R acme/website
110+```
111+
112+There is no `variable delete` command; delete variables in the web UI or with the API.
113+
114+### Variables with the API
115+
116+The API has GitHub's endpoints (`POST` to create, `PATCH` to update) plus a Diffinery `PUT` that creates or replaces in one call:
117+
118+| Request | Needs | Does |
119+|---|---|---|
120+| `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`. |
121+| `GET /api/v3/repos/{owner}/{repo}/actions/variables/{name}` | write role | Returns `{"name", "value", "created_at": null, "updated_at": null}`, or `404 Not Found` |
122+| `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`. |
123+| `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` |
124+| `PUT /api/v3/repos/{owner}/{repo}/actions/variables/{name}` with `{"value": "..."}` | admin role, people only | Creates or replaces a variable (204) |
125+| `DELETE /api/v3/repos/{owner}/{repo}/actions/variables/{name}` | admin role, people only | Deletes a variable (204) |
126+
127+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.
128+
129+```sh
130+curl -X POST https://diffinery.com/api/v3/repos/acme/website/actions/variables \
131+ -H "Authorization: Bearer dfy_..." \
132+ -H "Content-Type: application/json" \
133+ -d '{"name": "DEPLOY_REGION", "value": "eu-west-1"}'
134+```
135+
136+> **Note:** Variable names are stored in uppercase, but the API's `DELETE` matches the name exactly. Send the uppercase name (`DEPLOY_REGION`), or the request returns 204 without deleting anything.
137+
138+## Environment secrets and variables
139+
140+An [environment](/docs/actions/environments) such as `production` can have its own secrets and variables. Only jobs that name the environment with `environment:` receive them:
141+
142+```yaml
143+jobs:
144+ deploy:
145+ runs-on: ubuntu-latest
146+ environment: production
147+ steps:
148+ - run: npx wrangler deploy
149+ env:
150+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # the production secret
151+ DEPLOY_URL: ${{ vars.DEPLOY_URL }} # the production variable
152+```
153+
154+They're managed 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.
155+
156+```sh
157+# Environment secret: 201 when new, 204 when replaced
158+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
159+ -H "Authorization: Bearer dfy_..." \
160+ -H "Content-Type: application/json" \
161+ -d '{"value": "cf_..."}'
162+
163+# Environment variable: 201
164+curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \
165+ -H "Authorization: Bearer dfy_..." \
166+ -H "Content-Type: application/json" \
167+ -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}'
168+```
169+
170+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](/docs/actions/environments#environment-secrets-and-variables).
171+
172+### Rules for environment secrets
173+
174+Environment secrets are meant for credentials that only reviewed code should use, so they're handed out more carefully than repository secrets:
175+
176+- **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.`
177+- **Protected branches by default.** Adding the first secret to an environment that has no branch policy sets it to **protected branches only**.
178+- **Never on pull request refs.** Environment secrets never go to runs on `refs/pull/*` (`pull_request` runs), whatever the policy.
179+- **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.
180+- **Agents' tags don't count.** A tag that an agent created or moved never matches an environment's tag patterns.
181+
182+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](/docs/actions/environments#who-receives-environment-secrets).
183+
184+## Using secrets and variables in workflows
185+
186+```yaml
187+name: Deploy
188+on:
189+ push:
190+ branches: [main]
191+
192+env:
193+ REGION: ${{ vars.DEPLOY_REGION }}
194+
195+jobs:
196+ deploy:
197+ runs-on: ubuntu-latest
198+ steps:
199+ - uses: actions/checkout@v4
200+ - name: Deploy
201+ env:
202+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
203+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
204+ run: npx wrangler deploy --env production
205+```
206+
207+- Pass a secret to a step through `env` and read it from the environment, rather than writing `${{ secrets.X }}` into the script text.
208+- A secret or variable that doesn't exist evaluates to an empty string.
209+- 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.
210+- A reusable workflow only receives the secrets its caller passes (a mapping or `secrets: inherit`). See [Reusable workflows](/docs/actions/reusable-workflows).
211+
212+### Precedence
213+
214+From highest to lowest, for a name that exists in several places:
215+
216+1. `GITHUB_TOKEN` and `DIFFINERY_TOKEN`: always the job's own token.
217+2. Environment secrets, in a job that uses the environment.
218+3. Repository secrets (in a called reusable workflow: the secrets the caller passed).
219+4. Organization secrets.
220+
221+For variables, an environment variable replaces a repository variable with the same name in a job that uses the environment.
222+
223+### Which runs receive secrets
224+
225+Every workflow run in the repository receives its repository and organization secrets, whatever the event: `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)).
226+
227+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. `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.
228+
229+In practice: **anyone who can push a branch can run a workflow that uses the repository's secrets**, including agents with write access. Give write access only to people and agents you trust with the repository secrets, and keep production credentials in an [environment](/docs/actions/environments) set to protected branches only (see [Branch protection](/docs/repositories/branch-protection)) rather than in repository secrets. Pull request runs and branches that aren't protected never receive them.
230+
231+## Masking in logs
232+
233+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.
234+
235+- Every secret passed to the job is masked, including `GITHUB_TOKEN`.
236+- Values shorter than 3 characters aren't masked, because masking them would make logs unreadable. Don't use very short secrets.
237+- Multi-line secrets are masked line by line as well as whole.
238+- 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.
239+- A job output whose value contains a secret is dropped, with the warning `Skip output 'NAME' since it may contain secret.`
240+- Variables are never masked.
241+
242+### Masking other values with ::add-mask::
243+
244+Mask a value that isn't a secret, such as a token you fetch during the job, by printing the `add-mask` workflow command:
245+
246+```yaml
247+- name: Get a short-lived token
248+ run: |
249+ token=$(curl -fsS -X POST https://auth.example.com/token -d "key=$API_KEY" | jq -r .token)
250+ echo "::add-mask::$token"
251+ echo "SESSION_TOKEN=$token" >> "$GITHUB_ENV"
252+ env:
253+ API_KEY: ${{ secrets.API_KEY }}
254+```
255+
256+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.
257+
258+### Debug logging
259+
260+Set a secret or a variable named `ACTIONS_STEP_DEBUG` to `true` to show `::debug::` messages in the log and set `RUNNER_DEBUG=1` for steps. Turn it off again when you're done.
261+
262+## GITHUB_TOKEN and the job token
263+
264+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.
265+
266+What it can do:
267+
268+| Access | Allowed |
269+|---|---|
270+| Clone and fetch the repository that runs the workflow (including Git LFS downloads) | Yes |
271+| Read that repository through the REST API | Yes |
272+| Push, or write through the API (comments, labels, pull requests, releases, statuses) | **No**, whatever the workflow's `permissions` say |
273+| Read or write any other repository, even a public one | No |
274+| Use the container registry | Decided by the `packages` permission (see below) |
275+| Request an OIDC token | Only with `permissions: id-token: write` |
276+
277+How the workflow's `permissions` apply:
278+
279+| Permission | Effect |
280+|---|---|
281+| `packages` | Controls the registry. No `permissions` block or `write-all`: push and pull. `packages: write`: push and pull. Pushes reach only packages linked to the workflow's repository, or new packages, which get linked to it. `packages: read` or `read-all`: pull. A mapping without `packages`: none. A job's `permissions` replaces the workflow's. |
282+| `id-token: write` | Lets the job request OIDC tokens (in the workflow's or the job's `permissions`, or `write-all`). See [OpenID Connect (OIDC)](/docs/actions/oidc). |
283+| `contents`, `pull-requests`, `issues` and the other scopes | Accepted for compatibility; they don't change what the token can do. |
284+
285+To push commits, comment on pull requests or reach other repositories from a workflow, store a personal access token (created by a person, with the `write` scope and restricted to the repositories it needs) as a secret and use it instead:
286+
287+```yaml
288+- uses: actions/checkout@v4
289+ with:
290+ token: ${{ secrets.BOT_TOKEN }}
291+- run: |
292+ git config user.name "release-bot"
293+ git config user.email "release-bot@users.noreply.diffinery.com"
294+ git commit -am "Bump version" && git push
295+```
296+
297+Pushes made this way still follow branch protection. See [Personal access tokens](/docs/accounts/tokens).
298+
299+## Encryption at rest
300+
301+Secret values are encrypted before they're stored, with envelope encryption:
302+
303+- Each repository, each organization and each environment has its own AES-256-GCM key, derived from the instance's master key with HKDF.
304+- 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.
305+- 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.
306+- The API, CLI and web UI only ever return names and update times.
307+
308+## Agents, secrets and variables
309+
310+Agents (AI accounts) can't add, change or delete secrets or variables, whatever their role:
311+
312+- The organization secret endpoints (listing included) return `403 agents cannot manage secrets` for any agent's token.
313+- 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`.
314+- 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.
315+- 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).
316+- 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 variable values. An agent has the write role unless a person gave it more (its **Maximum role**, or an explicit admin role on the repository).
317+
318+Agents can still push branches and open pull requests, and the workflows that run for them receive repository and organization secrets (see [Which runs receive secrets](#which-runs-receive-secrets)). Treat write access for an agent as access to those secrets. Environment secrets are different: runs started by agents only get them from environments set to protected branches only, and never on pull request refs. See [Agents](/docs/agents/overview).
319+
320+## Audit log
321+
322+Changes made on the settings page are recorded in the audit log as `repo.secret.set`, `repo.secret.delete`, `repo.variable.set` and `repo.variable.delete`, with the name but never the value. Environment changes made with the API are recorded as `environment.secret.set`, `environment.secret.delete`, `environment.update`, `environment.delete`, `environment.branch_policy.create` and `environment.branch_policy.delete`. See [Audit log](/docs/security/audit-log).
323+
324+## Limits
325+
326+| Limit | Value |
327+|---|---|
328+| Name format | `^[A-Za-z_][A-Za-z0-9_]*$`, stored uppercase; repository and environment secrets and environment variables can't start with `GITHUB_` |
329+| Shortest value that is masked | 3 characters |
330+| Organization variables | Not available |
331+| Number and size of secrets | Diffinery doesn't enforce a limit of its own; every secret is decrypted for every job, so keep them few and small |
332+
333+## Troubleshooting
334+
335+| Problem | Cause and fix |
336+|---|---|
337+| `422 invalid secret name` | The name has characters other than letters, digits and `_`, starts with a digit, or (repository secrets) starts with `GITHUB_`. |
338+| `invalid secret name "...": use letters, digits and _, not starting with a digit` | The CLI checks the name before sending it. |
339+| `422 value is required (send it over HTTPS; it is encrypted at rest)` | The JSON body has no string `value`. |
340+| ``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`. |
341+| `secret value is empty` | The CLI got an empty value from `--body` or stdin. |
342+| `403 agents cannot manage secrets` | Use a person's token. |
343+| `403 agents cannot manage variables` | Agents can't create, change or delete variables. Use a person's token. |
344+| `403 admin access required` | The token's owner isn't a repository admin, or the token lacks the `admin` scope. |
345+| `403 this token needs the write scope` | The token has only the `read` scope and can't change anything. Use an `admin` token. |
346+| `403 this token needs the admin scope` | Organization secrets need an `admin`-scoped token, even for listing. |
347+| `403 this token is limited to repositories and can't change account settings` | Organization secrets need a token without a repository list. |
348+| `403 organization admins only` | Only organization owners and admins manage organization secrets. |
349+| `403 only repository admins (people, not agents) can manage environments` | Environments and their branch policies need a person with the admin role. |
350+| `404 environment not found` | Create the environment with `PUT .../environments/{name}` before adding secrets or variables. |
351+| 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:`. |
352+| `<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](/docs/actions/environments#deployment-branch-policies). |
353+| 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. |
354+| `***` 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. |
355+| `git push` with `GITHUB_TOKEN` fails with `403 write access required` | The job token is read-only. Use a personal token stored as a secret. |
addedapps/diffinery/docs/actions/workflow-syntax.md+761−0View file
@@ -1,0 +1,761 @@
1+---
2+title: Workflow syntax
3+description: 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.
4+---
5+
6+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.
7+
8+For when workflows run, see [Events that trigger workflows](/docs/actions/events). For `${{ }}` expressions and contexts, see [Expressions](/docs/actions/expressions). For how runs execute and how to watch them, see [Actions overview](/docs/actions/overview).
9+
10+## Workflow files
11+
12+- Workflow files live directly in `.github/workflows/` or `.diffinery/workflows/` and end in `.yml` or `.yaml`. Files in subdirectories are ignored.
13+- A file holds one YAML document whose top level is a mapping. Anchors, aliases and merge keys (`<<: *defaults`) are supported.
14+- Keys are case-sensitive, as on GitHub.
15+- 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.
16+
17+A complete example that uses most features:
18+
19+```yaml
20+name: CI
21+run-name: CI for ${{ github.ref_name }} by ${{ github.actor }}
22+
23+on:
24+ push:
25+ branches: [main, "release/**"]
26+ paths-ignore: ["docs/**"]
27+ pull_request:
28+ workflow_dispatch:
29+
30+permissions:
31+ contents: read
32+ packages: write
33+
34+env:
35+ NODE_ENV: test
36+
37+defaults:
38+ run:
39+ shell: bash
40+
41+concurrency:
42+ group: ci-${{ github.ref }}
43+ cancel-in-progress: true
44+
45+jobs:
46+ lint:
47+ runs-on: ubuntu-latest
48+ steps:
49+ - uses: actions/checkout@v4
50+ - run: npm ci && npm run lint
51+
52+ test:
53+ needs: lint
54+ name: Test (node ${{ matrix.node }})
55+ runs-on: ubuntu-latest
56+ timeout-minutes: 30
57+ strategy:
58+ fail-fast: false
59+ max-parallel: 2
60+ matrix:
61+ node: [20, 22]
62+ include:
63+ - node: 22
64+ coverage: true
65+ services:
66+ postgres:
67+ image: postgres:16
68+ env:
69+ POSTGRES_PASSWORD: postgres
70+ ports: ["5432:5432"]
71+ options: --health-cmd "pg_isready" --health-interval 5s --health-retries 10
72+ outputs:
73+ version: ${{ steps.meta.outputs.version }}
74+ steps:
75+ - uses: actions/checkout@v4
76+ - uses: actions/setup-node@v4
77+ with:
78+ node-version: ${{ matrix.node }}
79+ - id: meta
80+ run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
81+ - run: npm ci
82+ - run: npm test
83+ env:
84+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
85+ - if: ${{ matrix.coverage }}
86+ run: npm run coverage
87+
88+ report:
89+ needs: test
90+ if: always()
91+ runs-on: ubuntu-latest
92+ steps:
93+ - run: echo "Tests ${{ needs.test.result }}, version ${{ needs.test.outputs.version }}"
94+```
95+
96+## Validate workflow files
97+
98+Check files before you push with the CLI. It uses the same parser as the server and needs no network access or token:
99+
100+```sh
101+diffinery workflow validate # every file in .github/workflows and .diffinery/workflows
102+diffinery workflow validate .github/workflows/ci.yml
103+diffinery workflow validate --json # machine-readable results
104+```
105+
106+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.
107+
108+- **Errors** make the file invalid; it doesn't run.
109+- **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.
110+
111+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'`.
112+
113+## Top-level keys
114+
115+| Key | Support | Notes |
116+| --- | --- | --- |
117+| `name` | Supported | Display name of the workflow. Default: the file's path. |
118+| `run-name` | Supported | Title of each run. Evaluated with the `github`, `inputs` and `vars` contexts. |
119+| `on` | Supported | The events that start the workflow. Required. See [Events that trigger workflows](/docs/actions/events). |
120+| `permissions` | Partly | Parsed and validated. Only `id-token` and `packages` change what the job token can do. See [Permissions](#permissions). |
121+| `env` | Supported | Environment variables for every job. |
122+| `defaults` | Supported | `defaults.run.shell` and `defaults.run.working-directory` for every `run` step. |
123+| `concurrency` | Supported | Groups runs and optionally cancels older ones. See [Concurrency](#concurrency). |
124+| `jobs` | Supported | The jobs to run. Required, with at least one job. |
125+
126+Any other top-level key gives the warning `unknown top-level key '<key>'` and is ignored.
127+
128+### name
129+
130+```yaml
131+name: CI
132+```
133+
134+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.
135+
136+### run-name
137+
138+```yaml
139+run-name: Deploy ${{ inputs.environment }} by @${{ github.actor }}
140+```
141+
142+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.
143+
144+### on
145+
146+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:
147+
148+```yaml
149+on: push
150+```
151+
152+```yaml
153+on: [push, pull_request]
154+```
155+
156+```yaml
157+on:
158+ push:
159+ branches: [main]
160+ schedule:
161+ - cron: "0 3 * * 1-5"
162+```
163+
164+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](/docs/actions/events).
165+
166+### env
167+
168+```yaml
169+env:
170+ NODE_ENV: production
171+ RETRIES: 3
172+ VERBOSE: false
173+```
174+
175+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)`.
176+
177+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](/docs/actions/expressions#default-environment-variables).
178+
179+### defaults
180+
181+```yaml
182+defaults:
183+ run:
184+ shell: bash
185+ working-directory: ./app
186+```
187+
188+`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.
189+
190+### permissions
191+
192+```yaml
193+permissions:
194+ contents: read
195+ id-token: write
196+ packages: write
197+```
198+
199+`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'`.
200+
201+What the settings change on Diffinery:
202+
203+| Scope | Effect |
204+| --- | --- |
205+| `id-token` | `write` (or `write-all`) at the workflow level or on the job lets the job request OIDC tokens. See [OIDC](/docs/actions/oidc). |
206+| `packages` | Controls what `GITHUB_TOKEN` can do in the [container registry](/docs/packages/container-registry). The job's `permissions` replace the workflow's. With no `permissions` anywhere, the token gets `write`; with a mapping that leaves `packages` out, it gets none. `read-all` gives read and `write-all` gives write. |
207+| Every other scope | No effect. |
208+
209+> **Important:** On Diffinery the job's `GITHUB_TOKEN` always has read access to its own repository and nothing more, whatever `permissions` says. It can clone and read through the API, but it can't push, comment, label, create releases or send `repository_dispatch` events. Use a personal or agent token stored as a secret for that. See [Secrets and variables](/docs/actions/secrets-and-variables).
210+
211+### concurrency
212+
213+```yaml
214+concurrency: deploy-production
215+```
216+
217+```yaml
218+concurrency:
219+ group: ${{ github.workflow }}-${{ github.ref }}
220+ cancel-in-progress: true
221+```
222+
223+`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. Groups are shared by all workflows of the repository. See [Concurrency](/docs/actions/overview#concurrency) for the differences from GitHub.
224+
225+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'`.
226+
227+## Jobs
228+
229+`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`).
230+
231+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 '_'`.
232+
233+### Job keys
234+
235+| Key | Support | Notes |
236+| --- | --- | --- |
237+| `name` | Supported | Display name. May use `matrix`. |
238+| `needs` | Supported | Jobs that must finish first. |
239+| `if` | Supported | Condition evaluated before the job starts. |
240+| `runs-on` | Supported | Hosted Linux labels or self-hosted labels. Can't use `matrix`. `group` is ignored. |
241+| `environment` | Supported | The deployment environment: its branch policy, secrets and variables apply, and a deployment is recorded. Required reviewers and wait timers aren't supported. See [environment](#environment). |
242+| `outputs` | Supported | Values passed to dependent jobs through `needs`. |
243+| `env` | Supported | Environment variables for every step of the job. |
244+| `defaults` | Supported | `defaults.run` for the job's `run` steps. |
245+| `steps` | Supported | Required for jobs that don't call a reusable workflow. |
246+| `timeout-minutes` | Supported | Default and maximum 360. |
247+| `strategy` | Supported | `matrix` with `include` and `exclude`, `fail-fast`, `max-parallel`. |
248+| `continue-on-error` | Accepted, no effect | A failing job still fails the run. Use step-level `continue-on-error`. |
249+| `container` | Supported | Runs the steps in a Docker container. |
250+| `services` | Supported | Starts service containers next to the job. |
251+| `uses` | Supported | Calls a reusable workflow. |
252+| `with` | Supported | Inputs for a reusable workflow. |
253+| `secrets` | Supported | Secrets for a reusable workflow, or `inherit`. |
254+| `permissions` | Partly | See [Permissions](#permissions). |
255+| `concurrency` | Accepted, ignored | Job-level concurrency isn't enforced. |
256+| `snapshot` | Accepted, ignored | |
257+
258+Any other key gives the warning `unknown job key '<key>'`.
259+
260+### name (job)
261+
262+```yaml
263+jobs:
264+ test:
265+ name: Test on node ${{ matrix.node }}
266+```
267+
268+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)`.
269+
270+### needs
271+
272+```yaml
273+jobs:
274+ build:
275+ runs-on: ubuntu-latest
276+ steps: [{ run: make }]
277+ test:
278+ needs: build
279+ runs-on: ubuntu-latest
280+ steps: [{ run: make test }]
281+ deploy:
282+ needs: [build, test]
283+ runs-on: ubuntu-latest
284+ steps: [{ run: make deploy }]
285+```
286+
287+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:
288+
289+- `Job '<id>' depends on unknown job '<other>'`
290+- `Dependency cycle between jobs: a -> b -> a`
291+
292+Diffinery groups jobs into levels: level 1 holds the jobs that need nothing, level 2 the jobs that need only level 1 jobs, and so on. Every job of a level starts together, and the next level starts when every job of the current level has finished. A job therefore can start a little later than on GitHub, where it starts as soon as its own `needs` finish.
293+
294+By default a job runs only when every job it needs succeeded or was skipped. Use a status function in `if` to change that; see [Status check functions](/docs/actions/expressions#status-check-functions).
295+
296+### if (job)
297+
298+```yaml
299+jobs:
300+ deploy:
301+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
302+```
303+
304+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>`.
305+
306+### runs-on
307+
308+```yaml
309+runs-on: ubuntu-latest
310+```
311+
312+```yaml
313+runs-on: [self-hosted, linux, gpu]
314+```
315+
316+```yaml
317+runs-on:
318+ labels: [self-hosted, macos]
319+```
320+
321+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'`).
322+
323+| Labels | Where the job runs |
324+| --- | --- |
325+| 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. |
326+| Any list that includes `self-hosted` | A self-hosted runner whose labels include every label in the list. See [Runners](/docs/actions/runners). |
327+| 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.` |
328+
329+Labels may be expressions, evaluated with the `github`, `needs`, `inputs`, `vars` and `env` contexts. Unlike GitHub, `runs-on` can't use the `matrix` context: `runs-on: ${{ matrix.os }}` evaluates to `null` and the job fails with the message above. Use separate jobs for different operating systems.
330+
331+### environment
332+
333+```yaml
334+environment: production
335+```
336+
337+```yaml
338+environment:
339+ name: production
340+ url: https://www.example.com
341+```
342+
343+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](/docs/actions/environments) for the full guide.
344+
345+```yaml
346+jobs:
347+ deploy:
348+ runs-on: ubuntu-latest
349+ environment:
350+ name: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
351+ url: https://${{ github.ref_name == 'main' && 'www' || 'staging' }}.example.com
352+ steps:
353+ - uses: actions/checkout@v4
354+ - run: ./deploy.sh
355+ env:
356+ API_KEY: ${{ secrets.DEPLOY_API_KEY }} # an environment secret
357+ REGION: ${{ vars.REGION }} # an environment variable
358+```
359+
360+What happens when a job with `environment` starts:
361+
362+1. `name` and `url` are evaluated with the `github`, `inputs`, `vars` (repository variables) and `matrix` contexts. A `url` that refers to `steps.` is ignored, because step outputs don't exist yet when the job starts.
363+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.
364+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.`
365+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`, 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.
366+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 repository variables with the same names. `GITHUB_TOKEN` and `DIFFINERY_TOKEN` can't be overridden.
367+
368+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 records its own deployment. Read deployments with `GET /api/v3/repos/{owner}/{repo}/deployments`; webhooks subscribed to `deployment_status` get each status.
369+
370+Environment secrets aren't always handed out, even when the branch policy allows the ref:
371+
372+- 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.
373+- Runs started by agents get environment secrets only when the environment's policy is "protected branches only".
374+
375+In those cases the job still runs, with the environment's variables but without its secrets (an unset secret is `''`).
376+
377+Differences from GitHub:
378+
379+- Required reviewers, wait timers and other protection rules aren't supported, so a job never waits for approval. Creating an environment with them through the API fails with `required reviewers and wait timers are not supported yet`.
380+- `name` and `url` can't use the `needs` or `strategy` contexts, and `url` can't use step outputs.
381+- The job's `GITHUB_TOKEN` stays read-only; the environment doesn't change its permissions.
382+
383+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.
384+
385+### outputs
386+
387+```yaml
388+jobs:
389+ version:
390+ runs-on: ubuntu-latest
391+ outputs:
392+ tag: ${{ steps.tag.outputs.value }}
393+ steps:
394+ - id: tag
395+ run: echo "value=v$(date +%Y.%m.%d)" >> "$GITHUB_OUTPUT"
396+ release:
397+ needs: version
398+ runs-on: ubuntu-latest
399+ steps:
400+ - run: echo "Releasing ${{ needs.version.outputs.tag }}"
401+```
402+
403+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.
404+
405+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.
406+
407+### env (job)
408+
409+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).
410+
411+### defaults (job)
412+
413+Like the [top-level `defaults`](#defaults), for one job. Overrides the workflow's `defaults.run` key by key.
414+
415+### steps (job)
416+
417+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).
418+
419+### timeout-minutes (job)
420+
421+```yaml
422+timeout-minutes: 20
423+```
424+
425+A number or an expression (which may use `matrix`). 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.`
426+
427+### strategy
428+
429+```yaml
430+strategy:
431+ fail-fast: true
432+ max-parallel: 3
433+ matrix:
434+ os: [ubuntu-22.04, ubuntu-24.04]
435+ node: [20, 22]
436+ exclude:
437+ - os: ubuntu-22.04
438+ node: 20
439+ include:
440+ - node: 22
441+ experimental: true
442+ - os: ubuntu-24.04
443+ node: 23
444+```
445+
446+#### matrix
447+
448+`matrix` is a mapping of variables to lists. Diffinery creates one job for every combination, then applies `exclude` and `include` with GitHub's rules:
449+
450+1. Every combination of the lists is built.
451+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).
452+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.
453+
454+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'`.
455+
456+- A matrix with only `include` produces exactly the `include` entries.
457+- 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.
458+- A matrix may produce at most 256 jobs. A larger one fails the job with `A matrix may produce at most 256 jobs.`
459+- The `matrix` and `strategy` contexts give each job its values (`strategy.job-index`, `strategy.job-total`, `strategy.fail-fast`, `strategy.max-parallel`).
460+
461+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`, and at runtime `Error expanding matrix: <message>`.
462+
463+#### fail-fast
464+
465+`true` (the default), `false` or an expression. With `true`, when one job of the matrix fails, its unfinished sibling jobs are stopped and marked `cancelled` with the reason `Cancelled because <job name> failed (fail-fast).`
466+
467+#### max-parallel
468+
469+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.
470+
471+### continue-on-error (job)
472+
473+Accepted (`true`, `false` or an expression) but has no effect on Diffinery: a failing job fails the run and counts as a failure in `needs`. Put `continue-on-error: true` on the steps that may fail instead, or give dependent jobs `if: always()`.
474+
475+### container
476+
477+```yaml
478+container:
479+ image: node:22-bookworm
480+ credentials:
481+ username: ${{ secrets.REGISTRY_USER }}
482+ password: ${{ secrets.REGISTRY_TOKEN }}
483+ env:
484+ NODE_ENV: test
485+ ports: [8080]
486+ volumes: ["/data:/data"]
487+ options: --cpus 2
488+```
489+
490+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](/docs/actions/docker).
491+
492+### services
493+
494+```yaml
495+services:
496+ redis:
497+ image: redis:7
498+ ports: ["6379:6379"]
499+ options: --health-cmd "redis-cli ping" --health-interval 2s --health-retries 15
500+```
501+
502+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](/docs/actions/docker).
503+
504+### uses, with and secrets (reusable workflows)
505+
506+```yaml
507+jobs:
508+ call-tests:
509+ uses: ./.github/workflows/tests.yml
510+ with:
511+ node-version: 22
512+ secrets: inherit
513+ deploy:
514+ needs: call-tests
515+ uses: acme/shared-workflows/.github/workflows/deploy.yml@v2
516+ with:
517+ environment: production
518+ secrets:
519+ token: ${{ secrets.DEPLOY_TOKEN }}
520+```
521+
522+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'`.
523+
524+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`.
525+
526+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](/docs/actions/reusable-workflows).
527+
528+### permissions (job)
529+
530+Same syntax as the [top-level `permissions`](#permissions). For `packages`, the job's value replaces the workflow's. For `id-token`, either level can grant `write`.
531+
532+### concurrency (job)
533+
534+Accepted with the same syntax as the top-level `concurrency`, but not enforced: jobs don't wait for or cancel each other.
535+
536+## Steps
537+
538+Each item in `steps` is a mapping. A step runs either a shell script (`run`) or an action (`uses`), never both.
539+
540+| Key | Support | Notes |
541+| --- | --- | --- |
542+| `id` | Supported | Lets later steps read this step through `steps.<id>`. |
543+| `if` | Supported | Condition evaluated before the step. |
544+| `name` | Supported | Display name; may use expressions. |
545+| `uses` | Supported | An action: `owner/repo@ref`, `owner/repo/path@ref`, `./path` or `docker://image`. |
546+| `run` | Supported | A script run by `shell`. |
547+| `shell` | Supported | `bash`, `sh`, `python`, `pwsh`, `powershell` or a custom command with `{0}`. `cmd` fails. |
548+| `with` | Supported | Action inputs; scalar values only. |
549+| `env` | Supported | Environment variables for this step. |
550+| `continue-on-error` | Supported | `true`, `false` or an expression. |
551+| `timeout-minutes` | Partly | A number works; an expression is accepted but ignored. |
552+| `working-directory` | Supported | For `run` steps. |
553+
554+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'`.
555+
556+### id
557+
558+```yaml
559+- id: build
560+ run: echo "artifact=dist/app.tar.gz" >> "$GITHUB_OUTPUT"
561+- run: echo "${{ steps.build.outputs.artifact }} ${{ steps.build.outcome }}"
562+```
563+
564+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).
565+
566+### if (step)
567+
568+```yaml
569+- if: failure() && steps.test.outcome == 'failure'
570+ run: ./collect-crash-logs.sh
571+```
572+
573+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>`.
574+
575+### name (step)
576+
577+The step's title in the log. Without it, Diffinery uses `Run <uses>` or `Run <first line of the script>`, as on GitHub.
578+
579+### uses
580+
581+```yaml
582+- uses: actions/checkout@v4 # an action repository
583+- uses: actions/cache/restore@v4 # an action in a subdirectory
584+- uses: ./.github/actions/setup # a local action in the workspace
585+- uses: docker://alpine:3.20 # a Docker image
586+ with:
587+ entrypoint: /bin/sh
588+ args: -c "echo hello"
589+```
590+
591+- `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 and work for any version tag.
592+- 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.
593+- `./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?`
594+- `docker://image` runs a container; `with.args` and `with.entrypoint` set its arguments and entrypoint.
595+- `actions/github-script` isn't supported yet and fails with ``actions/github-script is not supported on Diffinery runners yet. Use a `run:` step with `node` or `curl` against $GITHUB_API_URL instead.``
596+- 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`.
597+
598+What each built-in action supports is in [GitHub Actions compatibility](/docs/actions/compatibility) and [Caching and artifacts](/docs/actions/caching-and-artifacts).
599+
600+### with
601+
602+```yaml
603+- uses: actions/setup-node@v4
604+ with:
605+ node-version: 22
606+ cache: npm
607+```
608+
609+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>`.
610+
611+### run
612+
613+```yaml
614+- name: Build
615+ run: |
616+ npm ci
617+ npm run build
618+```
619+
620+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>.`
621+
622+### shell
623+
624+| Value | Command | Notes |
625+| --- | --- | --- |
626+| (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). |
627+| `bash` | `bash --noprofile --norc -eo pipefail {0}` | |
628+| `sh` | `sh -e {0}` | |
629+| `python` | `python {0}` | Uses `python3` when there is no `python`. |
630+| `pwsh` | `pwsh -command ". '{0}'"` | PowerShell isn't installed on hosted runners; use a job container or self-hosted runner that has it. |
631+| `powershell` | `powershell -command ". '{0}'"` | Uses `pwsh` when there is no `powershell`. |
632+| `cmd` | | Fails with ``The `cmd` shell is not supported on Linux runners.`` |
633+| 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. |
634+
635+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`).
636+
637+> **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.
638+
639+### env (step)
640+
641+```yaml
642+- run: ./deploy.sh
643+ env:
644+ API_TOKEN: ${{ secrets.API_TOKEN }}
645+ TARGET: staging
646+```
647+
648+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`.
649+
650+### continue-on-error (step)
651+
652+```yaml
653+- run: npm run flaky-e2e
654+ continue-on-error: true
655+```
656+
657+`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.
658+
659+### timeout-minutes (step)
660+
661+```yaml
662+- run: npm test
663+ timeout-minutes: 10
664+```
665+
666+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.
667+
668+> **Note:** The parser accepts an expression here, but Diffinery currently ignores it and the step runs without its own timeout. Use a plain number.
669+
670+### working-directory
671+
672+```yaml
673+- run: npm test
674+ working-directory: packages/api
675+```
676+
677+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`.
678+
679+## Workflow commands
680+
681+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.
682+
683+| Command | Effect |
684+| --- | --- |
685+| `::add-mask::<value>` | Masks the value as `***` in every later log line. Values shorter than 3 characters are not masked. |
686+| `::group::<title>` and `::endgroup::` | Folds the lines between them under a title in the log. |
687+| `::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. |
688+| `::warning file=<f>,line=<n>,title=<t>::<message>` | Logs a warning. |
689+| `::notice file=<f>,line=<n>,title=<t>::<message>` | Logs a notice. |
690+| `::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`. |
691+| `::set-output name=<name>::<value>` | Sets a step output. Deprecated on GitHub but still works; prefer `$GITHUB_OUTPUT`. |
692+| `::save-state name=<name>::<value>` | Saves state for an action's post step (`STATE_<name>`). |
693+| `::stop-commands::<token>` ... `::<token>::` | Stops processing commands until the token line, so you can print untrusted text. |
694+| `::echo::on` and `::echo::off` | Echoes processed commands into the log. |
695+| `::set-env` and `::add-path` | Disabled, as on GitHub. They log ``The `set-env` command is disabled. Please upgrade to using Environment Files.`` |
696+
697+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.
698+
699+## Environment files
700+
701+Each step gets fresh, empty files whose paths are in these variables. Write to them to pass data on:
702+
703+| Variable | Format | Effect |
704+| --- | --- | --- |
705+| `GITHUB_OUTPUT` | `name=value`, or `name<<EOF` ... `EOF` for multi-line values | Sets the step's outputs (`steps.<id>.outputs.<name>`). |
706+| `GITHUB_ENV` | `NAME=value`, or `NAME<<EOF` ... `EOF` | Sets environment variables for the following steps. `NODE_OPTIONS` values containing `--require` or `-r` are ignored. |
707+| `GITHUB_PATH` | One directory per line | Prepends the directories to `PATH` for the following steps. |
708+| `GITHUB_STEP_SUMMARY` | Markdown | Adds to the job summary shown on the job page. |
709+| `GITHUB_STATE` | `name=value` | Saves state for an action's post step. |
710+
711+```yaml
712+- id: info
713+ run: |
714+ echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
715+ {
716+ echo "CHANGELOG<<EOF"
717+ git log -5 --oneline
718+ echo "EOF"
719+ } >> "$GITHUB_ENV"
720+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
721+```
722+
723+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.
724+
725+## Masking
726+
727+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.
728+
729+## Validation errors
730+
731+The most common errors from the parser, with what to do:
732+
733+| Message | Fix |
734+| --- | --- |
735+| `YAML syntax error: <detail>` | Fix the YAML at the reported line (indentation, quotes, tabs). |
736+| `workflow file is empty` | Add content, or delete the file. |
737+| `workflow is missing the 'on' trigger section` | Add `on:`. |
738+| `workflow is missing the 'jobs' section` | Add `jobs:`. |
739+| `job '<id>' is missing 'runs-on'` | Add `runs-on: ubuntu-latest`. |
740+| `job '<id>' must have a list of 'steps'` | Add `steps:` with at least one step. |
741+| `step must have either 'run' or 'uses'` | Give the step a `run` or `uses`. |
742+| `'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`. |
743+| `Invalid cron: <detail>` | Fix the `schedule` cron expression; see [schedule](/docs/actions/events#schedule). |
744+| `Unclosed expression: '${{' at offset <n> has no matching '}}'` | Close the expression. |
745+| `Invalid expression ${{ <expr> }}: <detail>` | Fix the expression syntax; see [Expressions](/docs/actions/expressions). |
746+| `<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. |
747+| `input '<name>' of type 'choice' must define 'options'` | Add `options:` to the `workflow_dispatch` input. |
748+| `input '<name>' must declare a 'type'` | `workflow_call` inputs need a `type`. |
749+
750+## Limits
751+
752+| Limit | Value |
753+| --- | --- |
754+| Job timeout | 360 minutes (default and maximum) |
755+| Jobs per matrix | 256 |
756+| Reusable workflow nesting | 4 levels |
757+| Reusable workflows called per run | 20 |
758+| Composite action nesting | 10 levels |
759+| Characters per log line | 16,384 (longer lines are cut and end with `…`) |
760+
761+See [Limits](/docs/reference/limits) for the rest of Diffinery's limits.
addedapps/diffinery/docs/admin/site-administration.md+183−0View file
@@ -1,0 +1,183 @@
1+---
2+title: Site administration
3+description: What site administrators do: manage people and access requests, invite people, issue sign-in links, read the audit log and check email delivery.
4+---
5+
6+Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, 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**.
7+
8+## Site administrators
9+
10+Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name and picture are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar with three pages:
11+
12+| Page | Path | What it is for |
13+| --- | --- | --- |
14+| **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links |
15+| **Email** | `/settings/admin/email` | Email delivery status, template previews and the log of every email |
16+| **Audit log** | `/settings/audit` | The most recent security-relevant events |
17+
18+Their **Settings → Profile** page shows **Site admin** as yes, and they have an **admin** badge in the users list.
19+
20+### Who becomes a site administrator
21+
22+The instance setting `BOOTSTRAP_ADMINS` (in `apps/diffinery/wrangler.jsonc` under `vars`) is a comma-separated list of email addresses and usernames. An account becomes a site administrator when:
23+
24+- it is created with an email address or username on the list (from an invite, or by verifying the email address through email recovery at `/auth/access`), or
25+- it was waiting for an invite (pending) and the person verifies an email address on the list at `/auth/access`.
26+
27+Bootstrap administrators don't need an invite. Adding an address to the list later doesn't promote an account that is already active, and there is no page for granting or removing site administration. See [Configuration](/docs/self-hosting/configuration) for the setting.
28+
29+### First sign-in for administrators
30+
31+On a new instance nobody has a passkey yet. A bootstrap administrator signs in through email recovery:
32+
33+1. Open `https://diffinery.com/auth/access`.
34+2. Confirm your email address with the one-time PIN that Cloudflare Access emails you.
35+3. On the page that follows (**Settings → Passkeys and sessions**), click **Add a passkey**.
36+
37+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](/docs/accounts/passkeys-and-sign-in).
38+
39+### Tokens and site administration
40+
41+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](/docs/accounts/tokens).
42+
43+## People
44+
45+**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`.
46+
47+### Pending access requests
48+
49+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.
50+
51+- **Approve** makes the account active. The person can then use Diffinery, but isn't added to any organization or repository; invite them to one or add them by username.
52+- **Deny** disables the account. The person gets a "Your account was disabled" email.
53+
54+See [Invitations](/docs/accounts/invitations#access-requests) for the requester's side.
55+
56+### Users
57+
58+The **Users** table lists every person (not organizations or agents) with their picture and username, email address, when they joined and their status. For each user except yourself you can pick a status in the menu and click **Save**. You see "User updated."
59+
60+| Status in the menu | Value | Meaning |
61+| --- | --- | --- |
62+| **Active** | `active` | Can sign in and use everything their roles allow |
63+| **Pending** | `pending` | Waiting for an invite: can sign in but only sees the **Waiting for an invite** page |
64+| **Disabled** | `disabled` | Can't use Diffinery |
65+
66+You can't change your own status ("you cannot change your own status").
67+
68+Pending accounts also become active without you when someone invites their email address, or when an organization or repository admin adds them by username or email with **Add people**.
69+
70+### Disable an account
71+
72+Set the user's status to **Disabled** and click **Save**. Immediately:
73+
74+- every browser session they have ends, so they are signed out everywhere, and passkey sign-in is refused with "Your account is disabled.",
75+- every access token the person has is revoked,
76+- agents they own stop working, because an agent can never do more than its owner,
77+- they get a "Your account was disabled" email.
78+
79+Their repositories, organizations and memberships stay as they are, but they can't be added to organizations or repositories while disabled ("octocat'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.
80+
81+Every status change is recorded in the audit log as `user.active`, `user.pending` or `user.disabled`.
82+
83+## Invites
84+
85+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.
86+
87+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](/docs/accounts/invitations) for all kinds of invites.
88+
89+## Sign-in links
90+
91+When someone loses their passkeys, a site administrator can give them a one-time link that adds a new passkey to their existing account.
92+
93+1. In the **Users** table, click **Sign-in link** next to the person.
94+2. Confirm "Create a one-time link that adds a passkey to octocat? Only send it to them."
95+3. The page shows **Sign-in link for octocat** 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.
96+
97+The person opens the link, creates a passkey and is signed in. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in#sign-in-link-from-an-administrator) for their side.
98+
99+| Rule | Value |
100+| --- | --- |
101+| Lifetime | 24 hours |
102+| Uses | Once |
103+| Disabled accounts | Refused with "enable this account first"; enable the account, then create the link |
104+| Effect | Adds a passkey and signs that browser in; the person's other passkeys stay |
105+| Audit | `signin_link.create` when you create it, `passkey.add` when it is used |
106+
107+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.
108+
109+## Audit log
110+
111+**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. Each row has **When**, **Actor** (and who an agent acted for), **Action**, **Target** and **Detail**. See [Audit log](/docs/security/audit-log) for every action and how to use it.
112+
113+## Email
114+
115+**Settings → Email** (`/settings/admin/email`) shows whether the instance can send email and lets you check every template.
116+
117+| Item | What it shows |
118+| --- | --- |
119+| **Sending** | "Cloudflare Email Sending is bound", or "not configured (no EMAIL binding): emails are logged, not sent" |
120+| **Queue** | `diffinery-mail`, or "not bound: emails are dropped" |
121+| **From** | The sender address set in `MAIL_FROM` (see [Configuration](/docs/self-hosting/configuration)) |
122+| **Last 24 hours** | How many emails were sent, failed and skipped |
123+
124+Buttons:
125+
126+- **Send me a test email** sends one test email to you right away and shows the result.
127+- **Send every template to me** queues one sample of every template (33 emails) to your address, ignoring your notification settings. They show up under **Recent emails** as they are sent.
128+
129+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`.
130+
131+**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.
132+
133+See [Notifications](/docs/accounts/notifications#every-email-diffinery-sends) for when each email is sent.
134+
135+### Email API for administrators
136+
137+| Method and path | Description | Needs |
138+| --- | --- | --- |
139+| `GET /api/v3/diffinery/admin/email` | `sending` and `queue` (whether they are configured), `from` (`MAIL_FROM`, or `null` when unset), `last_24h` counts (`sent`, `failed`, `skipped`) and the `last` email logged | site administrator, any token scope |
140+| `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 |
141+| `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` |
142+
143+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`, `push-bypassed`, `push-blocked`, `account-disabled`, `account-enabled`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted` and `test`.
144+
145+## Administration from the CLI
146+
147+The CLI (needs Node.js 22 or later) covers invites and, through `diffinery api`, any API endpoint:
148+
149+```sh
150+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
151+diffinery login
152+
153+# Invite someone to the instance (site administrators only)
154+diffinery invite friend@example.com
155+
156+# Invite someone to an organization as an admin
157+diffinery invite friend@example.com --org acme --role admin
158+
159+# Email delivery status
160+diffinery api GET /diffinery/admin/email
161+
162+# Send two sample emails to yourself (needs an admin-scoped token)
163+echo '{"only": ["signin", "test"]}' | diffinery api POST /diffinery/admin/emails/samples --input -
164+
165+# Everyone's recent emails, with their status
166+diffinery api GET /diffinery/emails -f all=1 -f per_page=100
167+```
168+
169+`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](/docs/reference/cli).
170+
171+Approving access requests, changing account status and creating sign-in links are only available on the **People and invitations** page.
172+
173+## Troubleshooting
174+
175+| Message | Cause and fix |
176+| --- | --- |
177+| `403 site administrators only` | Your account isn't a site administrator. |
178+| "you cannot change your own status" | Ask another site administrator. |
179+| "enable this account first" | Sign-in links can't be created for disabled accounts. Set the status to `active`, then create the link. |
180+| "enter a valid email address" | The invite form needs an address like `friend@example.com`. |
181+| `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`. |
182+| **Sending** says "not configured" | The Worker has no `send_email` binding, so emails are logged as skipped. See [Configuration](/docs/self-hosting/configuration). |
183+| Emails show as `failed` | The **Recent emails** detail has Cloudflare's error. Check the sending domain's DNS records and the queue. |
addedapps/diffinery/docs/agents/docs-for-agents.md+89−0View file
@@ -1,0 +1,89 @@
1+---
2+title: Docs for AI agents
3+description: How AI agents and tools can fetch these docs as Markdown, llms.txt, llms-full.txt, OpenAPI and over MCP, and how to point a coding agent at them.
4+---
5+
6+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.
7+
8+## Formats
9+
10+| What | URL | Content type |
11+| --- | --- | --- |
12+| Index of every page (the [llms.txt](https://llmstxt.org) convention) | `https://diffinery.com/llms.txt` | `text/plain` |
13+| Every page in one Markdown file | `https://diffinery.com/llms-full.txt` | `text/plain` |
14+| One page as Markdown | `https://diffinery.com/docs/<page>.md`, for example `/docs/actions/workflow-syntax.md` | `text/markdown` |
15+| The overview as Markdown | `https://diffinery.com/docs/index.md` | `text/markdown` |
16+| REST API as OpenAPI 3.1 | `https://diffinery.com/docs/api/openapi.json` | `application/json` |
17+| Search index used by the docs search box | `https://diffinery.com/docs/search.json` | `application/json` |
18+
19+`/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.
20+
21+A `.md` URL for a page that doesn't exist returns `404` with a short Markdown body that points to `llms.txt`.
22+
23+## Markdown pages
24+
25+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:
26+
27+```text
28+# Merge queue
29+
30+> 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.
31+
32+Source: https://diffinery.com/docs/collaboration/merge-queue
33+
34+...
35+```
36+
37+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.
38+
39+### Content negotiation
40+
41+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:
42+
43+```sh
44+curl -H "Accept: text/markdown" https://diffinery.com/docs/collaboration/pull-requests
45+```
46+
47+HTML pages also point at their Markdown version with `<link rel="alternate" type="text/markdown">` in the page head and a `Link` response header.
48+
49+### Copy page
50+
51+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.
52+
53+## Which format to use
54+
55+- **Answering one question:** fetch [llms.txt](/llms.txt), pick the page from its one-line descriptions, then fetch that page's `.md` URL. This keeps the context small.
56+- **Working on Diffinery for a while (for example, porting CI):** load [llms-full.txt](/llms-full.txt) once. It holds every page in reading order, separated by `---`.
57+- **Calling the REST API:** read the [OpenAPI document](/docs/api/openapi.json) or the [REST API reference](/docs/api). Both are generated from the server's router, so every endpoint that exists is listed. Use the [REST API guide](/docs/reference/rest-api) for authentication, errors and pagination.
58+- **Using MCP:** the [MCP tool reference](/docs/reference/mcp-tools) is generated from the server's tool list and matches `tools/list` exactly.
59+
60+## Searching and reading docs over MCP
61+
62+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:
63+
64+| Tool | Arguments | Returns |
65+| --- | --- | --- |
66+| `search_docs` | `query` | Up to 8 matching pages, each with `page`, `title`, `section`, the matching `heading`, `url`, `markdown_url` and a `snippet`. |
67+| `read_docs` | `page`: a slug like `actions/workflow-syntax`, `index`, or a docs URL (a `.md` suffix or `#anchor` is ignored) | `page`, `title` and `markdown`: the page in the same format as its `.md` URL. An unknown page returns an error that lists every slug. |
68+
69+```json
70+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"workflow_dispatch inputs"}}}
71+```
72+
73+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](/docs/agents/mcp) for connecting a client.
74+
75+## Pointing a coding agent at the docs
76+
77+Add a line like this to your agent's instructions (`CLAUDE.md`, `AGENTS.md`, `.cursorrules` or similar) in repositories hosted on Diffinery:
78+
79+```text
80+This repository is hosted on Diffinery (https://diffinery.com), a GitHub-compatible forge.
81+Docs index: https://diffinery.com/llms.txt (append .md to any docs URL for Markdown).
82+CI uses GitHub Actions syntax; check https://diffinery.com/docs/actions/compatibility.md
83+before using a GitHub-only feature. Use the diffinery CLI or the MCP server at
84+https://diffinery.com/mcp instead of gh.
85+```
86+
87+## Staying current
88+
89+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`, 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.
addedapps/diffinery/docs/agents/mcp.md+428−0View file
@@ -1,0 +1,428 @@
1+---
2+title: MCP server
3+description: 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.
4+---
5+
6+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 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.
7+
8+## Endpoint and transport
9+
10+| Property | Value |
11+| --- | --- |
12+| URL | `https://diffinery.com/mcp` |
13+| Transport | Streamable HTTP. Send JSON-RPC 2.0 messages with `POST`; every response is a single JSON body (`application/json`), never an event stream. |
14+| Sessions | None. The server is stateless and doesn't issue an `Mcp-Session-Id`; send the token with every request. |
15+| Batches | A JSON array of requests gets a JSON array of responses. |
16+| Notifications | Requests without an `id` (such as `notifications/initialized`) get `202 Accepted` with an empty body. |
17+| `GET /mcp` | `405` with `{"message": "Diffinery MCP: POST JSON-RPC here with Authorization: Bearer <token>"}`. There is no server-to-client stream. |
18+| 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`. |
19+| Capabilities | `tools` only (`listChanged: false`). No resources or prompts. |
20+| Methods | `initialize`, `ping`, `tools/list`, `tools/call`, and the notifications `notifications/initialized` and `notifications/cancelled` |
21+| 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) |
22+
23+## Authentication
24+
25+Every request needs a Diffinery token in the `Authorization` header:
26+
27+```http
28+Authorization: Bearer dfy_...
29+```
30+
31+`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](/docs/agents/overview)), or your own personal token (see [Tokens](/docs/accounts/tokens)). The token's scope limits what the tools can do: every tool that isn't read-only needs a token with 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)). There is no OAuth flow.
32+
33+| Response | Meaning |
34+| --- | --- |
35+| `401 {"message": "sign in required"}` | No `Authorization` header. |
36+| `401 {"message": "invalid, expired or revoked token"}` | The token is wrong, expired or revoked, or its account (or agent) is disabled. |
37+| `401 {"message": "unrecognised token; create one under Settings → Access tokens"}` | The header doesn't hold a `dfy_` token. |
38+
39+## Connect your client
40+
41+Create a token first: for an agent, in **Settings → Agents** or with `diffinery agent create` (which needs an `admin`-scoped token or a browser session); for yourself, in **Settings → Access tokens**. Keep it out of files you commit; most clients below can read it from an environment variable.
42+
43+### Claude Code
44+
45+```sh
46+claude mcp add --transport http diffinery https://diffinery.com/mcp \
47+ --header "Authorization: Bearer dfy_..."
48+```
49+
50+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:
51+
52+```json
53+{
54+ "mcpServers": {
55+ "diffinery": {
56+ "type": "http",
57+ "url": "https://diffinery.com/mcp",
58+ "headers": { "Authorization": "Bearer ${DIFFINERY_TOKEN}" }
59+ }
60+ }
61+}
62+```
63+
64+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.
65+
66+### Cursor
67+
68+Add to `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for all projects:
69+
70+```json
71+{
72+ "mcpServers": {
73+ "diffinery": {
74+ "url": "https://diffinery.com/mcp",
75+ "headers": { "Authorization": "Bearer dfy_..." }
76+ }
77+ }
78+}
79+```
80+
81+### VS Code
82+
83+Add to `.vscode/mcp.json`. VS Code prompts for the token and stores it securely:
84+
85+```json
86+{
87+ "inputs": [
88+ { "type": "promptString", "id": "diffinery-token", "description": "Diffinery token (dfy_...)", "password": true }
89+ ],
90+ "servers": {
91+ "diffinery": {
92+ "type": "http",
93+ "url": "https://diffinery.com/mcp",
94+ "headers": { "Authorization": "Bearer ${input:diffinery-token}" }
95+ }
96+ }
97+}
98+```
99+
100+### Claude Desktop and other clients that start local servers
101+
102+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:
103+
104+```json
105+{
106+ "mcpServers": {
107+ "diffinery": {
108+ "command": "npx",
109+ "args": ["-y", "mcp-remote", "https://diffinery.com/mcp", "--header", "Authorization:${AUTH_HEADER}"],
110+ "env": { "AUTH_HEADER": "Bearer dfy_..." }
111+ }
112+ }
113+}
114+```
115+
116+### Any other client
117+
118+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:
119+
120+```json
121+{ "mcpServers": { "diffinery": { "type": "http", "url": "https://diffinery.com/mcp", "headers": { "Authorization": "Bearer dfy_..." } } } }
122+```
123+
124+## Tools
125+
126+Every tool that works on a repository takes `owner` and `repo`. 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](/docs/reference/mcp-tools), and `tools/list` returns it at runtime.
127+
128+### Account and repositories
129+
130+| Tool | Read-only | What it does | Minimum role |
131+| --- | --- | --- | --- |
132+| `whoami` | Yes | The account the token acts as (`login`, `kind`) and, for an agent, `on_behalf_of` | Any token |
133+| `list_repos` | Yes | Repositories visible to you, optionally filtered by `owner` | Any token |
134+
135+### Code
136+
137+| Tool | Read-only | What it does | Minimum role |
138+| --- | --- | --- | --- |
139+| `list_tree` | Yes | Files and directories at a `path` and `ref` (default: the default branch) | read |
140+| `get_file` | Yes | A file's contents at a `ref`; binary files return only their size and `binary: true` | read |
141+| `list_branches` | Yes | Branches, marking the default and protected ones | read |
142+| `compare` | Yes | Commits ahead and behind, and the diff, between `base` and `head` | read |
143+
144+### Branches and commits
145+
146+| Tool | Read-only | What it does | Minimum role |
147+| --- | --- | --- | --- |
148+| `create_branch` | No | Create `branch` from another branch, tag or commit (`from`, default: the default branch) | write |
149+| `commit_files` | No | One commit on `branch` that writes `files` and removes `deletes`; see [Commit without git](#commit-without-git) | write |
150+
151+### Pull requests
152+
153+| Tool | Read-only | What it does | Minimum role |
154+| --- | --- | --- | --- |
155+| `list_pull_requests` | Yes | Up to 100 pull requests by `state` (`open`, `closed`, `all`) | read |
156+| `get_pull_request` | Yes | A pull request with its comments, reviews and mergeability (including blockers); `include_diff: true` adds the files and patches | read |
157+| `create_pull_request` | No | Open a pull request: `title`, `head`, optional `base`, `body`, `draft`, `intent`, `reviewers` | write |
158+| `comment` | No | Comment on an issue or pull request | triage |
159+| `review_pull_request` | No | Submit an `APPROVE`, `REQUEST_CHANGES` or `COMMENT` review, with optional line `comments` | read (write to approve or request changes) |
160+| `merge_pull_request` | No | Merge with `method`, `expected_head_sha`, `delete_branch`; queues the pull request when the base branch uses a merge queue | write |
161+| `dequeue_pull_request` | No | Remove a pull request from the merge queue | write |
162+
163+### Issues
164+
165+| Tool | Read-only | What it does | Minimum role |
166+| --- | --- | --- | --- |
167+| `list_issues` | Yes | Up to 100 issues by `state`, search text `q` and `label` | read |
168+| `get_issue` | Yes | An issue (or pull request) with its comments and reviews | read |
169+| `create_issue` | No | Open an issue with `title`, optional `body` and `labels` | read |
170+| `update_issue` | No | Change the `title`, `body`, `state` (`open`, `closed`) or `labels` of an issue or pull request | triage |
171+
172+### Workflows
173+
174+| Tool | Read-only | What it does | Minimum role |
175+| --- | --- | --- | --- |
176+| `list_workflow_runs` | Yes | The 30 newest runs, filtered by `branch`, `sha`, `pr_number` or `status` | read |
177+| `get_workflow_run` | Yes | A run with its jobs, their status, conclusion and failure reason | read |
178+| `get_job_logs` | Yes | A job's log as text; the last `tail` lines (default 400), with `complete` and `truncated` flags | read |
179+| `rerun_workflow` | No | Re-run a run | write |
180+| `cancel_workflow` | No | Cancel a queued or running run | write |
181+| `dispatch_workflow` | No | Start a `workflow_dispatch` run of `workflow` (for example `deploy.yml`) on `ref` with `inputs` | write |
182+
183+### Releases
184+
185+| Tool | Read-only | What it does | Minimum role |
186+| --- | --- | --- | --- |
187+| `list_releases` | Yes | Releases, newest first, up to `limit` (default 30, at most 100), with `total`. Drafts are included only for writers. | read |
188+| `get_release` | Yes | A release by `tag` or `id`, or the latest release when you pass neither | read |
189+| `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 |
190+| `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 |
191+| `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 |
192+| `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 |
193+
194+See [Releases](#releases-over-mcp) below for the result shape.
195+
196+### Docs
197+
198+| Tool | Read-only | What it does | Minimum role |
199+| --- | --- | --- | --- |
200+| `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 |
201+| `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 |
202+
203+### Sandboxes
204+
205+| Tool | Read-only | What it does | Minimum role |
206+| --- | --- | --- | --- |
207+| `sandbox_create` | No | Start a Linux sandbox with the repository checked out at `ref`; returns its `id` | write |
208+| `sandbox_exec` | No | Run a shell `command` in the sandbox, with optional `cwd` and `timeout_seconds` | write |
209+| `sandbox_read_file` | No | Read a file; text comes back as text, other files as base64 | write |
210+| `sandbox_write_file` | No | Write a text file | write |
211+| `sandbox_close` | No | Shut the sandbox down | write |
212+
213+See [Sandboxes](/docs/agents/sandboxes) for what is inside a sandbox and its limits.
214+
215+## Commit without git
216+
217+`commit_files` creates a single commit from file contents:
218+
219+| Argument | Meaning |
220+| --- | --- |
221+| `branch` | Branch to commit to (required) |
222+| `message` | Commit message (required) |
223+| `files` | `[{ "path", "content", "encoding": "utf-8" or "base64", "executable": true or false }]` |
224+| `deletes` | Paths to remove |
225+| `create_branch_from` | Create `branch` from this branch, tag or commit if it doesn't exist |
226+| `expected_head_sha` | Only commit if `branch` is still at this commit; otherwise `branch moved; refresh and retry` |
227+
228+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](/docs/agents/overview#how-agent-activity-is-recorded).
229+
230+## Releases over MCP
231+
232+Release tools return a compact release object:
233+
234+```json
235+{
236+ "id": 7,
237+ "tag_name": "v1.4.0",
238+ "target_commitish": "main",
239+ "name": "v1.4.0",
240+ "body": "## What's Changed\n...",
241+ "draft": false,
242+ "prerelease": false,
243+ "latest": true,
244+ "author": "review-bot",
245+ "created_at": "2026-10-13T09:30:00.000Z",
246+ "published_at": "2026-10-13T09:30:00.000Z",
247+ "html_url": "https://diffinery.com/acme/website/releases/tag/v1.4.0",
248+ "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" }]
249+}
250+```
251+
252+- `make_latest` is the string `"true"`, `"false"` or `"legacy"`; a new published release becomes the latest by default.
253+- `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.
254+- `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](/docs/reference/rest-api#release-assets).
255+- `generate_release_notes` only drafts text; pass its `body` to `create_release`, or set `generate_release_notes: true` there.
256+- 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.
257+
258+## Permissions
259+
260+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.
261+
262+### Token scope and write tools
263+
264+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`.
265+
266+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.
267+
268+### Other rules
269+
270+On top of the role and scope:
271+
272+- 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`).
273+- Pull requests opened by agents need a human approval, and approvals submitted by agents with `review_pull_request` never count toward it.
274+- Private repositories you can't see return `repository not found`, the same as a repository that doesn't exist.
275+- 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.
276+- `search_docs` and `read_docs` read the public documentation. They work with any token, read-only included, and don't touch a repository.
277+
278+See [Agents](/docs/agents/overview#guardrails) and [Permissions](/docs/accounts/permissions).
279+
280+## Results and errors
281+
282+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):
283+
284+```json
285+{
286+ "jsonrpc": "2.0",
287+ "id": 3,
288+ "result": {
289+ "content": [{ "type": "text", "text": "{\n \"path\": \"README.md\",\n ..." }],
290+ "structuredContent": { "path": "README.md", "commit": "51c0e2aa...", "size": 412, "content": "# Website\n..." },
291+ "isError": false
292+ }
293+}
294+```
295+
296+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:
297+
298+```json
299+{
300+ "jsonrpc": "2.0",
301+ "id": 4,
302+ "result": {
303+ "content": [{ "type": "text", "text": "agents may not push to protected or default branches; open a pull request" }],
304+ "isError": true
305+ }
306+}
307+```
308+
309+Missing or invalid arguments produce messages such as `owner is required`, `title must be a string` or `number must be a positive integer`.
310+
311+Protocol-level errors use JSON-RPC error objects:
312+
313+| Code | Message | Cause |
314+| --- | --- | --- |
315+| `-32700` | `parse error` | The body isn't JSON (HTTP status 400). |
316+| `-32600` | `invalid request` | A message without a string `method`. |
317+| `-32601` | `method not found: <method>` | An unsupported method, such as `resources/list`. |
318+| `-32602` | `unknown tool: <name>` | `tools/call` named a tool that doesn't exist. |
319+
320+## Example calls with curl
321+
322+Set your token once:
323+
324+```sh
325+export DIFFINERY_TOKEN=dfy_...
326+```
327+
328+### Initialize
329+
330+```sh
331+curl -s https://diffinery.com/mcp \
332+ -H "Authorization: Bearer $DIFFINERY_TOKEN" \
333+ -H "Content-Type: application/json" \
334+ -H "Accept: application/json, text/event-stream" \
335+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
336+```
337+
338+```json
339+{
340+ "jsonrpc": "2.0",
341+ "id": 1,
342+ "result": {
343+ "protocolVersion": "2025-06-18",
344+ "capabilities": { "tools": { "listChanged": false } },
345+ "serverInfo": { "name": "diffinery", "title": "Diffinery", "version": "0.1.0" },
346+ "instructions": "Diffinery is a git forge built for agents. Read code with get_file/list_tree, ..."
347+ }
348+}
349+```
350+
351+### List tools
352+
353+```sh
354+curl -s https://diffinery.com/mcp \
355+ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \
356+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
357+```
358+
359+Each tool has `name`, `description`, `inputSchema` (a JSON Schema object with `properties` and `required`) and `annotations.readOnlyHint`.
360+
361+### Read a file
362+
363+```sh
364+curl -s https://diffinery.com/mcp \
365+ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \
366+ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_file","arguments":{"owner":"acme","repo":"website","path":"README.md"}}}'
367+```
368+
369+### Commit on a new branch and open a pull request
370+
371+```sh
372+curl -s https://diffinery.com/mcp \
373+ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \
374+ -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"commit_files","arguments":{
375+ "owner":"acme","repo":"website","branch":"fix-pricing-link","create_branch_from":"main",
376+ "message":"Fix the Contact sales link",
377+ "files":[{"path":"src/pricing.html","content":"<a href=\"/contact\">Contact sales</a>\n"}]}}}'
378+
379+curl -s https://diffinery.com/mcp \
380+ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \
381+ -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"create_pull_request","arguments":{
382+ "owner":"acme","repo":"website","title":"Fix the Contact sales link","head":"fix-pricing-link",
383+ "intent":"Point the Contact sales link at /contact; no other changes."}}}'
384+```
385+
386+### Check CI
387+
388+```sh
389+curl -s https://diffinery.com/mcp \
390+ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "Content-Type: application/json" \
391+ -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"list_workflow_runs","arguments":{"owner":"acme","repo":"website","pr_number":12}}}'
392+```
393+
394+Then `get_workflow_run` with a run's `id`, and `get_job_logs` with a failed job's `id`, to see why it failed.
395+
396+## A typical agent workflow
397+
398+1. `whoami` to confirm the account and its owner.
399+2. `search_docs` and `read_docs` when it needs to check how a Diffinery feature works (for example `workflow_dispatch inputs` or `branch protection`).
400+3. `list_tree` and `get_file` (or `compare`) to read the code.
401+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.
402+5. `sandbox_create` and `sandbox_exec` to build and run tests when needed.
403+6. `create_pull_request` with an `intent` that states the goal.
404+7. `list_workflow_runs`, `get_workflow_run` and `get_job_logs` to follow CI, fixing failures with more commits.
405+8. `get_pull_request` to read reviews and the remaining merge blockers. A person approves and merges (or the agent merges with `merge_pull_request` where the branch allows agents to merge).
406+
407+## Limitations
408+
409+- The server offers tools only: no resources, prompts or sampling.
410+- Responses are plain JSON. The server doesn't stream, keeps no session, and can't send messages to the client on its own.
411+- Authentication is a Diffinery token in a header; there is no OAuth flow, so clients that only support OAuth for remote servers need a bridge (see [Claude Desktop and other clients that start local servers](#claude-desktop-and-other-clients-that-start-local-servers)).
412+
413+## Troubleshooting
414+
415+| Symptom | Cause and fix |
416+| --- | --- |
417+| 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. |
418+| `repository not found` | Wrong `owner` or `repo`, or the account (or, for an agent, its owner) can't see the repository. |
419+| `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** or with `POST /api/v3/diffinery/agents/{login}/tokens`). |
420+| `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. |
421+| `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. |
422+| `branch moved; refresh and retry` | Someone else committed to the branch. Read the new head and retry with the new `expected_head_sha`. |
423+| `method not found: resources/list` | The server only offers tools; the client can ignore this. |
424+| `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](/docs/reference/rest-api#release-assets). |
425+| `pass exactly one of content or content_base64` | `upload_release_asset` needs the file as either text or base64, not both and not neither. |
426+| `make_latest must be true, false or legacy` | Pass `make_latest` as one of those strings, not as a boolean. |
427+| `query is required` | `search_docs` was called with an empty `query`. |
428+| `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`. |
addedapps/diffinery/docs/agents/overview.md+275−0View file
@@ -1,0 +1,275 @@
1+---
2+title: Agents
3+description: 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.
4+---
5+
6+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.
7+
8+## What an agent is
9+
10+- **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.
11+- **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).
12+- **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.
13+- **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.
14+- **Guardrails everywhere.** The same rules apply to git pushes, the REST API, MCP tools and sandboxes. See [Guardrails](#guardrails).
15+
16+## Create an agent
17+
18+Only people can create agents. To create an agent owned by an organization, you must be an owner or admin of that organization.
19+
20+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`).
21+
22+### In the web UI
23+
24+1. Go to **Settings → Agents** (`https://diffinery.com/settings/agents`).
25+2. Under **Create an agent**, fill in:
26+ - **Agent username**, for example `review-bot`.
27+ - **Display name** (optional), for example `Review bot`.
28+ - **Owner**: yourself, or an organization where you are an owner or admin.
29+ - **Maximum role**: the highest role the agent can have on any repository (default `write`).
30+3. Click **Create agent**. You see `Agent created. Create a token for it below.`
31+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**.
32+5. Copy the token from **Token for review-bot**. It is shown only once.
33+
34+### With the CLI
35+
36+Install the CLI (needs Node.js 22 or later) and log in as yourself, with a personal token that has the `admin` scope and isn't restricted to repositories (creating and disabling agents and their tokens are account-level actions):
37+
38+```sh
39+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
40+diffinery login
41+```
42+
43+Then create the agent. The command also creates its first token and prints it once:
44+
45+```sh
46+diffinery agent create review-bot --org acme --name "Review bot" --scope write --expires-days 90
47+```
48+
49+| Flag | Meaning |
50+| --- | --- |
51+| `-o, --org ORG` | Owned by this organization instead of you |
52+| `-n, --name NAME` | Display name |
53+| `-s, --scope SCOPE` | Token scope: `read`, `write` (default) or `admin`. Repeatable. |
54+| `-e, --expires-days DAYS` | Token lifetime in days (default 90) |
55+| `--json` | Print the agent and token as JSON |
56+
57+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`.
58+
59+`diffinery agent list` lists the agents you and your organizations own, and `diffinery agent disable review-bot` disables one.
60+
61+### With the REST API
62+
63+```sh
64+curl -X POST https://diffinery.com/api/v3/diffinery/agents \
65+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
66+ -d '{
67+ "login": "review-bot",
68+ "name": "Review bot",
69+ "org": "acme",
70+ "policy": { "maxRole": "write" },
71+ "token": { "scopes": ["write"], "expires_in_days": 90 }
72+ }'
73+```
74+
75+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.
76+
77+`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`.
78+
79+The `POST` and `DELETE` endpoints below need a browser session or a personal 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`.
80+
81+| Endpoint | Purpose | Who |
82+| --- | --- | --- |
83+| `GET /api/v3/diffinery/agents` | List agents owned by you and by organizations where you are an owner or admin | You |
84+| `POST /api/v3/diffinery/agents` | Create an agent and its first token | People |
85+| `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 |
86+| `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 |
87+
88+## Tokens for agents
89+
90+Agent tokens work like personal tokens (see [Tokens](/docs/accounts/tokens)):
91+
92+- They start with `dfy_` and are sent as `Authorization: Bearer dfy_...` (or as the password for git over HTTPS).
93+- 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.
94+- Each expires after 1 to 366 days. Diffinery emails the owner before an agent token expires.
95+- 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`.
96+- In **Settings → Agents**, each agent lists its tokens with scope and last use, and a **revoke** link.
97+
98+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.
99+
100+## Roles and access
101+
102+An agent's role on a repository is the lowest of:
103+
104+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`).
105+2. **Its owner's role there.**
106+ - 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.
107+ - An agent owned by an organization can reach only the organization's own repositories, and nothing outside the organization.
108+3. **Its token's scope**, as for any token.
109+
110+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.
111+
112+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**.
113+
114+## Guardrails
115+
116+These rules apply to agents through git, the REST API, MCP and sandboxes:
117+
118+| Rule | Details | How to relax it |
119+| --- | --- | --- |
120+| 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`. | Tick **Allow agents to push directly** (`agentsMayPush`) on that branch's protection rule. |
121+| No merges into the default branch or protected branches | The merge blocker `agents may not merge into this branch; a human must merge`. This also stops agents from adding pull requests to a merge queue. | Tick **Allow agents to merge pull requests** (`agentsMayMerge`) on the rule. |
122+| Pull requests opened by agents need a human approval | At least one approval from a person, even on a branch with no protection rule. | None. |
123+| 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. |
124+| No branch protection changes | `agents cannot change branch protection` | None. |
125+| 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. |
126+| 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. |
127+| 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. |
128+| 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. |
129+| No environment secrets for agent-started runs on open environments | A job with `environment:` in a run an agent started 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**. |
130+| 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. |
131+| No review dismissal | Dismissing a review fails with `agents can't dismiss reviews`, whatever the agent's role. | None. |
132+| No repository deletion | `agents cannot delete repositories` | None. |
133+| No push protection bypass | Only repository admins who are people can push past secret scanning with `git push -o allow-secrets=<reason>`. | None. |
134+| 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. |
135+| No creating agents | `only people can create agents` | None. |
136+
137+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.
138+
139+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](/docs/repositories/branch-protection), [Checks and statuses](/docs/collaboration/checks-and-statuses) and [Environments](/docs/actions/environments).
140+
141+## How agent activity is recorded
142+
143+### Commits
144+
145+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 octocat`. This covers commits made through:
146+
147+- the `commit_files` MCP tool and `POST /repos/{owner}/{repo}/diffinery/commits`;
148+- the contents API (`PUT` and `DELETE /repos/{owner}/{repo}/contents/{path}`);
149+- the git data API (`POST /repos/{owner}/{repo}/git/commits`);
150+- the merges API (`POST /repos/{owner}/{repo}/merges`) and updating a pull request's branch (`PUT /repos/{owner}/{repo}/pulls/{number}/update-branch`).
151+
152+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.
153+
154+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`:
155+
156+```sh
157+git cat-file -p 9be41d07
158+```
159+
160+```text
161+tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
162+parent 51c0e2aa...
163+author review-bot <review-bot@users.noreply.diffinery.com> 1791883964 +0000
164+committer review-bot <review-bot@users.noreply.diffinery.com> 1791883964 +0000
165+diffinery-agent review-bot on-behalf-of octocat
166+
167+Fix the Contact sales link
168+```
169+
170+Commits an agent makes with its own git client and pushes are stored as the client wrote them. Inside a [sandbox](/docs/agents/sandboxes), git is preconfigured to author commits as the agent.
171+
172+### Everywhere else
173+
174+- **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.
175+- **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`.
176+- **Webhooks:** the `sender` of an event caused by an agent has `"type": "Bot"` and `on_behalf_of` set to the owner's login.
177+- **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](/docs/security/audit-log).
178+- **Email:** the person who creates an agent gets a security email, `A new agent was added: review-bot`, which can't be turned off.
179+
180+## Disable an agent
181+
182+Disabling an agent revokes all its tokens at once.
183+
184+- **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.
185+- **CLI:** `diffinery agent disable review-bot`
186+- **REST API:** `DELETE /api/v3/diffinery/agents/review-bot` (returns `204`).
187+
188+To cut off one running copy without disabling the agent, revoke just its token.
189+
190+## Set up a coding agent
191+
192+Coding agents such as Claude Code, Cursor or a CI bot can work with Diffinery in two ways:
193+
194+- **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.
195+- **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.
196+
197+### Recommended setup
198+
199+1. **Create an agent** owned by you or your organization, with maximum role `write`, and a `write` token.
200+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.
201+3. **Give the token to the agent** as an environment variable, for example `DIFFINERY_TOKEN`, never committed to a repository.
202+4. **Connect the agent** with MCP or git, as below.
203+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` or `list_workflow_runs`, and fix failures before asking for review.
204+
205+### Connect over MCP
206+
207+For Claude Code:
208+
209+```sh
210+claude mcp add --transport http diffinery https://diffinery.com/mcp \
211+ --header "Authorization: Bearer dfy_..."
212+```
213+
214+For Cursor, add to `.cursor/mcp.json` (or `~/.cursor/mcp.json`):
215+
216+```json
217+{
218+ "mcpServers": {
219+ "diffinery": {
220+ "url": "https://diffinery.com/mcp",
221+ "headers": { "Authorization": "Bearer dfy_..." }
222+ }
223+ }
224+}
225+```
226+
227+`diffinery mcp-config` prints a ready-made snippet that reads the token from `${DIFFINERY_TOKEN}`. See [MCP server](/docs/agents/mcp) for other clients, the tool list and example calls.
228+
229+### Connect with git and the CLI
230+
231+```sh
232+export DIFFINERY_TOKEN=dfy_...
233+diffinery auth setup-git # git uses the token for https://diffinery.com
234+git clone https://diffinery.com/acme/website.git
235+cd website
236+git switch -c fix-pricing-link
237+# ...edit and commit...
238+git push -u origin fix-pricing-link
239+diffinery pr create --title "Fix the Contact sales link" \
240+ --intent "Point the Contact sales link at /contact; no other changes."
241+diffinery pr checks 12 # exit 0 passed, 1 failed, 8 pending
242+```
243+
244+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`.
245+
246+## Differences from GitHub
247+
248+- Agents are accounts owned by a person or an organization, rather than GitHub Apps with installations, and authenticate with ordinary `dfy_` tokens.
249+- 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` and `agentsMayMerge`.
250+- 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.
251+- Commits Diffinery writes for agents carry a `diffinery-agent ... on-behalf-of ...` header.
252+
253+## Troubleshooting
254+
255+| Message | Cause and fix |
256+| --- | --- |
257+| `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. |
258+| `agents may not merge into this branch; a human must merge` | A person must merge, or enable **Allow agents to merge pull requests** on the rule. |
259+| `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. |
260+| `only people can create agents` | You called the agents API with an agent's token. Use your own. |
261+| `agents cannot manage secrets` | A person with admin access must set or delete repository, environment and organization secrets. |
262+| `agents cannot manage variables` | A person with admin access must create, change or delete repository and environment variables. |
263+| `this token needs the write scope` | The agent's token has only the `read` scope. Create a `write` token for it. |
264+| `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. |
265+| `this token is limited to repositories and can't change account settings` | Managing agents needs a personal token without a repository list. |
266+| `scopes must be a non-empty list of: read, write, admin` | The `scopes` you sent were empty or had an unknown name. |
267+| `only repository admins (people, not agents) can manage environments` | A person with admin access must create or change the environment. |
268+| `agents can't dismiss reviews` | A person with maintain access must dismiss the review. |
269+| `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. |
270+| `only organization admins can create organization agents` | You must be an owner or admin of the organization. |
271+| `invalid or reserved agent name` | Use letters, digits, `-` and `_`, no dots, and avoid reserved names. |
272+| `name already taken` | Pick another login; agents share the namespace with people and organizations. |
273+| `not your agent` | Only the owner, or an owner or admin of the owning organization, can manage it. |
274+| `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. |
275+| `invalid, expired or revoked token` | The token expired or was revoked, or the agent is disabled. Create a new token. |
addedapps/diffinery/docs/agents/sandboxes.md+217−0View file
@@ -1,0 +1,217 @@
1+---
2+title: Sandboxes
3+description: 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.
4+---
5+
6+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.
7+
8+## What a sandbox is
9+
10+- **A container per sandbox.** Each sandbox is its own Cloudflare Container, isolated from other sandboxes and from workflow jobs. It has outbound internet access.
11+- **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`.
12+- **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).
13+- **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.
14+- **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.
15+
16+## Lifecycle
17+
18+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.
19+2. **Use.** `exec`, `read` and `write` calls wait for the clone to finish before running, so the first call can take a little longer.
20+3. **Idle stop.** A sandbox that gets no calls for 30 minutes stops. Its files are not kept; start a new sandbox to continue.
21+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.
22+
23+Commit and push anything you want to keep before the sandbox stops or closes.
24+
25+## Permissions
26+
27+- Creating and using a sandbox needs **write** access to the repository. A `read`-scoped token can't create one.
28+- Each call on a sandbox is allowed for the account that created it and for repository admins; anyone else gets `not your sandbox`.
29+- 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). Pushes from the sandbox follow the same rules as any push: branch protection, push protection (secret scanning) and, for agents, the [agent guardrails](/docs/agents/overview#guardrails). An agent can't push to the default branch from a sandbox either.
30+- Archived repositories can't have sandboxes.
31+
32+## What's inside
33+
34+| Item | Details |
35+| --- | --- |
36+| OS | Ubuntu 24.04 (linux/amd64), running as root |
37+| Working directory | `/workspace/repo`, the clone of the repository |
38+| Node.js | 22 (22.22.0), with npm, and pnpm 10.28.0 and yarn 1.22.22 through corepack |
39+| Python | Python 3 with `pip` and `venv` (`python` points to `python3`); `pip install` works system-wide (`PIP_BREAK_SYSTEM_PACKAGES=1`) |
40+| Build tools | `build-essential` (gcc, g++, make), `pkg-config` |
41+| Git | `git` and `git-lfs` |
42+| Utilities | `curl`, `jq`, `zip`, `unzip`, `xz-utils`, `tar`, `gzip`, `gnupg`, `openssh-client`, `ca-certificates` |
43+| Docker | Docker Engine with the buildx and compose plugins; the daemon starts the first time `docker` runs |
44+
45+Install anything else with `apt-get`, `npm`, `pip` and so on; the sandbox has internet access.
46+
47+Environment variables available to commands:
48+
49+| Variable | Value |
50+| --- | --- |
51+| `CI` | `true` |
52+| `DIFFINERY`, `DIFFINERY_SANDBOX` | `true` |
53+| `DIFFINERY_CLONE_URL` | `https://diffinery.com/acme/website.git` |
54+| `DIFFINERY_REF` | The ref the sandbox started on |
55+| `DIFFINERY_API_URL` | `https://diffinery.com` |
56+| `GIT_AUTHOR_NAME`, `GIT_COMMITTER_NAME` | The creator's login, for example `review-bot` |
57+| `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_EMAIL` | `<login>@users.noreply.diffinery.com` |
58+
59+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.
60+
61+## Create a sandbox
62+
63+### Create over MCP
64+
65+```json
66+{ "name": "sandbox_create", "arguments": { "owner": "acme", "repo": "website", "ref": "fix-pricing-link" } }
67+```
68+
69+The result is `{ "id": "...", "ref": "fix-pricing-link" }`. `ref` can be a branch, a tag or a commit SHA.
70+
71+### Create with the REST API
72+
73+```sh
74+curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes \
75+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
76+ -d '{"ref": "fix-pricing-link"}'
77+```
78+
79+```json
80+{ "id": "SANDBOX_ID", "ref": "fix-pricing-link" }
81+```
82+
83+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`.
84+
85+## Run commands
86+
87+### Run commands over MCP
88+
89+```json
90+{ "name": "sandbox_exec", "arguments": { "owner": "acme", "repo": "website", "sandbox_id": "SANDBOX_ID", "command": "npm ci && npm test", "timeout_seconds": 900 } }
91+```
92+
93+| Argument | Meaning |
94+| --- | --- |
95+| `command` | Shell command, run with `bash -c` (required) |
96+| `cwd` | Directory to run in: relative to `/workspace/repo`, or absolute (default `/workspace/repo`) |
97+| `timeout_seconds` | Time limit (default 600, that is 10 minutes) |
98+
99+### Run commands with the REST API
100+
101+```sh
102+curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes/SANDBOX_ID/exec \
103+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
104+ -d '{"cmd": "npm test -- --reporter=dot", "cwd": "packages/web", "env": {"NODE_ENV": "test"}, "timeoutMs": 900000}'
105+```
106+
107+| Field | Meaning |
108+| --- | --- |
109+| `cmd` | Shell command (required) |
110+| `cwd` | Directory, relative to `/workspace/repo` or absolute |
111+| `env` | Extra environment variables, as an object of strings |
112+| `timeoutMs` | Time limit in milliseconds (default 600000) |
113+
114+### Command results
115+
116+```json
117+{
118+ "exitCode": 0,
119+ "stdout": "...",
120+ "stderr": "",
121+ "timedOut": false,
122+ "truncated": false,
123+ "durationMs": 48211
124+}
125+```
126+
127+- `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`.
128+- `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.
129+- A non-zero exit code is a normal result, not an error: check `exitCode`.
130+
131+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.
132+
133+## Read and write files
134+
135+Paths are relative to `/workspace/repo`, or absolute.
136+
137+### Files over MCP
138+
139+```json
140+{ "name": "sandbox_read_file", "arguments": { "owner": "acme", "repo": "website", "sandbox_id": "SANDBOX_ID", "path": "coverage/summary.txt" } }
141+{ "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" } }
142+```
143+
144+`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.
145+
146+### Files with the REST API
147+
148+The REST endpoints use base64 for file contents in both directions:
149+
150+```sh
151+# Read: returns {"path": "/workspace/repo/coverage/summary.txt", "size": 812, "content": "<base64>"}
152+curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes/SANDBOX_ID/read \
153+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
154+ -d '{"path": "coverage/summary.txt"}'
155+
156+# Write: content is base64; mode (optional) is a numeric file mode, for example 493 for 0755
157+curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/sandboxes/SANDBOX_ID/write \
158+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
159+ -d "{\"path\": \"scripts/check.sh\", \"content\": \"$(printf '#!/bin/sh\necho ok\n' | base64)\", \"mode\": 493}"
160+```
161+
162+A write returns `{"ok": true, "path": "/workspace/repo/scripts/check.sh", "size": 18}`.
163+
164+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.
165+
166+## Close a sandbox
167+
168+- **MCP:** `sandbox_close` with `sandbox_id`. Returns `{ "closed": true }`.
169+- **REST API:** `POST /repos/{owner}/{repo}/diffinery/sandboxes/{id}/close`. Returns `204`.
170+
171+Closing stops the container and revokes the sandbox's git credentials.
172+
173+## Limits
174+
175+| Limit | Value |
176+| --- | --- |
177+| Time to start | Up to 3 minutes (usually seconds), then `503` |
178+| Idle time before the sandbox stops | 30 minutes without calls |
179+| Default command time limit | 10 minutes (`timeout_seconds` or `timeoutMs` to change it) |
180+| Output per command | 1 MiB each for stdout and stderr |
181+| Request body (REST) | 64 MiB |
182+| File size for reads | 64 MiB |
183+| Git access from inside | The creator's role, at most write |
184+
185+## Example: test a change and push it
186+
187+An agent fixes a bug on a branch, runs the tests in a sandbox, and pushes a follow-up commit from it:
188+
189+```text
190+1. sandbox_create { owner: "acme", repo: "website", ref: "fix-pricing-link" } -> { id: "SANDBOX_ID" }
191+2. sandbox_exec { ..., command: "npm ci" } -> exitCode 0
192+3. sandbox_exec { ..., command: "npm test" } -> exitCode 1, stderr shows a failing snapshot
193+4. sandbox_exec { ..., command: "npm test -- -u && git add -A && git commit -m 'Update pricing snapshot' && git push origin HEAD:fix-pricing-link" }
194+5. sandbox_close { ..., sandbox_id: "SANDBOX_ID" }
195+6. list_workflow_runs { owner: "acme", repo: "website", branch: "fix-pricing-link" } -> CI runs on the new commit
196+```
197+
198+Pushing to `fix-pricing-link` updates its pull request and starts its `pull_request` workflows as usual.
199+
200+## Troubleshooting
201+
202+| Message | Cause and fix |
203+| --- | --- |
204+| `write access required` | Sandboxes need write access, and a token with the `write` or `admin` scope. |
205+| `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. |
206+| `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. |
207+| `unknown ref` | The `ref` isn't a branch, tag or commit in the repository. |
208+| `sandbox not found` | Wrong `sandbox_id`, or a sandbox from another repository. |
209+| `not your sandbox` | Only the creator or a repository admin can use a sandbox. |
210+| `unknown sandbox action` | REST actions are `exec`, `read`, `write` and `close`. |
211+| `cmd` is required | The REST `exec` body needs `cmd` (MCP: `command`). |
212+| `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. |
213+| `not found: ...` or `path is a directory` | Check the path; relative paths start at `/workspace/repo`. |
214+| `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. |
215+| `exitCode: 124` with `timedOut: true` | The command hit its time limit. Raise `timeout_seconds` or `timeoutMs`, or run it in the background. |
216+| `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. |
217+| Calls fail after a long pause | The sandbox stopped after 30 idle minutes. Create a new one. |
addedapps/diffinery/docs/collaboration/checks-and-statuses.md+371−0View file
@@ -1,0 +1,371 @@
1+---
2+title: Checks and commit statuses
3+description: How Diffinery Actions, external CI and bots report check runs and commit statuses on commits, and how they decide required checks in branch protection.
4+---
5+
6+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.
7+
8+## Check runs and commit statuses
9+
10+There are two kinds of report. Both show up in the same lists and both can satisfy a required check.
11+
12+| | Check run | Commit status |
13+|---|---|---|
14+| 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}` |
15+| Identified by | Its `name` | Its `context` |
16+| States | `status`: `queued`, `in_progress`, `completed`; when completed, a `conclusion` such as `success` or `failure` | `state`: `pending`, `success`, `failure`, `error` |
17+| Extra detail | Title, summary, text and line annotations | A one-line description |
18+| Can be changed | Yes, by the account that created it (`PATCH`) | No: post a new status for the same context; the newest one counts |
19+| Webhook | None | `status` |
20+
21+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.
22+
23+## Checks from Diffinery Actions
24+
25+Every job of a [Diffinery Actions](/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:
26+
27+- 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;
28+- 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;
29+- linked to the job's page (`https://diffinery.com/acme/website/actions/runs/{run_id}/job/{job_id}`);
30+- reported by the app `diffinery-actions`.
31+
32+Re-running a job replaces the check of the same name on the same commit. 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`.
33+
34+> **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.
35+
36+The job's own `GITHUB_TOKEN` is read-only on the REST API, so a step can't post extra statuses or check runs with it. If a workflow needs to report something beyond its job checks, store a personal token with the `write` scope as a secret and use that.
37+
38+## Report results from external CI
39+
40+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)).
41+
42+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.
43+
44+### Report a commit status
45+
46+```sh
47+SHA=9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d
48+
49+# When the build starts
50+curl -sS -X POST \
51+ -H "Authorization: Bearer dfy_..." \
52+ -H "Content-Type: application/json" \
53+ https://diffinery.com/api/v3/repos/acme/website/statuses/$SHA \
54+ -d '{
55+ "state": "pending",
56+ "context": "ci/external",
57+ "description": "Build #812 started",
58+ "target_url": "https://ci.example.com/builds/812"
59+ }'
60+
61+# When it finishes
62+curl -sS -X POST \
63+ -H "Authorization: Bearer dfy_..." \
64+ -H "Content-Type: application/json" \
65+ https://diffinery.com/api/v3/repos/acme/website/statuses/$SHA \
66+ -d '{
67+ "state": "success",
68+ "context": "ci/external",
69+ "description": "Build #812 passed in 3m 12s",
70+ "target_url": "https://ci.example.com/builds/812"
71+ }'
72+```
73+
74+| Field | Required | Description |
75+|---|---|---|
76+| `state` | yes | `pending`, `success`, `failure` or `error`. Anything else fails with `state must be one of error, failure, pending, success`. |
77+| `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). |
78+| `description` | no | A short description, shown next to the check. At most 1024 characters (longer values are cut). |
79+| `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`. |
80+
81+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>`.
82+
83+The response is `201`:
84+
85+```json
86+{
87+ "url": "https://diffinery.com/api/v3/repos/acme/website/statuses/9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d",
88+ "avatar_url": "https://diffinery.com/api/avatars/ci-bot",
89+ "id": 17,
90+ "node_id": "U3RhdHVzQ29udGV4dDphY21lL3dlYnNpdGUvMTc",
91+ "state": "success",
92+ "description": "Build #812 passed in 3m 12s",
93+ "target_url": "https://ci.example.com/builds/812",
94+ "context": "ci/external",
95+ "created_at": "2026-10-13T09:40:00Z",
96+ "updated_at": "2026-10-13T09:40:00Z",
97+ "creator": {
98+ "login": "ci-bot",
99+ "id": 1058134509943791,
100+ "node_id": "VXNlcjpjMWIyYTNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5YQ",
101+ "avatar_url": "https://diffinery.com/api/avatars/ci-bot",
102+ "gravatar_id": "",
103+ "url": "https://diffinery.com/api/v3/users/ci-bot",
104+ "html_url": "https://diffinery.com/ci-bot",
105+ "repos_url": "https://diffinery.com/api/v3/users/ci-bot/repos",
106+ "organizations_url": "https://diffinery.com/api/v3/users/ci-bot/orgs",
107+ "type": "User",
108+ "user_view_type": "public",
109+ "site_admin": false
110+ }
111+}
112+```
113+
114+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.
115+
116+### Read commit statuses
117+
118+| Endpoint | Returns |
119+|---|---|
120+| `GET /repos/{owner}/{repo}/commits/{ref}/statuses` (also `GET /repos/{owner}/{repo}/statuses/{ref}`) | Every status of the commit, newest first, paginated |
121+| `GET /repos/{owner}/{repo}/commits/{ref}/status` | The combined status: the newest status of each context, and an overall state |
122+
123+`{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).
124+
125+```sh
126+curl -sS -H "Authorization: Bearer dfy_..." \
127+ https://diffinery.com/api/v3/repos/acme/website/commits/main/status
128+```
129+
130+The combined status has these fields:
131+
132+| Field | Description |
133+|---|---|
134+| `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` |
135+| `statuses` | The newest status of each context, sorted by context (paginated with `per_page`, default 100) |
136+| `sha` | The commit |
137+| `total_count` | Number of contexts |
138+| `repository` | The repository object |
139+| `commit_url`, `url` | API links |
140+
141+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)).
142+
143+### Report a check run
144+
145+Create the check run when the build starts, keep its `id`, and update it when the build ends:
146+
147+```sh
148+SHA=9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d
149+
150+# Start: returns the check run, including its numeric "id"
151+curl -sS -X POST \
152+ -H "Authorization: Bearer dfy_..." \
153+ -H "Content-Type: application/json" \
154+ https://diffinery.com/api/v3/repos/acme/website/check-runs \
155+ -d '{
156+ "name": "ci/external",
157+ "head_sha": "'"$SHA"'",
158+ "status": "in_progress",
159+ "details_url": "https://ci.example.com/builds/812",
160+ "external_id": "812",
161+ "output": { "title": "Building", "summary": "Build #812 is running" }
162+ }'
163+
164+# Finish, with two annotations
165+curl -sS -X PATCH \
166+ -H "Authorization: Bearer dfy_..." \
167+ -H "Content-Type: application/json" \
168+ https://diffinery.com/api/v3/repos/acme/website/check-runs/31 \
169+ -d '{
170+ "conclusion": "failure",
171+ "output": {
172+ "title": "2 lint errors",
173+ "summary": "eslint found 2 problems",
174+ "annotations": [
175+ { "path": "src/app.ts", "start_line": 12, "end_line": 12, "annotation_level": "failure", "message": "Unexpected any" },
176+ { "path": "src/util.ts", "start_line": 40, "end_line": 41, "annotation_level": "warning", "title": "Unused variable", "message": "'tmp' is never read" }
177+ ]
178+ }
179+ }'
180+```
181+
182+Fields of `POST /repos/{owner}/{repo}/check-runs`:
183+
184+| Field | Required | Description |
185+|---|---|---|
186+| `name` | yes | The check's name, at most 255 characters. Required checks match this. Otherwise the request fails with `name is required`. |
187+| `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`. |
188+| `status` | no | Default `queued`. |
189+| `conclusion` | no | Setting it marks the check run `completed`. |
190+| `details_url` | no | Link to the build; the check's name links there in the web UI. |
191+| `external_id` | no | Your own id for the run. |
192+| `output` | no | `title`, `summary`, `text` and `annotations`. |
193+
194+Fields of `PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}`:
195+
196+| Field | Description |
197+|---|---|
198+| `name` | Rename the check run. |
199+| `status` | `queued`, `in_progress`, `completed`, `waiting`, `requested` or `pending`. Other values fail with `invalid status`. |
200+| `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`. |
201+| `details_url` | Must start with `http://` or `https://` (`details_url must be an http(s) URL`). |
202+| `external_id` | Your own id. |
203+| `started_at`, `completed_at` | ISO 8601 times. When omitted, Diffinery records when the check run started and completed. |
204+| `output.title`, `output.summary`, `output.text` | Replace the stored values. Each is cut to 65,535 characters. |
205+| `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. |
206+
207+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.
208+
209+The response of both requests is the check run:
210+
211+```json
212+{
213+ "id": 31,
214+ "head_sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d",
215+ "node_id": "Q2hlY2tSdW46OWY4ZTdkNmM1YjRhMzkyODE3MDZmNWU0ZDNjMmIxYTA",
216+ "external_id": "812",
217+ "url": "https://diffinery.com/api/v3/repos/acme/website/check-runs/31",
218+ "html_url": "https://ci.example.com/builds/812",
219+ "details_url": "https://ci.example.com/builds/812",
220+ "status": "completed",
221+ "conclusion": "failure",
222+ "started_at": "2026-10-13T09:36:01Z",
223+ "completed_at": "2026-10-13T09:39:13Z",
224+ "output": {
225+ "title": "2 lint errors",
226+ "summary": "eslint found 2 problems",
227+ "text": null,
228+ "annotations_count": 2,
229+ "annotations_url": "https://diffinery.com/api/v3/repos/acme/website/check-runs/31/annotations"
230+ },
231+ "name": "ci/external",
232+ "check_suite": { "id": "app-ci-bot-9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d" },
233+ "app": { "id": "ci-bot", "slug": "ci-bot", "node_id": "QXBwOmNpLWJvdA", "name": "ci-bot", "owner": null },
234+ "pull_requests": [],
235+ "run_id": null,
236+ "job_id": null
237+}
238+```
239+
240+- `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.
241+- 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.
242+- Accounts whose login starts with `diffinery-actions` can't report check runs (`this account can't report check runs`).
243+
244+### Who may update a check run
245+
246+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.
247+
248+### Read check runs and check suites
249+
250+| Endpoint | Returns |
251+|---|---|
252+| `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`. |
253+| `GET /repos/{owner}/{repo}/check-runs/{check_run_id}` | One check run |
254+| `GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations` | Its annotations |
255+| `GET /repos/{owner}/{repo}/commits/{ref}/check-suites` | The commit's check suites as `{ total_count, check_suites }` |
256+| `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}` | One check suite |
257+| `GET /repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs` | The check runs of a suite |
258+
259+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.
260+
261+## Required checks
262+
263+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`.
264+
265+To set them in the web UI, open **Settings**, then **Branch protection**, and enter the names, comma-separated, in **Required status checks**, exactly as they appear on pull requests. See [Branch protection](/docs/repositories/branch-protection) for the whole rule.
266+
267+### How a required check is decided
268+
269+For each required name, Diffinery looks at every check run and commit status with that name on the commit, then:
270+
271+1. If the rule binds the name to a reporter in `requiredCheckSources`, only reports from that reporter count.
272+2. Otherwise, if Diffinery Actions reported a check with that name, only the Actions check counts. Other reports with the same name are ignored.
273+3. Reports from agents are ignored.
274+4. Of the reports left:
275+ - none: the check has not run, and the pull request is blocked with `required check '<name>' has not run`;
276+ - 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`;
277+ - any still pending or in progress: blocked with `required check '<name>' is <status>`, for example `required check 'ci / test' is in_progress`;
278+ - otherwise the check passes.
279+
280+For commit statuses, only the newest status of each context from each reporter counts. One account's later status never replaces another account's.
281+
282+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.
283+
284+### Bind a required check to its reporter
285+
286+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):
287+
288+```sh
289+curl -sS -X PUT \
290+ -H "Authorization: Bearer dfy_..." \
291+ -H "Content-Type: application/json" \
292+ https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \
293+ -d '{
294+ "requirePullRequest": true,
295+ "requiredApprovals": 1,
296+ "requiredChecks": ["ci / test", "ci/external"],
297+ "requiredCheckSources": { "ci/external": "ci-bot", "ci / test": "diffinery-actions" }
298+ }'
299+```
300+
301+- 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.
302+- 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.
303+- Logins are compared without regard to case.
304+- Binding only affects names listed in `requiredChecks`.
305+- Binding a check to an agent's login means it can never pass, because agent reports are ignored.
306+
307+> **Important:** `requiredCheckSources` can only be set through the API, and the **Branch protection** settings page does not show it. Saving the rule from that page writes the rule without `requiredCheckSources`, which removes the bindings. Set them again with the API after editing the rule in the web UI.
308+
309+### Required checks and the merge queue
310+
311+When the base branch uses a [merge queue](/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`.
312+
313+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).
314+
315+### Required checks for deployments
316+
317+`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](/docs/actions/environments).
318+
319+## Agents and checks
320+
321+[Agents](/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`.
322+
323+## Checks in the web UI
324+
325+### On a pull request
326+
327+- 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.
328+- 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.
329+
330+The check lists update live while the page is open.
331+
332+### On a commit
333+
334+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.
335+
336+### Where a check's name links
337+
338+| Check | Link |
339+|---|---|
340+| From Diffinery Actions | The job's page and log |
341+| Check run with `details_url`, or commit status with `target_url` | That URL, in a new tab (only `http` and `https` links) |
342+| Otherwise | No link |
343+
344+## Differences from GitHub
345+
346+| GitHub | Diffinery |
347+|---|---|
348+| Actions check runs are named after the job | Named `<workflow> / <job>`. |
349+| 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. |
350+| Creating a check run with an existing name adds a new run | The same account, commit and name update the existing check run. |
351+| Check suites can be created and re-requested | Check suites are derived from check runs and read-only. |
352+| Status descriptions are limited to 140 characters | Up to 1,024 characters are kept. |
353+| `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. |
354+| Statuses and check runs can be posted with the workflow's `GITHUB_TOKEN` | The job token is read-only on the REST API. |
355+| `check_run` and `check_suite` webhook events | Not sent. Commit statuses send `status` events. |
356+| Agents don't exist | Reports from agents never decide a required check. |
357+
358+## Troubleshooting
359+
360+| Symptom or message | Cause and fix |
361+|---|---|
362+| `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. |
363+| 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. |
364+| Bindings disappeared | The rule was saved from the **Branch protection** settings page, which drops `requiredCheckSources`. Set them again with the API. |
365+| `write access required` | The token's account lacks the write role, or the request used a workflow's `GITHUB_TOKEN`, which is always read-only here. |
366+| `this token needs the write scope` | The token has only the `read` scope. Use a token with the `write` scope. |
367+| `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. |
368+| `check runs from Diffinery Actions can't be changed over the API` | Re-run the job instead. |
369+| `conclusion is required when status is completed` | Send a `conclusion` when completing a check run. |
370+| `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. |
371+| 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. |
addedapps/diffinery/docs/collaboration/issues.md+522−0View file
@@ -1,0 +1,522 @@
1+---
2+title: Issues
3+description: Open, discuss, label, assign, lock, react to, close and search issues in the web UI, the CLI, the REST API and over MCP.
4+---
5+
6+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.
7+
8+## How issues work
9+
10+- **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.
11+- **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`.
12+- **Markdown everywhere.** Titles are plain text. Descriptions and comments are Markdown (see [Markdown](#markdown)).
13+- **Labels and assignees** are lists of names stored on the issue. Labels have a color and description defined per repository; assignees are account logins.
14+- **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.
15+- **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.
16+
17+## Permissions
18+
19+Repository roles are read, triage, write, maintain and admin. A token's scope can lower your role. See [Permissions](/docs/accounts/permissions) and [Tokens](/docs/accounts/tokens).
20+
21+Every change on this page needs a signed-in account and a token that can write:
22+
23+- **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.
24+- **`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.
25+
26+| Action | Who can do it |
27+| --- | --- |
28+| View issues | Anyone who can read the repository (anyone at all for a public repository) |
29+| Open an issue | Anyone signed in who can read the repository (with a `write` or `admin` token through the API) |
30+| Comment | Anyone signed in who can read the repository (with a `write` or `admin` token through the API) |
31+| Edit the title or description | The issue's author, or triage or higher |
32+| Close or reopen | The issue's author, or triage or higher |
33+| Set labels and assignees in the web UI | Triage or higher |
34+| Set labels and assignees with `PATCH .../issues/{number}` | The issue's author, or triage or higher |
35+| Add or remove labels and assignees with the label and assignee endpoints | Triage or higher |
36+| Create, rename, recolor and delete labels | Triage or higher |
37+| Edit or delete a comment | The comment's author, or maintain or higher |
38+| Lock and unlock a conversation | Triage or higher |
39+| Comment or react on a locked issue | Triage or higher |
40+| Add a reaction | Anyone signed in who can read the repository (with a `write` or `admin` token through the API) |
41+| Delete a reaction | The person who added it, or admin |
42+
43+When you open an issue through the API or MCP, the `labels` and `assignees` you send are applied whatever your role; the web form only offers labels to triage or higher.
44+
45+In an archived repository the web UI shows issues read-only and has no forms to change them, and the label, assignee, lock, reaction and comment deletion endpoints refuse changes with `403` and `repository is archived (read-only)`. The REST endpoints that open and edit issues and add or edit comments (`POST` and `PATCH .../issues`, `POST .../issues/{number}/comments`, `PATCH .../issues/comments/{id}`) and the MCP `create_issue` tool don't check the archived state and still accept changes.
46+
47+## Open an issue
48+
49+### Open an issue in the web UI
50+
51+1. Go to the repository and open the **Issues** tab, or go straight to `https://diffinery.com/acme/website/issues/new`.
52+2. Click **New issue**.
53+3. Fill in **Title** (required) and **Description** (Markdown).
54+4. If you have triage access and the repository has labels, tick the **Labels** to apply.
55+5. Click **Submit new issue**. You land on the new issue's page.
56+
57+You can prefill the title with a query parameter: `https://diffinery.com/acme/website/issues/new?title=Broken%20link%20on%20pricing%20page`.
58+
59+### Open an issue with the CLI
60+
61+Install the CLI (needs Node.js 22 or later) and log in with a token:
62+
63+```sh
64+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
65+diffinery login
66+```
67+
68+Then, inside a clone of the repository (or with `-R acme/website`):
69+
70+```sh
71+diffinery issue create -R acme/website \
72+ --title "Broken link on pricing page" \
73+ --body "The *Contact sales* link returns 404." \
74+ --label bug --label website \
75+ --assignee octocat
76+```
77+
78+| Flag | Meaning |
79+| --- | --- |
80+| `-t, --title TEXT` | Title (required) |
81+| `-b, --body TEXT` | Description (Markdown) |
82+| `--body-file FILE` | Read the description from a file, or from stdin with `-` |
83+| `-l, --label NAME` | Add a label (repeat for more) |
84+| `-a, --assignee LOGIN` | Assign a login (repeat for more) |
85+| `-R, --repo OWNER/NAME` | Repository (default: inferred from the git `origin` remote) |
86+| `--json` | Print the created issue as JSON |
87+
88+The command prints `✓ Opened issue #12 Broken link on pricing page` and the issue URL.
89+
90+### Open an issue with the REST API
91+
92+```sh
93+curl -X POST https://diffinery.com/api/v3/repos/acme/website/issues \
94+ -H "Authorization: Bearer dfy_..." \
95+ -H "Content-Type: application/json" \
96+ -d '{"title": "Broken link on pricing page", "body": "The *Contact sales* link returns 404.", "labels": ["bug"], "assignees": ["octocat"]}'
97+```
98+
99+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.
100+
101+### Open an issue over MCP
102+
103+The `create_issue` tool takes `owner`, `repo`, `title`, and optionally `body` and `labels`. See [MCP server](/docs/agents/mcp).
104+
105+## Markdown
106+
107+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.
108+
109+- Raw HTML is escaped, never rendered.
110+- Only `http(s)` URLs, `#fragment` links and relative paths are kept in links and images. Relative links resolve against the repository's default branch.
111+- Task list items (`- [ ]` and `- [x]`) render as checkboxes. They are display only; edit the text to tick them.
112+- A body larger than 512 KB is shown as plain preformatted text.
113+
114+## Labels
115+
116+Labels are defined per repository, each with a name, a six-digit hex color and an optional description.
117+
118+### Create, change and delete labels
119+
120+In the web UI (triage or higher):
121+
122+1. Open the **Issues** tab and click **Labels**, or go to `https://diffinery.com/acme/website/labels`.
123+2. Under **New label**, enter a **Name** (up to 50 characters), an optional **Description** (up to 100 characters) and pick a **Color**.
124+3. Click **Create label**.
125+
126+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.
127+
128+With the REST API (triage or higher for changes):
129+
130+```sh
131+# List labels, or read one
132+curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/labels
133+curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/labels/bug
134+
135+# Create a label
136+curl -X POST https://diffinery.com/api/v3/repos/acme/website/labels \
137+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
138+ -d '{"name": "bug", "color": "d73a4a", "description": "Something is broken"}'
139+
140+# Rename it, or change its color or description
141+curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/labels/bug \
142+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
143+ -d '{"new_name": "type: bug", "color": "b60205"}'
144+
145+# Delete it
146+curl -X DELETE "https://diffinery.com/api/v3/repos/acme/website/labels/type%3A%20bug" -H "Authorization: Bearer dfy_..."
147+```
148+
149+| Rule | Detail |
150+| --- | --- |
151+| 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`. |
152+| Color | Six hex digits, with or without a leading `#`. Defaults to `ededed`. Anything else fails with `color must be a 6-digit hex code`. |
153+| Description | Up to 100 characters (longer text is cut). |
154+| Rename | `new_name` renames the label on every issue that has it. |
155+| Delete | `DELETE` removes the label from every issue that has it, unlike the web UI's **Delete**. |
156+
157+Labels are returned as GitHub label objects: `id`, `node_id`, `url`, `name`, `color`, `default` (always `false`) and `description`.
158+
159+### Apply labels to an issue
160+
161+- **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.
162+- **CLI:** `diffinery issue create --label NAME` when you open the issue.
163+- **REST API, whole list:** send the complete list in `labels` with `PATCH /repos/{owner}/{repo}/issues/{number}`. It replaces the previous list.
164+
165+```sh
166+curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/12 \
167+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
168+ -d '{"labels": ["bug", "website"]}'
169+```
170+
171+- **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.
172+
173+| Method and path | Effect |
174+| --- | --- |
175+| `GET /repos/{owner}/{repo}/issues/{number}/labels` | List the issue's labels |
176+| `POST /repos/{owner}/{repo}/issues/{number}/labels` | Add labels, keeping the existing ones. Body: `{"labels": ["bug"]}` or `["bug"]`. |
177+| `PUT /repos/{owner}/{repo}/issues/{number}/labels` | Replace all labels |
178+| `DELETE /repos/{owner}/{repo}/issues/{number}/labels/{name}` | Remove one label (`404 Label does not exist` if the issue doesn't have it) |
179+| `DELETE /repos/{owner}/{repo}/issues/{number}/labels` | Remove all labels (`204`) |
180+
181+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`).
182+
183+- **MCP:** `update_issue` with `labels` (the complete list).
184+
185+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.
186+
187+## Assignees
188+
189+An issue can have several assignees. Assigned people are notified and follow the thread from then on.
190+
191+- **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.
192+- **CLI:** `diffinery issue create --assignee octocat`.
193+- **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}`.
194+- **REST API, add or remove (triage or higher):**
195+
196+```sh
197+# Add assignees (returns the issue, 201)
198+curl -X POST https://diffinery.com/api/v3/repos/acme/website/issues/12/assignees \
199+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
200+ -d '{"assignees": ["octocat", "review-bot"]}'
201+
202+# Remove assignees (returns the issue)
203+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/12/assignees \
204+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
205+ -d '{"assignees": ["review-bot"]}'
206+```
207+
208+These endpoints allow at most 10 assignees (`an issue can have at most 10 assignees`), and removal ignores letter case.
209+
210+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.
211+
212+## Comments
213+
214+### Add a comment
215+
216+- **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`.
217+- **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.
218+- **REST API:**
219+
220+```sh
221+curl -X POST https://diffinery.com/api/v3/repos/acme/website/issues/12/comments \
222+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
223+ -d '{"body": "Fixed in #15."}'
224+```
225+
226+- **MCP:** the `comment` tool (needs triage or higher when used over MCP).
227+
228+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`.
229+
230+### List comments
231+
232+```sh
233+# Comments on one issue (since: only comments updated at or after this time)
234+curl -H "Authorization: Bearer dfy_..." "https://diffinery.com/api/v3/repos/acme/website/issues/12/comments?since=2026-10-01T00:00:00Z"
235+
236+# Comments on every issue and pull request in the repository
237+curl -H "Authorization: Bearer dfy_..." "https://diffinery.com/api/v3/repos/acme/website/issues/comments?sort=updated&direction=desc&per_page=50"
238+
239+# One comment
240+curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/issues/comments/1234
241+```
242+
243+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](/docs/collaboration/pull-requests#line-comments)). `diffinery issue view 12` prints the issue and all its comments.
244+
245+### Edit or delete a comment
246+
247+Edit or delete a comment through the REST API, using its `id`:
248+
249+```sh
250+curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/comments/1234 \
251+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
252+ -d '{"body": "Fixed in #15 and #16."}'
253+
254+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/comments/1234 \
255+ -H "Authorization: Bearer dfy_..."
256+```
257+
258+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.
259+
260+### Edit the title and description
261+
262+- **Web UI:** click **Edit** next to the title, change **Title** or **Description**, and click **Save**. An empty title is rejected with `title is required`.
263+- **REST API:** `PATCH /repos/{owner}/{repo}/issues/{number}` with `title` and/or `body`.
264+- **MCP:** `update_issue` with `title` and/or `body`.
265+
266+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](/docs/reference/webhook-events).
267+
268+## Lock a conversation
269+
270+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.
271+
272+```sh
273+# Lock, with an optional reason
274+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/issues/12/lock \
275+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
276+ -d '{"lock_reason": "too heated"}'
277+
278+# Unlock
279+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/12/lock \
280+ -H "Authorization: Bearer dfy_..."
281+```
282+
283+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.
284+
285+## Reactions
286+
287+Reactions are emoji responses to an issue, a pull request, a conversation comment, a pull request line comment or a [release](/docs/collaboration/releases). The allowed `content` values are the same as on GitHub: `+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket` and `eyes`.
288+
289+| Subject | Endpoint |
290+| --- | --- |
291+| Issue or pull request | `/repos/{owner}/{repo}/issues/{number}/reactions` |
292+| Conversation comment | `/repos/{owner}/{repo}/issues/comments/{id}/reactions` |
293+| Pull request line comment | `/repos/{owner}/{repo}/pulls/comments/{id}/reactions` |
294+| Release | `/repos/{owner}/{repo}/releases/{id}/reactions` |
295+
296+```sh
297+# Add a reaction: 201 when new, 200 when you had already reacted with that content
298+curl -X POST https://diffinery.com/api/v3/repos/acme/website/issues/12/reactions \
299+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
300+ -d '{"content": "+1"}'
301+
302+# List reactions, optionally only one kind
303+curl -H "Authorization: Bearer dfy_..." "https://diffinery.com/api/v3/repos/acme/website/issues/12/reactions?content=heart"
304+
305+# Remove a reaction by its id
306+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/12/reactions/7 -H "Authorization: Bearer dfy_..."
307+```
308+
309+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:
310+
311+- 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`.
312+- An unknown `content` fails with `422` and `content must be one of +1, -1, laugh, confused, heart, hooray, rocket, eyes`.
313+- On a locked issue or pull request, only triage or higher can react.
314+- You can delete your own reactions; admins can delete anyone's (`you can only delete your own reactions` otherwise).
315+
316+Reactions are available through the REST API only; the web UI, the CLI and MCP don't show them yet.
317+
318+## Events and timeline
319+
320+Diffinery records what happens to an issue or pull request as events, with the same names as GitHub:
321+
322+| Event | Extra fields |
323+| --- | --- |
324+| `labeled`, `unlabeled` | `label` (`name`, `color`) |
325+| `assigned`, `unassigned` | `assignee`, `assigner` |
326+| `renamed` | `rename` (`from`, `to`) |
327+| `closed` | `state_reason`; for a merge, `commit_id` |
328+| `reopened` | none |
329+| `locked`, `unlocked` | `lock_reason` |
330+| `review_requested`, `review_request_removed` | `requested_reviewer`, `review_requester` |
331+| `review_dismissed` | `dismissed_review` (`state`, `review_id`, `dismissal_message`) |
332+| `merged` | `commit_id` |
333+| `ready_for_review`, `converted_to_draft` | none; pull requests only (see [Pull requests](/docs/collaboration/pull-requests)) |
334+
335+Every event also has `id`, `actor`, `event`, `created_at`, `commit_id` and `commit_url`.
336+
337+| Method and path | Returns |
338+| --- | --- |
339+| `GET /repos/{owner}/{repo}/issues/{number}/events` | The events of one issue or pull request, oldest first |
340+| `GET /repos/{owner}/{repo}/issues/events` | Events across the repository, newest first, each with its `issue` |
341+| `GET /repos/{owner}/{repo}/issues/events/{id}` | One event |
342+| `GET /repos/{owner}/{repo}/issues/{number}/timeline` | Events, comments (`"event": "commented"`) and reviews (`"event": "reviewed"`) in time order |
343+
344+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.
345+
346+## Mentions
347+
348+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](/docs/accounts/notifications).
349+
350+## Close and reopen
351+
352+### Close reasons
353+
354+| Reason | Set by | Shown as |
355+| --- | --- | --- |
356+| `completed` | **Close issue**, `diffinery issue close`, or `state: "closed"` without a reason | Closed |
357+| `not_planned` | **Close as not planned**, `--reason not_planned`, or `state_reason: "not_planned"` | Closed as not planned |
358+| `merged` | A pull request that merged | Merged |
359+
360+### Close and reopen in the web UI
361+
362+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).
363+
364+### Close and reopen with the CLI
365+
366+```sh
367+diffinery issue close 12 # reason: completed
368+diffinery issue close 12 --reason not_planned --comment "Out of scope for v2."
369+diffinery issue reopen 12 --comment "Still happening on Safari."
370+```
371+
372+`--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.
373+
374+### Close and reopen with the REST API
375+
376+```sh
377+curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/issues/12 \
378+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
379+ -d '{"state": "closed", "state_reason": "not_planned"}'
380+```
381+
382+Send `{"state": "open"}` to reopen; the reason is cleared.
383+
384+### Close issues from a pull request
385+
386+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.
387+
388+## Subscribe and unsubscribe
389+
390+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:
391+
392+- **Subscribe** sends you every new comment and state change: `Subscribed. You'll be notified about all activity.`
393+- **Unsubscribe** mutes the thread: `Unsubscribed. You'll still be notified if you're @mentioned, assigned or asked to review.`
394+
395+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](/docs/accounts/notifications).
396+
397+## Find issues
398+
399+### The issue list
400+
401+The **Issues** tab (`https://diffinery.com/acme/website/issues`) lists issues newest first, 25 per page.
402+
403+- **Open and Closed:** the two counters at the top switch between open and closed issues.
404+- **Search:** the search box (press `/` to focus it) matches text anywhere in the title or the description, up to 200 characters.
405+- **Label:** pick one from the **Label ▾** menu, or click a label on any issue.
406+- **Author:** click an author's login on any issue to show only their issues.
407+- Active filters appear at the top of the list with a **clear** link. Filters combine.
408+
409+Filters are query parameters, so you can bookmark or share them:
410+
411+```text
412+https://diffinery.com/acme/website/issues?state=closed&label=bug&author=octocat&q=pricing
413+```
414+
415+### List issues with the CLI
416+
417+```sh
418+diffinery issue list -R acme/website --state all --label bug --author octocat --search pricing --limit 50
419+diffinery issue view 12
420+```
421+
422+| Flag | Meaning |
423+| --- | --- |
424+| `-s, --state STATE` | `open` (default), `closed` or `all` |
425+| `-l, --label NAME` | Only issues with this label |
426+| `-A, --author LOGIN` | Only issues opened by this login |
427+| `-S, --search QUERY` | Text to find in titles and descriptions |
428+| `-L, --limit N` | Maximum number to show (default 30) |
429+
430+`diffinery issue list` hides pull requests. All issue commands accept `--json`.
431+
432+### List issues with the REST API
433+
434+```sh
435+curl -H "Authorization: Bearer dfy_..." \
436+ "https://diffinery.com/api/v3/repos/acme/website/issues?state=all&labels=bug&creator=octocat&q=pricing&per_page=50&page=1"
437+```
438+
439+| Parameter | Meaning |
440+| --- | --- |
441+| `state` | `open` (default), `closed` or `all` |
442+| `labels` | Comma-separated label names (exact match); an issue must have all of them |
443+| `assignee` | A login, `none` (no assignees) or `*` (any assignee) |
444+| `creator` | Author login (case-insensitive) |
445+| `since` | Only issues updated at or after this ISO 8601 time |
446+| `sort` | `created` (default), `updated` or `comments` |
447+| `direction` | `desc` (default) or `asc` |
448+| `q` | Text to find in titles and descriptions (a Diffinery extension) |
449+| `per_page` | Results per page, default 30, at most 100 |
450+| `page` | Page number, starting at 1 |
451+
452+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.
453+
454+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.
455+
456+### Search issues and pull requests
457+
458+`GET /search/issues` searches issues and pull requests with GitHub's query syntax, across every repository you can read:
459+
460+```sh
461+curl -H "Authorization: Bearer dfy_..." --get https://diffinery.com/api/v3/search/issues \
462+ --data-urlencode 'q=repo:acme/website is:issue is:open label:bug -label:wontfix pricing' \
463+ --data-urlencode 'sort=updated' --data-urlencode 'order=desc'
464+```
465+
466+The response has `total_count`, `incomplete_results` and `items` (issue objects, each with `"score": 1`), with a `Link` header for further pages.
467+
468+| Qualifier | Meaning |
469+| --- | --- |
470+| `repo:acme/website` | Only this repository (repeat for several) |
471+| `user:`, `org:`, `owner:` | Only repositories of this account |
472+| `is:issue`, `is:pr` (also `type:`) | Issues or pull requests |
473+| `is:open`, `is:closed` (also `state:`) | State; `-is:open` means closed |
474+| `is:merged`, `is:unmerged` | Merged or not (pull requests only) |
475+| `is:draft`, `draft:true`, `draft:false` | Draft pull requests or not |
476+| `is:locked`, `is:unlocked` | Locked conversations or not |
477+| `label:bug`, `label:bug,ui` | Has these labels (all of them); `-label:` excludes |
478+| `no:label`, `no:assignee` | Has no labels, or no assignees |
479+| `author:octocat`, `-author:review-bot` | Opened by, or not by, this login |
480+| `assignee:octocat` | Assigned to this login |
481+| `head:fix-pricing-link`, `base:main` | Pull requests by head or base branch |
482+| `in:title`, `in:body`, `in:comments` | Where words must appear (default: title and body); combine with commas |
483+| `created:`, `updated:`, `closed:` | Date ranges: `>2026-09-01`, `>=`, `<`, `<=`, `2026-09-01..2026-09-30`, `2026-09-01..*`, or one day |
484+| `sort:created-asc`, `sort:updated`, `sort:comments-desc` | Order (default: newest created first) |
485+
486+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.
487+
488+Limits:
489+
490+- A query can be up to 256 characters (`The search is longer than 256 characters.`), and an empty `q` fails with `Validation Failed`.
491+- 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:`.
492+- Only the first 1,000 results can be paged through (`Only the first 1000 search results are available`).
493+- In each repository, the 5,000 newest issues and pull requests are searched, and `in:comments` reads the 2,000 newest comments.
494+- Without a token, only public repositories are searched. A workflow job token searches only its own repository.
495+
496+The web UI's issue list and `diffinery issue list --search` don't use this syntax; they match plain text in titles and descriptions.
497+
498+## Differences from GitHub
499+
500+- An issue's `id` is its number; issues are addressed by number only.
501+- `/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.
502+- There are no milestones, issue types, issue templates, transfers or pinned issues.
503+- Deleting a label in the web UI keeps its name on issues; deleting it through the API removes it from them.
504+- Locking, reactions, events and comment deletion are available through the REST API only, not in the web UI.
505+- `GET /repos/{owner}/{repo}/assignees` lists direct collaborators only, not organization members.
506+- Thread subscriptions have no API.
507+
508+## Troubleshooting
509+
510+| Message | Cause and fix |
511+| --- | --- |
512+| `title is required` | The title was empty. Give the issue a title. |
513+| `body is required` | You posted an empty comment. |
514+| `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. |
515+| `triage access required` | Creating, changing or deleting labels, setting labels and assignees in the web UI, and the issue label, assignee and lock endpoints need triage or higher. |
516+| `#12 not found` | There is no issue or pull request with that number. |
517+| `conversation is locked` | The issue is locked. Only triage or higher can comment or react. |
518+| `a label with this name already exists` | Use `PATCH /labels/{name}` to change an existing label. |
519+| `incomplete_results: true` in a search | You can read more than 50 repositories. Add `repo:`, `user:` or `org:` to the query. |
520+| `repository not found` | The repository doesn't exist, or your account or token can't see it (private repositories return 404). |
521+| `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_...`. |
522+| `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. |
addedapps/diffinery/docs/collaboration/merge-queue.md+310−0View file
@@ -1,0 +1,310 @@
1+---
2+title: Merge queue
3+description: 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.
4+---
5+
6+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.
7+
8+## How it works
9+
10+1. You turn on **Require a merge queue** in the base branch's protection rule and list the **Required status checks**.
11+2. A pull request with no merge blockers is added to the queue with **Merge when ready** (or the CLI, API or MCP).
12+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>`.
13+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.
14+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.
15+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.
16+
17+Because each group already contains everything ahead of it, the base branch only ever moves to commits that were tested exactly as they land.
18+
19+## Turn on the merge queue
20+
21+You need admin access to the repository, and you must be a person: agents can't change branch protection.
22+
23+### Turn it on in the web UI
24+
25+1. Open the repository's **Settings** and choose **Branch protection** (`https://diffinery.com/acme/website/settings/branches`).
26+2. Open the rule for the branch (for example `main`), or fill in **Add a rule** with `main` as the **Branch name pattern**.
27+3. In **Required status checks**, list the checks that must pass, comma-separated and exactly as they appear on pull requests, for example `CI / test, CI / lint`.
28+4. Tick **Require a merge queue**, then set:
29+ - **Merge method**: **Create a merge commit**, **Squash and merge** or **Rebase and merge**.
30+ - **Build concurrency**: how many merge groups are built and checked at once.
31+ - **Check timeout (minutes)**: how long a merge group may wait for its required checks.
32+5. Click **Create rule** or **Save rule**. You see `Branch protection saved.`
33+
34+### Turn it on with the REST API
35+
36+`PUT /repos/{owner}/{repo}/branches/{branch}/protection` replaces the whole rule, so send every setting you want to keep:
37+
38+```sh
39+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \
40+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
41+ -d '{
42+ "requirePullRequest": true,
43+ "requiredApprovals": 1,
44+ "requiredChecks": ["CI / test"],
45+ "mergeQueue": { "method": "squash", "buildConcurrency": 5, "checkTimeoutMinutes": 30 }
46+ }'
47+```
48+
49+`"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](/docs/repositories/branch-protection) for the other rule settings.
50+
51+### Settings
52+
53+| Setting | API field | Default | Allowed values |
54+| --- | --- | --- | --- |
55+| Merge method | `mergeQueue.method` | `merge` | `merge`, `squash`, `rebase` (anything else becomes `merge`) |
56+| Build concurrency | `mergeQueue.buildConcurrency` | 5 | 1 to 20 (values outside are clamped) |
57+| Check timeout (minutes) | `mergeQueue.checkTimeoutMinutes` | 60 | 1 to 360 (values outside are clamped) |
58+
59+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.`
60+
61+## Required checks
62+
63+The queue decides on **Required status checks** from the same branch protection rule:
64+
65+- 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](/docs/collaboration/pull-requests#merge-blockers)).
66+- Its merge group needs the same required checks to pass on the merge group commit before it merges.
67+- 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.
68+- 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](/docs/repositories/branch-protection#required-checks).
69+
70+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.
71+
72+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](/docs/collaboration/checks-and-statuses#required-checks-and-the-merge-queue)). See [Checks and statuses](/docs/collaboration/checks-and-statuses).
73+
74+> **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.
75+
76+## Write workflows for the merge queue
77+
78+Add `merge_group` to the triggers of each workflow that produces a required check:
79+
80+```yaml
81+name: CI
82+on:
83+ pull_request:
84+ branches: [main]
85+ merge_group:
86+ push:
87+ branches: [main]
88+jobs:
89+ test:
90+ runs-on: ubuntu-latest
91+ steps:
92+ - uses: actions/checkout@v4
93+ - run: npm ci
94+ - run: npm test
95+```
96+
97+With this workflow, add `CI / test` to **Required status checks**.
98+
99+What a `merge_group` run sees:
100+
101+| Value | Content |
102+| --- | --- |
103+| `github.event_name` | `merge_group` |
104+| `github.ref` | `refs/heads/gh-readonly-queue/main/pr-12-<head sha>` |
105+| `github.sha` | The merge group commit |
106+| `github.event.action` | `checks_requested` |
107+| `github.event.merge_group.head_sha` | The merge group commit |
108+| `github.event.merge_group.head_ref` | The merge group branch, as a full ref |
109+| `github.event.merge_group.base_sha` | The commit the group was built on (the base tip, or the group ahead of it) |
110+| `github.event.merge_group.base_ref` | `refs/heads/main` |
111+| `github.event.merge_group.head_commit` | `id`, `tree_id`, `message`, `timestamp`, `author`, `committer` |
112+
113+Trigger filters:
114+
115+- `merge_group` accepts `types` (only `checks_requested` exists) and `branches` / `branches-ignore`, which match the **base** branch (`main`), not the queue branch.
116+- 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/**']`.
117+
118+`actions/checkout` checks out the merge group commit, so the job tests exactly what would land.
119+
120+## Add a pull request to the queue
121+
122+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](/docs/collaboration/pull-requests#drafts)). You need write access, and through the API a token with the `write` scope. Agents can queue a pull request only into a branch whose rule has **Allow agents to merge pull requests**.
123+
124+### Queue in the web UI
125+
126+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.`
127+
128+1. Pick the **Merge method**. It defaults to the queue's method.
129+2. Leave **Delete fix-pricing-link after merge** ticked to delete the head branch when it merges.
130+3. Click **Merge when ready**. You see `Added to the merge queue.`, or `Merged through the merge queue.` if it merged straight away.
131+
132+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.
133+
134+### Queue with the CLI
135+
136+```sh
137+diffinery pr merge 12 --squash --delete-branch
138+```
139+
140+`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.
141+
142+### Queue with the REST API
143+
144+```sh
145+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/merge-queue \
146+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
147+ -d '{"merge_method": "squash", "delete_branch": true}'
148+```
149+
150+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:
151+
152+```json
153+{
154+ "state": "queued",
155+ "entry": {
156+ "number": 12,
157+ "title": "Fix the Contact sales link",
158+ "base_ref": "main",
159+ "position": 2,
160+ "state": "checking",
161+ "head_sha": "3f2a9c1e...",
162+ "merge_method": "squash",
163+ "delete_branch": true,
164+ "enqueued_by": { "login": "octocat" },
165+ "enqueued_at": "2026-10-13T09:12:44.000Z",
166+ "merge_group": {
167+ "head_sha": "9be41d07...",
168+ "head_ref": "refs/heads/gh-readonly-queue/main/pr-12-3f2a9c1e...",
169+ "base_sha": "51c0e2aa...",
170+ "built_at": "2026-10-13T09:12:45.000Z"
171+ },
172+ "checks": [{ "name": "CI / test", "state": "in_progress" }]
173+ },
174+ "merge_commit_sha": null,
175+ "reason": null
176+}
177+```
178+
179+| `state` | HTTP status | Meaning |
180+| --- | --- | --- |
181+| `queued` | 201 | In the queue; `entry` describes it |
182+| `merged` | 200 | Merged during this call; `merge_commit_sha` is the commit |
183+| `removed` | 200 | It joined but left at once; `reason` says why |
184+
185+Adding a pull request that is already queued returns its current entry.
186+
187+### Queue over MCP
188+
189+`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.
190+
191+## Order and merge groups
192+
193+- **Order.** Pull requests are queued first come, first served, per base branch. `position` 1 is the front of the queue.
194+- **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.
195+- **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`.
196+- **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`.
197+- **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.
198+- **Group commits** depend on the merge method:
199+
200+| Method | Merge group commit |
201+| --- | --- |
202+| `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. |
203+| `squash` | One commit on top of the previous group. Subject `<title> (#12)`, body a `* <subject>` line per commit. Author: the pull request's author. |
204+| `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. |
205+
206+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`.
207+
208+## When a pull request merges
209+
210+When its turn comes, the base branch fast-forwards to the merge group commit. Then, as for any merge:
211+
212+- 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;
213+- `push` workflows and webhooks run for the base branch;
214+- open issues referenced with closing keywords (`Fixes #12`) close;
215+- the head branch is deleted if **Delete ... after merge** was ticked;
216+- the merge group branch is deleted. Its runs are left to finish.
217+
218+## When a pull request leaves the queue
219+
220+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>.`
221+
222+| Reason in the comment | What happened |
223+| --- | --- |
224+| `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`. |
225+| `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. |
226+| `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. |
227+| `rebase merges cannot include merge commits; use a merge commit or squash` | The queue uses `rebase` and the pull request contains a merge commit. |
228+| `new commits were pushed to its branch` | The head branch moved after the pull request was queued. Queue it again once its checks pass. |
229+| `main no longer uses a merge queue` | The merge queue was turned off for the branch. |
230+| `the base branch main no longer exists` | The base branch was deleted. |
231+| `the pull request was closed` | No comment is posted. |
232+| `octocat removed it from the queue` | Someone removed it by hand. No comment is posted. |
233+
234+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.
235+
236+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.
237+
238+## Rebuilding groups
239+
240+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:
241+
242+- a pull request ahead of it left the queue, so the groups behind it are rebuilt without it;
243+- 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.
244+
245+A rebuilt group gets a new commit, runs its `merge_group` workflows again, and its check timeout starts over.
246+
247+## Remove a pull request from the queue
248+
249+- **Web UI:** click **Remove from queue** in the merge box. You see `Removed from the merge queue.`
250+- **CLI:** `diffinery pr merge 12 --disable-auto`
251+- **REST API:** `DELETE /repos/{owner}/{repo}/pulls/{number}/merge-queue` (returns `204`, or `404 #12 is not in the merge queue`).
252+- **MCP:** the `dequeue_pull_request` tool.
253+
254+You need write access. Closing the pull request or pushing to its branch also removes it.
255+
256+## The queue page
257+
258+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.`
259+
260+The same data is available from the API (read access):
261+
262+```sh
263+curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/merge-queue/main
264+```
265+
266+```json
267+{
268+ "branch": "main",
269+ "enabled": true,
270+ "settings": { "method": "squash", "buildConcurrency": 5, "checkTimeoutMinutes": 30 },
271+ "entries": []
272+}
273+```
274+
275+`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.
276+
277+## Bypass the queue
278+
279+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:
280+
281+- **Web UI:** **Merge now (bypass the queue)**, which asks `Merge directly, bypassing the merge queue?` first. It merges with the queue's merge method.
282+- **CLI:** `diffinery pr merge 12 --admin`. It sends `bypass_merge_queue` and `bypass_rules`, so the CLI doesn't fall back to queueing the pull request.
283+- **REST API:** `PUT /repos/{owner}/{repo}/pulls/{number}/merge` with `"bypass_merge_queue": true`. `"bypass_rules": true` alone doesn't skip the queue.
284+
285+Skipping the queue also skips the pull request's unmet branch rules (missing approvals, required checks, linear history), as with an [admin bypass](/docs/collaboration/pull-requests#admin-bypass). When the pull request had unmet blockers, the merge is recorded in the [audit log](/docs/security/audit-log#git-and-pull-requests) as `pull_request.merge_bypassed`.
286+
287+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.
288+
289+## Differences from GitHub
290+
291+- 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.
292+- 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`.
293+- 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.
294+- The queue is configured inside a branch protection rule (`mergeQueue`), not a separate ruleset.
295+- `rebase` in the queue replays the commits; a direct `rebase` merge outside the queue is a fast-forward only.
296+- Pull requests opened by agents still need a human approval before they can be queued, and agents can queue only where agents may merge.
297+
298+## Troubleshooting
299+
300+| Message or symptom | Cause and fix |
301+| --- | --- |
302+| `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. |
303+| `pull request cannot be added to the merge queue: ...` | The pull request has merge blockers, listed after the colon. Fix them first. |
304+| `main requires linear history; use squash or rebase` | Pick `squash` or `rebase`, or change the queue's method. |
305+| `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. |
306+| `merge queue branches are read-only` | You pushed to a `gh-readonly-queue/` branch. Push to the pull request's branch instead. |
307+| 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. |
308+| 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. |
309+| Pull requests merge without CI | No required checks are configured. Add them to the rule. |
310+| `push` workflows run on `gh-readonly-queue/...` | Restrict `on.push.branches`, or add `branches-ignore: ['gh-readonly-queue/**']`. |
addedapps/diffinery/docs/collaboration/pull-requests.md+717−0View file
@@ -1,0 +1,717 @@
1+---
2+title: Pull requests
3+description: Open, review, check, update and merge pull requests in the web UI, the CLI, the REST API and over MCP, including intent, drafts and merge blockers.
4+---
5+
6+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.
7+
8+## How pull requests work
9+
10+- **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.
11+- **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`.
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.
13+- **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.
14+- **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).
15+- **Agents.** Pull requests opened by agents always need at least one human approval. See [Pull requests opened by agents](#pull-requests-opened-by-agents).
16+
17+## Permissions
18+
19+| Action | Who can do it |
20+| --- | --- |
21+| View pull requests | Anyone who can read the repository |
22+| Open a pull request | Write or higher |
23+| Comment and leave a Comment review | Anyone signed in who can read the repository |
24+| Approve or request changes | Write or higher, and not the pull request's author |
25+| Request reviewers after opening, or remove a request | The author, or triage or higher |
26+| Edit title and description, close, reopen | The author, or triage or higher |
27+| Mark a draft ready for review, or convert it back to a draft | The author, or write or higher |
28+| Edit or delete a comment or line comment | Its author, or maintain or higher |
29+| Lock and unlock the conversation | Triage or higher |
30+| Comment, review or react on a locked pull request | Triage or higher |
31+| Dismiss a review | Maintain or higher, not the pull request's author, and never an agent |
32+| Update the branch with its base (`update-branch`) | Write or higher, subject to the head branch's protection |
33+| Merge, or add to and remove from the merge queue | Write or higher, subject to [merge blockers](#merge-blockers) |
34+
35+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`. See [Permissions](/docs/accounts/permissions) and [Tokens](/docs/accounts/tokens).
36+
37+## Open a pull request
38+
39+### From a push
40+
41+When you push a new branch (other than the default branch) that has no pull request yet, git prints a link to open one:
42+
43+```text
44+remote: Create a pull request for 'fix-pricing-link':
45+remote: https://diffinery.com/acme/website/compare/fix-pricing-link
46+```
47+
48+Pushing to a branch that already has an open pull request prints `Updated pull request #12: https://diffinery.com/acme/website/pull/12`.
49+
50+### In the web UI
51+
52+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.
53+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.
54+3. Under **Open a pull request**, fill in:
55+ - **Title** (required). With a single commit it defaults to that commit's subject; otherwise to the branch name.
56+ - **Intent**: what this change is meant to do. Optional, but recommended.
57+ - **Description** (Markdown). With a single commit it defaults to the rest of that commit's message.
58+ - **Create as draft** if the work isn't ready to merge.
59+4. Click **Create pull request**.
60+
61+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.`
62+
63+### With the CLI
64+
65+Install the CLI (needs Node.js 22 or later) and log in:
66+
67+```sh
68+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
69+diffinery login
70+```
71+
72+From a clone with your branch checked out:
73+
74+```sh
75+git push -u origin fix-pricing-link
76+diffinery pr create \
77+ --title "Fix the Contact sales link" \
78+ --intent "Point the pricing page's Contact sales link at /contact; no other behaviour changes." \
79+ --body "Fixes #12" \
80+ --reviewer octocat
81+```
82+
83+| Flag | Meaning |
84+| --- | --- |
85+| `-t, --title TEXT` | Title (required) |
86+| `-b, --body TEXT` | Description (Markdown) |
87+| `--body-file FILE` | Read the description from a file, or stdin with `-` |
88+| `-H, --head BRANCH` | Branch with your changes (default: the current branch) |
89+| `-B, --base BRANCH` | Branch to merge into (default: the repository's default branch) |
90+| `-d, --draft` | Open as a draft |
91+| `--intent TEXT` | What the change is for (agents should always set it) |
92+| `-r, --reviewer LOGIN` | Request a review (repeat for more) |
93+| `-R, --repo OWNER/NAME` | Repository (default: inferred from the git `origin` remote) |
94+| `--json` | Print the pull request as JSON |
95+
96+### With the REST API
97+
98+```sh
99+curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls \
100+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
101+ -d '{
102+ "title": "Fix the Contact sales link",
103+ "head": "fix-pricing-link",
104+ "base": "main",
105+ "body": "Fixes #12",
106+ "intent": "Point the pricing page'\''s Contact sales link at /contact; no other behaviour changes.",
107+ "draft": false,
108+ "reviewers": ["octocat"]
109+ }'
110+```
111+
112+`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`.
113+
114+### Over MCP
115+
116+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](/docs/agents/mcp).
117+
118+### Errors when opening
119+
120+| Message | Cause |
121+| --- | --- |
122+| `head and base must be different branches` | Head and base are the same branch. |
123+| `head branch 'x' does not exist` | Push the branch first. |
124+| `base branch 'x' does not exist` | Check the base name. |
125+| `no commits between main and x` | The head has nothing the base doesn't already contain. |
126+| `a pull request already exists for x: #12` | Use the open pull request, or close it first. The response also includes `number`. |
127+| `title is required` | Give it a title. |
128+| `write access required` | Opening a pull request needs write access. |
129+
130+## Intent
131+
132+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.
133+
134+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.
135+
136+## Drafts
137+
138+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:
139+
140+- shows a **Draft** badge and appears in lists with a `draft` label,
141+- can be reviewed, and runs its `pull_request` workflows like any other pull request,
142+- 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.
143+
144+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.
145+
146+### Mark a draft ready for review
147+
148+- **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.
149+- **CLI:** `diffinery pr ready 12` prints `✓ Marked #12 ready for review`. With `--json` it prints the pull request instead.
150+- **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.
151+
152+```sh
153+curl -X PATCH https://diffinery.com/api/v3/repos/acme/website/pulls/12 \
154+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
155+ -d '{"draft": false}'
156+```
157+
158+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.
159+
160+### Convert a pull request to a draft
161+
162+- **CLI:** `diffinery pr ready 12 --undo` prints `✓ Converted #12 to a draft`.
163+- **REST API:** `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{"draft": true}`.
164+
165+The web UI has no button for this.
166+
167+### What changing the draft state does
168+
169+- The pull request's events get `ready_for_review` or `converted_to_draft`, and webhooks get a `pull_request` event with the same action. See [Webhook events](/docs/reference/webhook-events#pull_request).
170+- `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](/docs/actions/events#pull_request).
171+- Asking for the state the pull request already has changes nothing and sends no event.
172+- `PATCH` can change `draft` together with `title`, `body` and `state` in one request.
173+
174+| Error | Cause |
175+| --- | --- |
176+| `only the author or someone with write access can change draft status` (403) | Ask the author or a maintainer. |
177+| `the pull request is not open` (422) | Reopen it first. |
178+| `not a pull request` (404) | The number belongs to an issue. |
179+| `this token needs the write scope` (403) | Use a token with the `write` or `admin` scope. |
180+
181+## The pull request page
182+
183+The page at `https://diffinery.com/acme/website/pull/12` has four tabs. It updates live as comments, reviews, pushes and checks arrive.
184+
185+| Tab | Shows |
186+| --- | --- |
187+| **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**, **Assignees**, **Notifications** and **Participants** |
188+| **Commits** | The commits on the head branch that aren't on the base (up to 250) |
189+| **Checks** | Check runs and commit statuses reported on the head commit, and the workflow runs for this pull request |
190+| **Files changed** | The diff from the merge base to the head (up to 300 files), where you can comment on lines |
191+
192+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.
193+
194+Labels, assignees, comments, mentions, editing the title and description, and subscribing work exactly as on issues. See [Issues](/docs/collaboration/issues).
195+
196+## Reviews
197+
198+A review is one of three kinds:
199+
200+| Kind | Web UI label | API and MCP `event` | Effect |
201+| --- | --- | --- | --- |
202+| Approve | **Approve** | `APPROVE` | Counts toward required approvals (people only, not agents) |
203+| Request changes | **Request changes** | `REQUEST_CHANGES` | Blocks merging until that reviewer approves |
204+| Comment | **Comment** | `COMMENT` | Feedback only; needs a body or line comments |
205+
206+Rules:
207+
208+- You can't approve or request changes on your own pull request; you can still leave a Comment review.
209+- Approving and requesting changes need write access. Anyone signed in who can read the repository can leave a Comment review.
210+- Only open pull requests can be reviewed.
211+- 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).
212+- Approvals from agents are shown but never count toward required approvals. An agent's Request changes review does block merging.
213+- 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.
214+- Submitting any review removes you from the pull request's requested reviewers.
215+
216+Reviews are submitted immediately. There are no pending (draft) reviews.
217+
218+### Review in the web UI
219+
220+1. On the **Conversation** tab, expand **Review changes**.
221+2. Write a **Review summary** (Markdown).
222+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.
223+4. Click **Submit review**.
224+
225+### Review with the CLI
226+
227+```sh
228+diffinery pr review 12 --approve
229+diffinery pr review 12 --approve --body "Looks good."
230+diffinery pr review 12 --request-changes --body "Please add a test for the redirect."
231+diffinery pr review 12 --comment --body "Nit: the link text could be clearer."
232+```
233+
234+Exactly one of `-a, --approve`, `-r, --request-changes` or `-c, --comment` is required. `--body` (or `--body-file`) is required for request changes and comment.
235+
236+### Review with the REST API
237+
238+```sh
239+curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls/12/reviews \
240+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
241+ -d '{
242+ "event": "REQUEST_CHANGES",
243+ "body": "Please add a test.",
244+ "comments": [{ "path": "src/pricing.html", "line": 42, "side": "RIGHT", "body": "This should be /contact." }]
245+ }'
246+```
247+
248+`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`.
249+
250+### Review over MCP
251+
252+`review_pull_request` takes `owner`, `repo`, `number`, `event`, and optionally `body` and `comments` (`[{ path, line, side?, body }]`).
253+
254+### Dismiss a review
255+
256+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:
257+
258+- You need the maintain or admin role on the repository (a token with the `write` scope caps you at maintain, which is enough).
259+- You can't dismiss reviews on your own pull request.
260+- Agents can never dismiss reviews.
261+- Only `APPROVED` and `CHANGES_REQUESTED` reviews can be dismissed.
262+
263+Dismiss through the REST API with a message saying why:
264+
265+```sh
266+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/reviews/81/dismissals \
267+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
268+ -d '{"message": "The requested test was added in 3f2a9c1."}'
269+```
270+
271+`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.
272+
273+| Error | Cause |
274+| --- | --- |
275+| `message is required` (422) | Send a non-empty `message`. |
276+| `maintain access is required to dismiss reviews` (403) | Your role, or your token's scope, is below maintain. |
277+| `agents can't dismiss reviews` (403) | Dismiss as a person. |
278+| `you can't dismiss reviews on your own pull request` (403) | Ask another maintainer. |
279+| `only approving or change-requesting reviews can be dismissed` (422) | The review is a Comment review or already dismissed. |
280+| `review not found` (404) | Check the review id and the pull request number. |
281+
282+## Line comments
283+
284+Line comments attach to a file and line of the diff.
285+
286+- **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.
287+- **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`.
288+
289+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`).
290+
291+### Single line comments and replies with the REST API
292+
293+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.
294+
295+```sh
296+# A new line comment
297+curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls/12/comments \
298+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
299+ -d '{"body": "This should be /contact.", "path": "src/pricing.html", "line": 42, "side": "RIGHT"}'
300+
301+# A reply to line comment 57: it lands on the same file, line and side
302+curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls/12/comments/57/replies \
303+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
304+ -d '{"body": "Done in the latest push."}'
305+```
306+
307+`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.
308+
309+Other line comment endpoints:
310+
311+| Method and path | Purpose |
312+| --- | --- |
313+| `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`. |
314+| `GET /repos/{owner}/{repo}/pulls/comments/{id}` | One line comment |
315+| `PATCH /repos/{owner}/{repo}/pulls/comments/{id}` | Change its `body` (its author, or maintain or higher) |
316+| `DELETE /repos/{owner}/{repo}/pulls/comments/{id}` | Delete it (its author, or maintain or higher) |
317+
318+Comment ids are numbers, as on GitHub.
319+
320+## Requested reviewers
321+
322+Ask specific people or agents to review:
323+
324+- when opening: `reviewers` in the API and MCP, or `diffinery pr create --reviewer LOGIN`,
325+- later, through the API (the author, or triage or higher):
326+
327+```sh
328+curl -X POST https://diffinery.com/api/v3/repos/acme/website/pulls/12/requested_reviewers \
329+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
330+ -d '{"reviewers": ["octocat", "review-bot"]}'
331+```
332+
333+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`.
334+
335+Read the current requests with `GET /repos/{owner}/{repo}/pulls/{number}/requested_reviewers` (`{"users": [...], "teams": []}`), and remove one (the author, or triage or higher):
336+
337+```sh
338+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/pulls/12/requested_reviewers \
339+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
340+ -d '{"reviewers": ["review-bot"]}'
341+```
342+
343+Requests and removals show in the pull request's events as `review_requested` and `review_request_removed`.
344+
345+## Checks
346+
347+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](/docs/collaboration/checks-and-statuses) for both APIs. A check run, for example:
348+
349+```sh
350+curl -X POST https://diffinery.com/api/v3/repos/acme/website/check-runs \
351+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
352+ -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."}}'
353+```
354+
355+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.
356+
357+Where checks show up:
358+
359+- The merge box lists every check with its state: `All checks have passed`, `N failing checks`, or `Some checks haven't completed yet`.
360+- The **Checks** tab shows each check and the pull request's workflow runs.
361+- `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.
362+- `GET /repos/{owner}/{repo}/pulls/12` includes `checks` (name and state).
363+
364+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](/docs/repositories/branch-protection#required-checks).
365+
366+## Pull request workflows
367+
368+Workflows that trigger on `pull_request` behave as on GitHub:
369+
370+- 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.
371+- 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.
372+- While the pull request conflicts with its base, there is no test merge commit and `pull_request` workflows don't run.
373+- `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.
374+
375+Example:
376+
377+```yaml
378+name: CI
379+on:
380+ pull_request:
381+ branches: [main]
382+jobs:
383+ test:
384+ runs-on: ubuntu-latest
385+ steps:
386+ - uses: actions/checkout@v4
387+ - run: npm ci && npm test
388+```
389+
390+See [Events](/docs/actions/events) for every trigger and filter.
391+
392+## Update a branch with its base
393+
394+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.
395+
396+```sh
397+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/update-branch \
398+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
399+ -d '{"expected_head_sha": "3f2a9c1e..."}'
400+```
401+
402+`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.
403+
404+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.
405+
406+| Error | Cause |
407+| --- | --- |
408+| `There are no new commits on the base branch.` (422) | The head already contains the base. |
409+| `merge conflict between base and head` (422) | Resolve the conflict locally (see [Conflicts](#conflicts)). |
410+| `expected head sha didn't match current head ref.` (422) | Someone pushed to the head. Fetch it and try again. |
411+| `head branch moved; try again` (409) | The head moved while the merge was being made. |
412+| `pull request is closed` (422) | Only open pull requests can be updated. |
413+| `base branch main no longer exists` (422) | The base branch was deleted. |
414+| `write access is required to update the branch` (403) | You need write access. |
415+
416+If the base requires linear history, don't use `update-branch`: it adds a merge commit to the head. Rebase locally instead.
417+
418+## Conflicts
419+
420+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:
421+
422+```sh
423+git fetch origin
424+git switch fix-pricing-link
425+git merge origin/main # or: git rebase origin/main, then git push --force-with-lease
426+# fix the conflicts, then
427+git add -A && git commit
428+git push
429+```
430+
431+The push updates the pull request, rebuilds its test merge commit, and runs its `pull_request` workflows again.
432+
433+## Merge methods
434+
435+| Method | Web UI label | Result |
436+| --- | --- | --- |
437+| `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. |
438+| `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. |
439+| `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)`. |
440+
441+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`.
442+
443+When no method is given, the API uses `squash` if the base branch requires linear history, and `merge` otherwise. The web UI, the CLI and MCP default to `merge`.
444+
445+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) can create a merge commit on such a branch.
446+
447+In a [merge queue](/docs/collaboration/merge-queue), `rebase` replays each commit onto the queue instead of fast-forwarding.
448+
449+## Merge a pull request
450+
451+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).
452+
453+### Merge in the web UI
454+
455+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.
456+2. Pick the **Merge method**.
457+3. Leave **Delete fix-pricing-link after merge** ticked to delete the head branch, or untick it.
458+4. Click **Merge pull request**.
459+
460+The timeline then shows `octocat merged commit abc1234 into main`.
461+
462+### Merge with the CLI
463+
464+```sh
465+diffinery pr merge 12 --squash --delete-branch
466+diffinery pr merge 12 --merge --subject "Merge the pricing fix" --body "Fixes #12"
467+diffinery pr merge 12 --rebase --match-head-commit 3f2a9c1e...
468+```
469+
470+| Flag | Meaning |
471+| --- | --- |
472+| `-m, --merge` | Create a merge commit (default) |
473+| `-s, --squash` | Squash into one commit |
474+| `-r, --rebase` | Fast-forward the base to the head |
475+| `-d, --delete-branch` | Delete the head branch after merging |
476+| `-t, --subject TEXT` | Commit title |
477+| `-b, --body TEXT`, `--body-file FILE` | Commit message body |
478+| `--match-head-commit SHA` | Only merge if the head is still at this commit |
479+| `--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). |
480+| `--disable-auto` | Remove the pull request from the merge queue |
481+
482+### Merge with the REST API
483+
484+```sh
485+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/merge \
486+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
487+ -d '{"merge_method": "squash", "delete_branch": true, "sha": "HEAD_SHA"}'
488+```
489+
490+| Field | Meaning |
491+| --- | --- |
492+| `merge_method` | `merge`, `squash` or `rebase` |
493+| `commit_title`, `commit_message` | Override the commit title and body (`merge` and `squash`) |
494+| `sha` | Only merge if the head is still at this commit; otherwise `409 head branch was modified; review and try again` |
495+| `delete_branch` | Delete the head branch after merging (default `false`) |
496+| `bypass_rules` | Admins only: merge even though the pull request has unmet branch rules. See [Admin bypass](#admin-bypass). |
497+| `bypass_merge_queue` | Admins only: merge directly on a branch that uses a merge queue. It also bypasses unmet branch rules, like `bypass_rules`. |
498+
499+The response is `{"merged": true, "sha": "...", "method": "squash", "message": "Pull Request successfully merged"}`.
500+
501+### Check mergeability
502+
503+`GET /repos/{owner}/{repo}/pulls/{number}` reports, like GitHub:
504+
505+| Field | Meaning |
506+| --- | --- |
507+| `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. |
508+| `mergeable_state` | `clean` (no blockers), `blocked` (blockers other than conflicts), `dirty` (conflicts), `draft`, or `unknown` (merged, closed, or the base branch is gone) |
509+| `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. |
510+
511+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`.
512+
513+`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`.
514+
515+### Merge over MCP
516+
517+`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.
518+
519+### After the merge
520+
521+- The pull request is closed with the reason `merged`, and `merged_by` and `merge_commit_sha` are set.
522+- The base branch moves as if pushed to: `push` workflows run and webhooks fire.
523+- `pull_request` workflows whose `types` include `closed` run, with `github.event.pull_request.merged` set to `true`.
524+- Open issues referenced with a closing keyword (`Fixes #12`, `closes #3`, `resolves #7`) close. See [Issues](/docs/collaboration/issues#close-issues-from-a-pull-request).
525+- If you asked for it, the head branch is deleted, unless it is the default branch or has moved since the merge.
526+
527+### Admin bypass
528+
529+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:
530+
531+- you have the admin role on the repository (through a token, that needs the `admin` scope: a `write` token caps you at maintain);
532+- you are a person: agents can never bypass;
533+- the base branch's rule doesn't have **Do not allow administrators to bypass these rules**;
534+- the pull request has no merge conflicts and isn't a draft.
535+
536+What it bypasses: 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. With `bypass_merge_queue` it also skips a merge queue.
537+
538+How to ask for it:
539+
540+- **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.
541+- **CLI:** `diffinery pr merge 12 --squash --admin`. The CLI sends both `bypass_rules` and `bypass_merge_queue`.
542+- **REST API:** add `"bypass_rules": true` to `PUT /repos/{owner}/{repo}/pulls/{number}/merge`:
543+
544+```sh
545+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/pulls/12/merge \
546+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
547+ -d '{"merge_method": "squash", "bypass_rules": true}'
548+```
549+
550+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.
551+
552+A merge that bypassed blockers is recorded in the [audit log](/docs/security/audit-log#git-and-pull-requests) as `pull_request.merge_bypassed`, with the pull request as the target and the unmet `blockers` and the `base` branch in the detail, in addition to the usual `pull.merge` entry.
553+
554+## Merge blockers
555+
556+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:
557+
558+| Blocker | Meaning and fix |
559+| --- | --- |
560+| `already merged` | The pull request is merged. |
561+| `pull request is closed` | Reopen it first. |
562+| `base branch main no longer exists` | The base branch was deleted. Recreate it, or open a new pull request against another base. |
563+| `pull request is a draft` | Drafts can't be merged. See [Drafts](#drafts). |
564+| `merge conflicts in N file(s)` | Resolve the conflicts and push. See [Conflicts](#conflicts). |
565+| `needs N more approving review(s)` | Get more approvals from people with write access. Agent approvals don't count. |
566+| `changes requested by octocat` | That reviewer must approve before merging. |
567+| `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`. |
568+| `required check 'CI / test' is queued` (or `is in_progress`) | Wait for it to finish. A `pending` commit status shows as `is in_progress`. |
569+| `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. |
570+| `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. |
571+| `write access is required to merge` | You need write access. |
572+| `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**. |
573+
574+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.
575+
576+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)`.
577+
578+Merge blockers apply to admins too, unless they ask for an [admin bypass](#admin-bypass).
579+
580+## Pull requests opened by agents
581+
582+When the author is an [agent](/docs/agents/overview):
583+
584+- the pull request needs at least one approval from a person, even on a branch without protection rules, and approvals from agents never count;
585+- the agent's owner can approve it, because the owner is a different account from the agent;
586+- 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;
587+- the author is shown with an `agent` badge and `on behalf of` its owner, and the API returns `"type": "Bot"` for it.
588+
589+## Merge when ready and the merge queue
590+
591+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.
592+
593+The pull request must already have no blockers to join the queue. Pushing to its branch removes it from the queue. See [Merge queue](/docs/collaboration/merge-queue) for the full behaviour.
594+
595+## Lock the conversation
596+
597+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):
598+
599+```sh
600+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/issues/12/lock \
601+ -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
602+ -d '{"lock_reason": "resolved"}'
603+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/issues/12/lock -H "Authorization: Bearer dfy_..."
604+```
605+
606+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](/docs/collaboration/issues#lock-a-conversation).
607+
608+## Reactions
609+
610+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](/docs/collaboration/issues#reactions) for the allowed values and rules.
611+
612+## Events and timeline
613+
614+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`, `converted_to_draft` and `merged`. (GitHub names the draft event `convert_to_draft`.) 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](/docs/collaboration/issues#events-and-timeline).
615+
616+## Close and reopen
617+
618+- **Web UI:** **Close pull request** at the bottom of the conversation (posting any comment you typed), and **Reopen** on a closed one.
619+- **REST API:** `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{"state": "closed"}` or `{"state": "open"}`. The same endpoint changes `title`, `body` and [`draft`](#drafts).
620+- **MCP:** `update_issue` with `state`.
621+
622+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.
623+
624+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.
625+
626+## Pull request refs
627+
628+Like GitHub, every pull request has two read-only refs:
629+
630+| Ref | Points at |
631+| --- | --- |
632+| `refs/pull/<n>/head` | The pull request's head commit |
633+| `refs/pull/<n>/merge` | The test merge commit: parents `[base, head]`, authored by `Diffinery <noreply@diffinery.com>`. Removed while the pull request conflicts. |
634+
635+They are refreshed when the pull request is opened, pushed to, reopened or closed. Fetch them with git:
636+
637+```sh
638+git fetch origin refs/pull/12/head:pr-12
639+git fetch origin +refs/pull/12/merge:refs/remotes/origin/pr/12/merge
640+```
641+
642+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.
643+
644+## Find pull requests
645+
646+The **Pull requests** tab lists pull requests with the same filters as the issue list: open or closed, search, label and author. See [Issues](/docs/collaboration/issues#find-issues).
647+
648+```sh
649+diffinery pr list --state merged --author octocat --limit 20
650+diffinery pr view 12 --comments
651+diffinery pr diff 12 --name-only
652+```
653+
654+`pr list --state` accepts `open` (default), `closed`, `merged` or `all`. The `list_pull_requests` and `get_pull_request` MCP tools do the same (`get_pull_request` with `include_diff: true` adds the diff).
655+
656+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.
657+
658+| Parameter | Meaning |
659+| --- | --- |
660+| `state` | `open` (default), `closed` or `all` |
661+| `head` | Head branch, as `branch` or `owner:branch` (the owner part is ignored) |
662+| `base` | Base branch |
663+| `sort` | `created` (default), `updated`, or `popularity` (by number of comments) |
664+| `direction` | `desc` (default) or `asc` |
665+| `labels` | Comma-separated label names; a pull request must have all of them |
666+| `assignee` | A login, `none` (no assignees) or `*` (any assignee) |
667+| `creator` | Author login |
668+| `since` | Only pull requests updated at or after this ISO 8601 time |
669+| `q` | Text to find in titles and descriptions |
670+| `per_page`, `page` | Paging: up to 100 per page, default 30 |
671+
672+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](/docs/collaboration/issues#search-issues-and-pull-requests).
673+
674+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>`.
675+
676+`diffinery pr comment 12 --body "..."` adds a conversation comment.
677+
678+## Differences from GitHub
679+
680+- Pull requests are between branches of the same repository; there are no forks.
681+- The intent can't be edited after opening.
682+- Draft state changes through `PATCH /pulls/{number}` with `draft` (GitHub offers this only in GraphQL), and the timeline event for converting to a draft is `converted_to_draft` (GitHub: `convert_to_draft`).
683+- An admin's merge past unmet branch rules must be asked for with `bypass_rules` (or `bypass_merge_queue`); without it an admin is refused like anyone else. A merge commit is refused on a branch that requires linear history.
684+- Reviews are submitted immediately; there are no pending reviews, suggested changes, or thread resolution yet.
685+- `rebase` outside the merge queue is a fast-forward only.
686+- `update-branch` exists in the REST API only (merge method only, no rebase); there is no **Update branch** button and no web conflict editor.
687+- Dismissing a review needs maintain access, and the pull request's author and agents can't do it.
688+- Pull requests opened by agents always need a human approval, and agent approvals never count.
689+- `GET /pulls/{number}/merge` answers GitHub clients with `204` or `404`, and other clients with a mergeability report.
690+- `mergeable_state` never reports `behind`, `unstable` or `has_hooks`.
691+- Requested reviewers are accounts only (no teams).
692+- Locking, reactions, review dismissal and repository-wide comment lists have no web UI yet.
693+- See also [Merge queue](/docs/collaboration/merge-queue#differences-from-github).
694+
695+## Troubleshooting
696+
697+| Message | Cause and fix |
698+| --- | --- |
699+| `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). |
700+| `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`). |
701+| `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. |
702+| `authentication required` (401) | You sent no credentials. Every change needs a signed-in caller, even on a public repository. |
703+| `this token needs the write scope` (403) | Your token has the `read` scope. Create one with `write` (or `admin`). |
704+| `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. |
705+| `base branch moved during merge; try again` (409) | The base changed while merging. Retry. |
706+| `merge conflicts` (405) | The head conflicts with the base. Resolve and push. |
707+| `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`. |
708+| `you cannot approve or request changes on your own pull request` | Ask someone else to review; you can leave a Comment review. |
709+| `write access is required to approve or request changes` | Approving needs write access. |
710+| `a comment review needs a body or line comments` | Add a review body or at least one line comment. |
711+| `pull request is closed` (when reviewing) | Only open pull requests can be reviewed. |
712+| `a merged pull request cannot be reopened` | Open a new pull request instead. |
713+| `conversation is locked` (403) | The pull request is locked. Only triage or higher can comment, review or react. |
714+| `path and line are required` | A new line comment needs `path` and `line` (or `in_reply_to` for a reply). |
715+| `team reviewers are not supported; request people by login` | Request reviewers by login in `reviewers`. |
716+| `main requires a merge queue: add the pull request to the queue` | Use **Merge when ready**, `diffinery pr merge`, or the merge queue API. |
717+| 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. |
addedapps/diffinery/docs/collaboration/releases.md+519−0View file
@@ -1,0 +1,519 @@
1+---
2+title: Releases
3+description: 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.
4+---
5+
6+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.
7+
8+## How releases work
9+
10+- 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.
11+- 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.
12+- A published release can be marked as a **pre-release** (not production ready).
13+- One published, non-pre-release release is the repository's **latest release**. It appears on the repository home page and at `/releases/latest`.
14+- 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`.
15+- 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.
16+- Creating, publishing, editing and deleting a release sends `release` webhook events and starts `on: release` workflows.
17+
18+Each repository numbers its releases and assets with its own integer ids, starting at 1.
19+
20+## Who can do what
21+
22+| Action | Role needed |
23+|---|---|
24+| See published releases, their notes and assets; download assets and source archives | read (anyone, for a public repository) |
25+| See draft releases | write |
26+| Create, edit, publish and delete releases; save drafts | write |
27+| Upload, rename and delete assets | write |
28+| Generate release notes (REST API and MCP) | write |
29+| React to a release | read (signed in) |
30+
31+- 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.
32+- A workflow's `GITHUB_TOKEN` (the job token) is read-only on the REST API, whatever the workflow's `permissions` say, so it can read releases but not create them or upload assets. Store a personal or agent token with the `write` scope as an Actions secret and use that instead.
33+- On an archived repository, every release change (including assets) fails with `repository is archived (read-only)`.
34+- Deleting a release never deletes its tag. Moving or deleting an existing tag needs the maintain role.
35+
36+See [Permissions](/docs/accounts/permissions) for the full role table and [Tokens](/docs/accounts/tokens) for scopes.
37+
38+## Releases in the web UI
39+
40+### Find releases
41+
42+- 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.
43+- `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.
44+- `https://diffinery.com/acme/website/releases/tag/v1.2.0` shows one release in full.
45+- `https://diffinery.com/acme/website/releases/latest` redirects to the latest release, or to the list when there is none.
46+- The **Tags** button on the list opens the repository's tags.
47+
48+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.
49+
50+### Create a release
51+
52+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).
53+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.
54+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.
55+4. Enter a **Release title**. When you leave it empty, the tag name is shown instead.
56+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.
57+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.
58+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.
59+8. Select **Publish release**, or **Save draft** to keep it private to writers.
60+
61+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.
62+
63+### Edit, publish or delete a release
64+
65+- 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.
66+- 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`).
67+- To remove an asset, open the release page and select the **×** next to the file.
68+- 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.
69+
70+## Releases with the REST API
71+
72+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](/docs/api).
73+
74+| Method and path | What it does | Role |
75+|---|---|---|
76+| `GET /repos/{owner}/{repo}/releases` | List releases, newest first (`per_page` up to 100, `page`; `Link` header). Drafts only for writers. | read |
77+| `POST /repos/{owner}/{repo}/releases` | Create a release | write |
78+| `GET /repos/{owner}/{repo}/releases/latest` | The latest release | read |
79+| `GET /repos/{owner}/{repo}/releases/tags/{tag}` | The published release of a tag | read |
80+| `GET /repos/{owner}/{repo}/releases/{id}` | One release by id | read (write for a draft) |
81+| `PATCH /repos/{owner}/{repo}/releases/{id}` | Edit, publish or unpublish a release | write |
82+| `DELETE /repos/{owner}/{repo}/releases/{id}` | Delete a release and its assets (the tag stays); returns `204` | write |
83+| `POST /repos/{owner}/{repo}/releases/generate-notes` | Generate release notes without creating anything | write |
84+| `GET /repos/{owner}/{repo}/releases/{id}/assets` | List a release's assets | read |
85+| `POST {upload_url}` | Upload an asset (see [Assets](#assets)) | write |
86+| `GET /repos/{owner}/{repo}/releases/assets/{asset_id}` | One asset as JSON, or its bytes with `Accept: application/octet-stream` | read |
87+| `PATCH /repos/{owner}/{repo}/releases/assets/{asset_id}` | Rename an asset or change its label | write |
88+| `DELETE /repos/{owner}/{repo}/releases/assets/{asset_id}` | Delete an asset; returns `204` | write |
89+| `GET`, `POST /repos/{owner}/{repo}/releases/{id}/reactions`, `DELETE .../reactions/{reaction_id}` | Reactions on a release (adding one needs a signed-in account) | read |
90+
91+### Create a release with the API
92+
93+```sh
94+curl -sS -X POST \
95+ -H "Authorization: Bearer dfy_..." \
96+ -H "Content-Type: application/json" \
97+ https://diffinery.com/api/v3/repos/acme/website/releases \
98+ -d '{
99+ "tag_name": "v1.2.0",
100+ "target_commitish": "main",
101+ "name": "Website 1.2.0",
102+ "generate_release_notes": true
103+ }'
104+```
105+
106+| Field | Type | Default | Description |
107+|---|---|---|---|
108+| `tag_name` | string | required | The tag. Created at `target_commitish` when the release is published and the tag does not exist. |
109+| `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. |
110+| `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. |
111+| `body` | string | none | Release notes in Markdown, at most 125,000 characters. |
112+| `draft` | boolean | `false` | Save as a draft, visible to writers only. No tag is created for a draft. |
113+| `prerelease` | boolean | `false` | Mark as a pre-release. |
114+| `make_latest` | `"true"`, `"false"`, `"legacy"` or boolean | `"true"` | Whether this release becomes the latest release. See [The latest release](#the-latest-release). |
115+| `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. |
116+
117+The response is `201` with the release:
118+
119+```json
120+{
121+ "url": "https://diffinery.com/api/v3/repos/acme/website/releases/7",
122+ "assets_url": "https://diffinery.com/api/v3/repos/acme/website/releases/7/assets",
123+ "upload_url": "https://diffinery.com/api/uploads/repos/acme/website/releases/7/assets{?name,label}",
124+ "html_url": "https://diffinery.com/acme/website/releases/tag/v1.2.0",
125+ "id": 7,
126+ "author": {
127+ "login": "octocat",
128+ "id": 7220136606554077,
129+ "node_id": "VXNlcjphMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MA",
130+ "avatar_url": "https://diffinery.com/api/avatars/octocat",
131+ "gravatar_id": "",
132+ "url": "https://diffinery.com/api/v3/users/octocat",
133+ "html_url": "https://diffinery.com/octocat",
134+ "repos_url": "https://diffinery.com/api/v3/users/octocat/repos",
135+ "organizations_url": "https://diffinery.com/api/v3/users/octocat/orgs",
136+ "type": "User",
137+ "user_view_type": "public",
138+ "site_admin": false
139+ },
140+ "node_id": "UmVsZWFzZTo3",
141+ "tag_name": "v1.2.0",
142+ "target_commitish": "main",
143+ "name": "Website 1.2.0",
144+ "draft": false,
145+ "immutable": false,
146+ "prerelease": false,
147+ "created_at": "2026-10-13T10:00:00Z",
148+ "updated_at": "2026-10-13T10:00:00Z",
149+ "published_at": "2026-10-13T10:00:00Z",
150+ "assets": [],
151+ "tarball_url": "https://diffinery.com/api/v3/repos/acme/website/tarball/v1.2.0",
152+ "zipball_url": "https://diffinery.com/api/v3/repos/acme/website/zipball/v1.2.0",
153+ "body": "## What's Changed\n* Fix broken footer links by @octocat in https://diffinery.com/acme/website/pull/41\n\n**Full Changelog**: https://diffinery.com/acme/website/compare/v1.1.0...v1.2.0",
154+ "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 },
155+ "latest": true
156+}
157+```
158+
159+`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.
160+
161+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.
162+
163+The same request with the CLI (install it with `curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery`, which needs Node.js 22 or later):
164+
165+```sh
166+cat > release.json <<'EOF'
167+{ "tag_name": "v1.2.0", "name": "Website 1.2.0", "generate_release_notes": true }
168+EOF
169+diffinery api POST /repos/acme/website/releases --input release.json
170+```
171+
172+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.
173+
174+### Edit, publish and unpublish
175+
176+`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`.
177+
178+```sh
179+# Publish a draft
180+curl -sS -X PATCH -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
181+ https://diffinery.com/api/v3/repos/acme/website/releases/7 -d '{ "draft": false }'
182+
183+# Turn a pre-release into a full release and make it the latest
184+curl -sS -X PATCH -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
185+ https://diffinery.com/api/v3/repos/acme/website/releases/7 -d '{ "prerelease": false, "make_latest": "true" }'
186+```
187+
188+- 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"`.
189+- Setting `draft` to `true` on a published release unpublishes it. Its tag stays.
190+- 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.
191+- Only one published release can use a tag. Drafts can share a tag with each other and with a published release.
192+
193+### Get releases
194+
195+```sh
196+# The latest release
197+curl -sS -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/releases/latest
198+
199+# A release by tag
200+curl -sS -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/releases/tags/v1.2.0
201+```
202+
203+For a public repository you can leave out the `Authorization` header. `GET .../releases/tags/{tag}` returns only published releases; a draft returns `404`.
204+
205+## The latest release
206+
207+Diffinery picks the latest release like GitHub:
208+
209+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.
210+2. Otherwise, the most recently created published release that is not a pre-release is the latest.
211+
212+Drafts and pre-releases are never the latest release.
213+
214+| `make_latest` | Effect |
215+|---|---|
216+| `"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. |
217+| `"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. |
218+| `"legacy"` | Clears the explicit choice, so the most recently created published full release is the latest. |
219+
220+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"`).
221+
222+When the repository has no published full release, `GET .../releases/latest` returns `404` with `no published releases`.
223+
224+## Generated release notes
225+
226+Diffinery generates release notes the way GitHub's **Generate release notes** does:
227+
228+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`.
229+2. It collects the pull requests merged into the commits between the previous tag and the new one (by merge commit or head commit).
230+3. It groups them by the categories in `.github/release.yml`, if that file exists.
231+4. It lists people whose first merged pull request in the repository is in the range under **New Contributors**.
232+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.
233+
234+Each pull request becomes one line: `* <title> by @<author> in <link>`.
235+
236+### Generate notes without creating a release
237+
238+```sh
239+curl -sS -X POST -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
240+ https://diffinery.com/api/v3/repos/acme/website/releases/generate-notes \
241+ -d '{ "tag_name": "v1.2.0", "target_commitish": "main", "previous_tag_name": "v1.1.0" }'
242+```
243+
244+| Field | Required | Description |
245+|---|---|---|
246+| `tag_name` | yes | The tag the notes are for. It does not need to exist yet. |
247+| `target_commitish` | no | Used when the tag doesn't exist: the commit the notes run up to. Defaults to the default branch. |
248+| `previous_tag_name` | no | The tag to start from. It must exist, or the request fails with `previous tag '<name>' does not exist`. |
249+| `configuration_file_path` | no | Path of the configuration file in the repository, instead of `.github/release.yml`. |
250+
251+The response has the suggested title (the tag name) and the Markdown body:
252+
253+```json
254+{
255+ "name": "v1.2.0",
256+ "body": "## What's Changed\n### New Features\n* Add dark mode by @octocat in https://diffinery.com/acme/website/pull/40\n\n### Bug Fixes\n* Fix broken footer links by @octocat in https://diffinery.com/acme/website/pull/41\n\n**Full Changelog**: https://diffinery.com/acme/website/compare/v1.1.0...v1.2.0"
257+}
258+```
259+
260+### Configure categories with .github/release.yml
261+
262+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:
263+
264+```yaml
265+changelog:
266+ exclude:
267+ labels:
268+ - ignore-for-release
269+ authors:
270+ - review-bot
271+ categories:
272+ - title: Breaking Changes
273+ labels:
274+ - breaking-change
275+ - title: New Features
276+ labels:
277+ - enhancement
278+ - feature
279+ - title: Bug Fixes
280+ labels:
281+ - bug
282+ exclude:
283+ labels:
284+ - wontfix
285+```
286+
287+These keys are supported, all under `changelog`:
288+
289+| Key | Type | Effect |
290+|---|---|---|
291+| `exclude.labels` | list of strings | Pull requests with any of these labels are left out of the notes. |
292+| `exclude.authors` | list of logins | Pull requests by these authors are left out. |
293+| `categories` | list | Sections of the notes, in order. |
294+| `categories[].title` | string | The section heading (`### <title>`). A category without a title is ignored. |
295+| `categories[].labels` | list of strings | A pull request with any of these labels goes into this category. |
296+| `categories[].exclude.labels` | list of strings | Pull requests with any of these labels are not put into this category. |
297+| `categories[].exclude.authors` | list of logins | Pull requests by these authors are not put into this category. |
298+
299+- `"*"` in a labels list matches every pull request, including ones without labels; `"*"` in an authors list matches every author.
300+- Labels match exactly (case matters). Author logins match regardless of case.
301+- A single string works where a list is expected.
302+- 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**.
303+- A file that is not valid YAML, or has no `changelog` key, is ignored and the notes are generated without categories.
304+- **New Contributors** is not filtered by `exclude`.
305+
306+## Assets
307+
308+### Upload an asset
309+
310+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:
311+
312+```sh
313+curl -sS -X POST \
314+ -H "Authorization: Bearer dfy_..." \
315+ -H "Content-Type: application/gzip" \
316+ --data-binary @dist/website-linux-amd64.tar.gz \
317+ "https://diffinery.com/api/uploads/repos/acme/website/releases/7/assets?name=website-linux-amd64.tar.gz&label=Linux%20build"
318+```
319+
320+| Part | Description |
321+|---|---|
322+| `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. |
323+| `label` query parameter | Optional. A label shown next to the file name. |
324+| `Content-Type` header | Stored as the asset's content type and sent back on download. Defaults to `application/octet-stream`. |
325+| Body | The file. Up to 100 MB (104,857,600 bytes). |
326+
327+The response is `201` with the asset:
328+
329+```json
330+{
331+ "url": "https://diffinery.com/api/v3/repos/acme/website/releases/assets/12",
332+ "id": 12,
333+ "node_id": "UmVsZWFzZUFzc2V0OjEy",
334+ "name": "website-linux-amd64.tar.gz",
335+ "label": "Linux build",
336+ "uploader": {
337+ "login": "octocat",
338+ "id": 7220136606554077,
339+ "node_id": "VXNlcjphMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MA",
340+ "avatar_url": "https://diffinery.com/api/avatars/octocat",
341+ "gravatar_id": "",
342+ "url": "https://diffinery.com/api/v3/users/octocat",
343+ "html_url": "https://diffinery.com/octocat",
344+ "repos_url": "https://diffinery.com/api/v3/users/octocat/repos",
345+ "organizations_url": "https://diffinery.com/api/v3/users/octocat/orgs",
346+ "type": "User",
347+ "user_view_type": "public",
348+ "site_admin": false
349+ },
350+ "content_type": "application/gzip",
351+ "state": "uploaded",
352+ "size": 5242880,
353+ "digest": null,
354+ "download_count": 0,
355+ "created_at": "2026-10-13T10:01:00Z",
356+ "updated_at": "2026-10-13T10:01:00Z",
357+ "browser_download_url": "https://diffinery.com/acme/website/releases/download/v1.2.0/website-linux-amd64.tar.gz"
358+}
359+```
360+
361+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.
362+
363+### Download an asset
364+
365+- `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.
366+- `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.
367+
368+```sh
369+curl -sSL -H "Authorization: Bearer dfy_..." -H "Accept: application/octet-stream" \
370+ -o website-linux-amd64.tar.gz \
371+ https://diffinery.com/api/v3/repos/acme/website/releases/assets/12
372+```
373+
374+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.
375+
376+### Rename or delete an asset
377+
378+```sh
379+# Rename and relabel
380+curl -sS -X PATCH -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
381+ https://diffinery.com/api/v3/repos/acme/website/releases/assets/12 \
382+ -d '{ "name": "website-1.2.0-linux-amd64.tar.gz", "label": "Linux (amd64)" }'
383+
384+# Delete
385+curl -sS -X DELETE -H "Authorization: Bearer dfy_..." \
386+ https://diffinery.com/api/v3/repos/acme/website/releases/assets/12
387+```
388+
389+### Asset size limits
390+
391+| Upload path | Limit |
392+|---|---|
393+| REST API `upload_url` | 100 MB per file |
394+| Web form (**Attach files**) | 100 MB for all files of one submission together |
395+| MCP `upload_release_asset` | 25 MB per file |
396+
397+## Releases over MCP
398+
399+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](/docs/reference/mcp-tools).
400+
401+| Tool | What it does |
402+|---|---|
403+| `list_releases` | List releases, newest first (`limit` default 30, at most 100). Drafts only for writers. |
404+| `get_release` | Get a release by `tag` (drafts included for writers), by `id`, or the latest release when neither is given. |
405+| `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. |
406+| `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. |
407+| `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`). |
408+| `generate_release_notes` | Return generated notes for `tag_name` (with optional `target_commitish` and `previous_tag_name`) without creating anything. Needs write access. |
409+
410+There is no MCP tool to delete a release; use the REST API or the web UI.
411+
412+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`).
413+
414+## Workflows triggered by releases
415+
416+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.
417+
418+```yaml
419+name: publish
420+on:
421+ release:
422+ types: [published]
423+jobs:
424+ build:
425+ runs-on: ubuntu-latest
426+ steps:
427+ - uses: actions/checkout@v4
428+ - run: ./build.sh "${{ github.event.release.tag_name }}"
429+ - name: Attach the build to the release
430+ env:
431+ TOKEN: ${{ secrets.RELEASE_TOKEN }}
432+ run: |
433+ curl -fsS -X POST \
434+ -H "Authorization: Bearer $TOKEN" \
435+ -H "Content-Type: application/gzip" \
436+ --data-binary @dist/website.tar.gz \
437+ "https://diffinery.com/api/uploads/repos/${{ github.repository }}/releases/${{ github.event.release.id }}/assets?name=website.tar.gz"
438+```
439+
440+`RELEASE_TOKEN` is a personal or agent token with the `write` scope, stored as an Actions secret: the job's own `GITHUB_TOKEN` can't upload assets.
441+
442+### Activity types
443+
444+| Type | When |
445+|---|---|
446+| `created` | A release is created (published or not). |
447+| `published` | A release is published: created as published, or a draft is published. |
448+| `released` | A full release is published, or a published pre-release becomes a full release. |
449+| `prereleased` | A pre-release is published, or a published release becomes a pre-release. |
450+| `edited` | A published release is edited (without changing its draft state). |
451+| `unpublished` | A published release is turned back into a draft. |
452+| `deleted` | A published release is deleted. |
453+
454+Like GitHub:
455+
456+- 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".
457+- Changes to drafts (creating, editing or deleting a draft) never start workflows. They do send webhooks.
458+- The workflow only runs when the release's tag exists.
459+- Releases created or changed with a job's `GITHUB_TOKEN` don't start workflows (the job token can't change releases anyway). Releases made with a personal or agent token do.
460+
461+### The event payload
462+
463+`github.event` holds:
464+
465+| Field | Description |
466+|---|---|
467+| `action` | The activity type, for example `published` |
468+| `release` | The release, in the same shape as the REST API (`id`, `tag_name`, `name`, `body`, `draft`, `prerelease`, `assets`, `upload_url`, `html_url`, `author`, ...) |
469+| `repository` | `id`, `node_id`, `name`, `full_name`, `owner.login`, `default_branch`, `html_url`, `clone_url` |
470+| `sender` | `login` and `type` (`User` or `Bot`) of who caused the event |
471+
472+## Release webhooks
473+
474+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](/docs/reference/webhook-events#release) for the full payload and [Webhooks](/docs/repositories/webhooks) to set one up.
475+
476+## Agents and releases
477+
478+[Agents](/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:
479+
480+- 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](/docs/actions/environments) need a person to create the tag.
481+- Workflow runs started by an agent's release get environment secrets only when the environment is limited to protected branches.
482+- Agents can't change branch protection, secrets or environments, so they can't loosen those rules to ship a release.
483+
484+## Differences from GitHub
485+
486+| GitHub | Diffinery |
487+|---|---|
488+| Uploads go to `uploads.github.com` | `upload_url` is on the same host, under `/api/uploads/`. |
489+| Asset downloads through the API redirect to a storage URL | `Accept: application/octet-stream` returns the bytes directly with `200`. |
490+| Assets up to 2 GiB | Assets up to 100 MB (25 MB over MCP). |
491+| `make_latest: legacy` considers creation date and semantic version | `legacy` uses creation date only. |
492+| Release ids are global | Release and asset ids are numbered per repository. |
493+| `discussion_category_name`, immutable releases, asset digests | Not supported: `discussion_category_name` is ignored, `immutable` is always `false` and `digest` is always `null`. |
494+| No `latest` field | Releases have a `latest` boolean. |
495+
496+## Troubleshooting
497+
498+| Message | Cause and fix |
499+|---|---|
500+| `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`). |
501+| `a release for this tag already exists` | Only one published release per tag. Edit the existing release, or use another tag. |
502+| `'<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`). |
503+| `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`. |
504+| `tag was created concurrently; retry` | Someone created the tag at the same moment. Send the request again. |
505+| `write access required` | Your role is below write, or the request used a workflow's `GITHUB_TOKEN`, which is always read-only here. Use a stored personal or agent token. |
506+| `this token needs the write scope` | Your token has only the `read` scope. |
507+| `release not found` | Wrong id, or the release is a draft and you can't write to the repository. |
508+| `no published releases` | `/releases/latest` with no published full release. Publish one, or mark one as latest. |
509+| `body is too long (maximum is 125000 characters)` | Shorten the notes, or attach the full changelog as an asset. |
510+| `the name query parameter is required` | Add `?name=<file name>` to the upload URL (drop the `{?name,label}` template part). |
511+| `release assets are limited to 100 MB` | Split the file or host it elsewhere and link to it from the notes. |
512+| `an asset named <name> already exists` | Delete or rename the existing asset first. |
513+| `asset name must be 1 to 255 characters` | The name is empty after sanitizing, or too long. |
514+| `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`. |
515+| `MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files` | Upload large files over REST. |
516+| `previous tag '<name>' does not exist` | Check `previous_tag_name`, or leave it out to let Diffinery find the previous release. |
517+| An `on: release` workflow runs three times | It has no `types` filter. Add `types: [published]`. |
518+| An `on: release` workflow doesn't run for a draft | Draft changes never start workflows. Publish the release. |
519+| 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. |
addedapps/diffinery/docs/concepts.md+210−0View file
@@ -1,0 +1,210 @@
1+---
2+title: Concepts
3+description: The Diffinery mental model in one page: accounts, agents, repositories, roles, tokens, checks, releases, environments, Actions and audit.
4+---
5+
6+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.
7+
8+## Accounts
9+
10+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/octocat`, `https://diffinery.com/acme` and `https://diffinery.com/review-bot` are all account pages, and repositories live under users and organizations.
11+
12+### Users
13+
14+A user is a person. Diffinery is invite-only: you join by opening an invite link, 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).
15+
16+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**).
17+
18+See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in), [Invitations](/docs/accounts/invitations) and [Site administration](/docs/admin/site-administration).
19+
20+### Organizations
21+
22+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:
23+
24+| Organization role | Role on the organization's repositories |
25+| --- | --- |
26+| member | write |
27+| admin | admin |
28+| owner | admin, plus managing owners and renaming the organization |
29+
30+Unlike GitHub, there are no teams and no custom roles. To give someone access to a single repository, add them as a collaborator on it instead. See [Organizations](/docs/accounts/organizations).
31+
32+### Agents
33+
34+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.
35+
36+Agents are held to guardrails that keep a person in the loop:
37+
38+- An agent never has more access than its owner. An organization's agent can reach only that organization's repositories.
39+- Its maximum role is `write` unless its owner chooses otherwise.
40+- It can't push to, or merge into, the default branch or a protected branch, unless a branch protection rule explicitly allows it.
41+- A pull request it opens needs at least one approval from a person, and its own approvals never count.
42+- 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 repositories or bypass push protection.
43+- Commit statuses and check runs it reports never satisfy a required check, and tags it pushes never match an environment's tag rules.
44+- Commits it makes carry a `diffinery-agent` header naming the agent and the person it acts for.
45+
46+Agents work through the [MCP server](/docs/agents/mcp), the REST API, the CLI and Git. See [Agents](/docs/agents/overview).
47+
48+## Repositories
49+
50+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/<owner>/<name>` (for example `https://diffinery.com/acme/website`) and is cloned from the same URL with `.git` appended.
51+
52+New repositories are private and use `main` as the default branch unless you choose otherwise.
53+
54+To move a repository from GitHub, `diffinery migrate github` copies its branches, tags, default branch and variables, and optionally its labels, issues, comments and open pull requests. See [Migrate from GitHub](/docs/migrate-from-github).
55+
56+There is no forking. Pull requests are always between two branches of the same repository, so contributors need write access to push a branch.
57+
58+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.
59+
60+See [Repositories](/docs/repositories/overview), [Browsing code](/docs/repositories/browsing-code), [Git over HTTPS](/docs/repositories/git) and [Git LFS](/docs/repositories/git-lfs).
61+
62+## Visibility
63+
64+A repository is either **private** or **public**.
65+
66+| Visibility | Who can see it |
67+| --- | --- |
68+| Private | The owner, organization members (for organization repositories), collaborators and site administrators |
69+| Public | Everyone, including people who aren't signed in. Signed-in users get `read`. |
70+
71+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`. See [Visibility](/docs/repositories/overview#visibility).
72+
73+## Roles
74+
75+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:
76+
77+| Role | In short |
78+| --- | --- |
79+| `read` | View and clone; when signed in, also open issues and comment |
80+| `triage` | Manage issues, pull requests and labels without pushing code |
81+| `write` | Push, open and merge pull requests, run workflows |
82+| `maintain` | Also move or delete tags, edit other people's comments and dismiss reviews |
83+| `admin` | Settings, access, branch protection, secrets and webhooks |
84+
85+Your role on a repository comes from owning it, your organization role, a collaborator grant or the repository being public, whichever is highest. Tokens and agent limits can then only lower it. See [Permissions](/docs/accounts/permissions).
86+
87+## Tokens
88+
89+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.
90+
91+Each token has a scope that caps your role, whatever your role actually is:
92+
93+| Scope | Highest effective role |
94+| --- | --- |
95+| `read` | read |
96+| `write` | maintain |
97+| `admin` | admin |
98+
99+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`).
100+
101+Some actions belong to your account rather than to a repository, and need a minimum scope whatever your role:
102+
103+| Action | Token scope needed |
104+| --- | --- |
105+| Create a repository | `write` |
106+| Create an organization, send invitations, manage organization secrets | `admin` |
107+| Create or revoke tokens, create or disable agents, create agent tokens | `admin` |
108+
109+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 or manage agents. 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.
110+
111+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](/docs/accounts/tokens).
112+
113+## Job tokens
114+
115+Each workflow job gets a short-lived job token as `GITHUB_TOKEN` (also `DIFFINERY_TOKEN`). It starts with `djt_`, acts as `diffinery-actions[bot]`, works only for the repository the job runs in and stops working when the job completes.
116+
117+The job token has `read` on the repository: the checkout step uses it to clone. With the job's `permissions: packages`, it can pull its owner's container images and push to images linked to its own repository. This differs from GitHub, where `GITHUB_TOKEN` can usually push. To push commits or call write endpoints from a workflow, store a personal or agent token as a secret and use that.
118+
119+Sandboxes get a similar token starting with `dst_` (see [Sandboxes](#sandboxes)). See [Job tokens and sandbox tokens](/docs/accounts/permissions#job-tokens-and-sandbox-tokens) and [Secrets and variables](/docs/actions/secrets-and-variables).
120+
121+## Branch protection
122+
123+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.
124+
125+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](/docs/collaboration/checks-and-statuses).
126+
127+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.
128+
129+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`. Bypassing merge requirements is for people only, never agents, and never works for a draft or a pull request with conflicts.
130+
131+See [Branch protection](/docs/repositories/branch-protection) and [Merge queue](/docs/collaboration/merge-queue).
132+
133+## Pull requests with intent
134+
135+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.
136+
137+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.
138+
139+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.
140+
141+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. See [Pull requests](/docs/collaboration/pull-requests) and [Issues](/docs/collaboration/issues).
142+
143+## Actions
144+
145+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](/docs/actions/compatibility).
146+
147+### Runs, jobs and steps
148+
149+| Term | Meaning |
150+| --- | --- |
151+| Workflow | A YAML file that says when to run (`on:`) and what to run (`jobs:`). |
152+| 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. |
153+| Run | One execution of one workflow for one event, with a number, a status and a conclusion. |
154+| 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. |
155+| Step | One command (`run:`) or one action (`uses:`), run in order in the job's workspace. |
156+| Check | The result of one job, reported on the commit as `<workflow name> / <job name>`, for example `CI / test`. Branch protection can require it. |
157+| 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. |
158+
159+Runs are orchestrated durably, so they survive platform restarts, and a job can run for at most 6 hours. See [Actions overview](/docs/actions/overview), [Workflow syntax](/docs/actions/workflow-syntax) and [Events that trigger workflows](/docs/actions/events).
160+
161+### Runners
162+
163+A runner is the machine that executes one job. You pick one per job with `runs-on`:
164+
165+- **Hosted runners** run each job in a fresh Ubuntu 24.04 container on Cloudflare. `runs-on: ubuntu-latest` and similar labels use them.
166+- **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.
167+
168+See [Runners](/docs/actions/runners).
169+
170+## Checks and commit statuses
171+
172+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](/docs/collaboration/checks-and-statuses).
173+
174+## Releases
175+
176+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](/docs/collaboration/releases).
177+
178+## Environments and deployments
179+
180+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](/docs/actions/environments).
181+
182+## Sandboxes
183+
184+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.
185+
186+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](/docs/agents/sandboxes).
187+
188+## Push protection
189+
190+Every `git push` is scanned for leaked credentials such as API keys and private keys. A push 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=<reason>`, which is recorded and emailed. See [Push protection](/docs/security/push-protection) and [Security overview](/docs/security/overview).
191+
192+## Audit log
193+
194+The audit log is an append-only record of security-relevant actions: sign-ins, passkey and token changes, repository settings, 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.
195+
196+Only site administrators can view it, under **Settings → Audit log**, which shows the 200 most recent entries. See [Audit log](/docs/security/audit-log).
197+
198+## Notifications
199+
200+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.
201+
202+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](/docs/accounts/notifications).
203+
204+## Where to go next
205+
206+- [Quickstart](/docs/quickstart): try these concepts end to end in about 15 minutes.
207+- [Releases](/docs/collaboration/releases), [Environments](/docs/actions/environments) and [Checks and statuses](/docs/collaboration/checks-and-statuses).
208+- [Migrate from GitHub](/docs/migrate-from-github): import repositories, variables, issues and open pull requests with `diffinery migrate github`, and set secrets again.
209+- [REST API](/docs/reference/rest-api), the [CLI reference](/docs/reference/cli) and the [MCP tool reference](/docs/reference/mcp-tools).
210+- [Limits](/docs/reference/limits): sizes, timeouts and quotas in one place.
addedapps/diffinery/docs/index.md+50−0View file
@@ -1,0 +1,50 @@
1+---
2+title: Overview
3+description: 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.
4+---
5+
6+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 `diffinery migrate github` imports a GitHub repository in one command (see [Migrate from GitHub](/docs/migrate-from-github)).
7+
8+## Start here
9+
10+| If you want to | Read |
11+| --- | --- |
12+| Get set up and push your first repository | [Quickstart](/docs/quickstart) |
13+| Understand accounts, roles, agents and runs | [Concepts](/docs/concepts) |
14+| Move a project from GitHub (`diffinery migrate github`) | [Migrate from GitHub](/docs/migrate-from-github) |
15+| Run CI | [Actions overview](/docs/actions/overview) and [Workflow syntax](/docs/actions/workflow-syntax) |
16+| Connect an AI agent | [Agents](/docs/agents/overview) and [MCP](/docs/agents/mcp) |
17+| Script against Diffinery | [REST API guide](/docs/reference/rest-api), [REST API reference](/docs/api) and [CLI reference](/docs/reference/cli) |
18+| Run your own instance | [Deploy Diffinery](/docs/self-hosting/deploy) |
19+
20+## What you get
21+
22+- **Git hosting.** Clone, fetch and push over HTTPS with a token. Shallow clones, Git LFS and `refs/pull/<n>/head` and `/merge` work as on GitHub. See [Git over HTTPS](/docs/repositories/git).
23+- **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](/docs/collaboration/pull-requests) and [Merge queue](/docs/collaboration/merge-queue).
24+- **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](/docs/actions/compatibility).
25+- **Container registry.** Push and pull images at `diffinery.com/<owner>/<name>` instead of ghcr.io. See [Container registry](/docs/packages/container-registry).
26+- **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. See [Agents](/docs/agents/overview).
27+- **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](/docs/security/overview).
28+
29+## How Diffinery runs
30+
31+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](/docs/self-hosting/architecture).
32+
33+## Ways to work with Diffinery
34+
35+| Interface | Best for | Where |
36+| --- | --- | --- |
37+| Web UI | Browsing code, reviewing, settings | `https://diffinery.com` |
38+| git | Code | `https://diffinery.com/<owner>/<repo>.git` |
39+| CLI | Terminals and scripts | [`diffinery`](/docs/reference/cli) |
40+| REST API | Integrations, GitHub-compatible tools | `https://diffinery.com/api/v3`, see [the reference](/docs/api) |
41+| MCP | AI agents | `https://diffinery.com/mcp`, see [the tools](/docs/reference/mcp-tools) |
42+| Webhooks | Reacting to events elsewhere | [Webhooks](/docs/repositories/webhooks) |
43+
44+## For AI agents reading these docs
45+
46+Every page is available as plain Markdown: add `.md` to its URL, or send `Accept: text/markdown`. [/llms.txt](/llms.txt) indexes every page, [/llms-full.txt](/llms-full.txt) holds all of them in one file, and [/docs/api/openapi.json](/docs/api/openapi.json) describes the REST API. Agents connected over MCP can call `search_docs` and `read_docs`. See [Docs for AI agents](/docs/agents/docs-for-agents).
47+
48+## Access
49+
50+Diffinery is invite-only: 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](/docs/accounts/invitations).
addedapps/diffinery/docs/migrate-from-github.md+504−0View file
@@ -1,0 +1,504 @@
1+---
2+title: Migrate from GitHub
3+description: Move repositories from GitHub to Diffinery with diffinery migrate or plain Git, copy secrets, variables and releases, update remotes, or mirror from GitHub.
4+---
5+
6+This page shows how to move a project from GitHub to Diffinery: with the `diffinery migrate github` command, with plain Git, or by keeping GitHub as the upstream and mirroring every push to Diffinery. It also covers secrets, variables and environments, releases, Git LFS files, remotes, workflows, scripts that call GitHub's API, and a checklist.
7+
8+## Choose a method
9+
10+| Method | Copies | Use it when |
11+| --- | --- | --- |
12+| [`diffinery migrate github`](#import-with-diffinery-migrate) | Branches, tags, default branch, Actions variables, and the names of Actions secrets. With `--issues`, also labels, issues, comments and open pull requests. | You want one command that does the whole move. |
13+| [Plain Git](#move-with-plain-git) | Branches and tags. | You only need the code, or the CLI can't run where you are. |
14+| [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. |
15+
16+Whatever you choose, Diffinery runs your existing `.github/workflows/*.yml` files on the first push. See [Workflows](#workflows).
17+
18+## Before you start
19+
20+You need:
21+
22+- **A Diffinery account.** Diffinery is invite-only; ask a site administrator or an owner or admin of your organization for an invite. See [Invitations](/docs/accounts/invitations).
23+- **An organization** if the repository should belong to one, for example `acme`. Create it under **Settings → Organizations**. See [Organizations](/docs/accounts/organizations).
24+- **A Diffinery token with the `admin` scope.** Create it under **Settings → Access tokens** (`https://diffinery.com/settings/tokens`). The import sets the default branch and Actions variables, which need the admin role, and a `write` token acts as at most maintain. Don't restrict the token to some repositories: the import creates the repository, and a restricted token gets `this token is limited to repositories and can't change account settings`. Use your own account's token, not an agent's: agents can't push to the default branch or set Actions variables. See [Personal access tokens](/docs/accounts/tokens).
25+- **Git**, version 2.31 or newer for `diffinery migrate` (2.26 or newer for plain Git).
26+- **The `diffinery` CLI** for the import command. It needs Node.js 22 or later:
27+
28+ ```sh
29+ curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
30+ diffinery login # paste the dfy_ token
31+ diffinery auth setup-git # let plain git use the same token
32+ ```
33+
34+## Import with diffinery migrate
35+
36+`diffinery migrate github` copies a GitHub repository into Diffinery in one go:
37+
38+```sh
39+export GH_TOKEN=ghp_... # a GitHub token; needed for private repositories
40+diffinery migrate github acme/website --to acme/website --issues
41+```
42+
43+### What it copies
44+
45+| Data | Copied | How |
46+| --- | --- | --- |
47+| Repository | Created if it doesn't exist | With GitHub's description and visibility (a public repository stays public; any other becomes private), or private with `--private`. If the target exists, the command uses it. |
48+| Branches and tags | Yes | Every `refs/heads/*` and `refs/tags/*`. Pull request refs (`refs/pull/*`) and other refs are not copied. |
49+| Default branch | Yes | Set to GitHub's default branch, if that branch was pushed. |
50+| Actions variables | Yes | Repository variables, with their values. |
51+| Actions secrets | Names only | GitHub never reveals secret values. The command prints a `diffinery secret set` line for each one. |
52+| Labels | With `--issues` | Name, color and description. |
53+| Issues | With `--issues` | Title, body, labels, and closed state with its reason (completed or not planned). |
54+| Issue and pull request comments | With `--issues` | The conversation comments, in order. |
55+| Open pull requests | With `--issues` | Recreated when both branches were copied and the head branch is in the same repository (not a fork), with title, body and draft state. |
56+
57+Everything created on Diffinery is created by you (the token's account), with new timestamps. Each issue, pull request and comment starts with an attribution line that names the original author and date and links back to GitHub:
58+
59+```text
60+> Imported from GitHub issue [#12](https://github.com/acme/website/issues/12) by @octocat on 2026-03-01
61+
62+The original description follows here.
63+```
64+
65+Comments start with `> Imported from GitHub: comment by @octocat on 2026-03-02`.
66+
67+### What it doesn't copy
68+
69+- Closed and merged pull requests, pull requests from forks, and pull requests whose branch is missing.
70+- Pull request reviews and review (line) comments.
71+- Assignees, milestones, reactions, locked state and the original authors' accounts (only the attribution line).
72+- Original issue and pull request numbers. Diffinery numbers them in order as it creates them, so they differ from GitHub's whenever something was skipped. The output lists each mapping (`GitHub #12 → #9`).
73+- Secret values, environments with their secrets and variables, organization secrets and organization variables (see [Move secrets and variables](#move-secrets-and-variables)).
74+- Git LFS files (see [Move Git LFS files](#move-git-lfs-files)).
75+- Releases and their assets (see [Move releases](#move-releases)).
76+- Topics, wikis, projects, discussions, GitHub Pages, packages, webhooks, deploy keys, branch protection rules, collaborators and teams, and Actions run history.
77+
78+Set up branch protection, collaborators, webhooks and topics on Diffinery yourself afterwards (see [Checklist](#checklist)).
79+
80+### Options
81+
82+```text
83+diffinery migrate github <GH-OWNER/GH-REPO> [options]
84+```
85+
86+The argument can also be a URL such as `https://github.com/acme/website`.
87+
88+| Option | Default | Meaning |
89+| --- | --- | --- |
90+| `-t, --to OWNER/NAME` | `<you>/<github repo name>` | The Diffinery repository to create or push into. Use `--to acme/website` for an organization. |
91+| `-i, --issues` | off | Also import labels, issues, comments and open pull requests. |
92+| `-p, --private` | off | Create the repository as private, whatever its visibility on GitHub. |
93+| `--json` | off | Print a JSON summary instead of the text report. |
94+
95+Environment variables:
96+
97+| Variable | Meaning |
98+| --- | --- |
99+| `GH_TOKEN` or `GITHUB_TOKEN` | GitHub token (`GH_TOKEN` wins if both are set). Without one, only public repositories can be read, and GitHub's low rate limit for anonymous requests applies. |
100+| `GITHUB_API_URL` | GitHub Enterprise Server API URL, for example `https://github.example.com/api/v3`. Defaults to `https://api.github.com`. |
101+| `DIFFINERY_TOKEN`, `DIFFINERY_URL` | Override the token and server saved by `diffinery login`. |
102+
103+### GitHub token permissions
104+
105+The GitHub token is used to clone the repository and read its metadata through GitHub's REST API. It is passed to Git as a one-off HTTP header and is never written to disk.
106+
107+- **Classic token**: the `repo` scope.
108+- **Fine-grained token** for the repository, read-only access to: **Contents**, **Metadata**, **Issues** and **Pull requests** (for `--issues`), **Variables**, and **Secrets** (only names are read).
109+
110+If the token can't read something optional (labels, issues, pull requests, comments, variables or secret names), the command prints a warning such as `could not read Actions variables from GitHub (403; the token may lack access)` and carries on without it.
111+
112+### What happens when you run it
113+
114+1. Checks your Diffinery login (`GET /api/v3/user`) and reads the GitHub repository.
115+2. Creates the Diffinery repository, or prints `Using existing repository acme/website`.
116+3. Clones the GitHub repository with `git clone --mirror` into a temporary folder, which it deletes at the end.
117+4. Pushes all branches and tags in one `git push`. If that fails (for example because the push is over 100 MB or a ref is rejected), it prints a warning and pushes each ref separately, collecting the refs that still fail.
118+5. Sets the default branch, copies Actions variables and reads secret names.
119+6. With `--issues`: copies labels, then walks through every issue and pull request in number order, creating issues, recreating eligible open pull requests, copying comments and closing issues that were closed.
120+7. Prints a summary.
121+
122+GitHub rate limits are handled: when GitHub asks it to wait, the command prints `GitHub rate limit reached; waiting 42s…` and retries. If the reset is more than an hour away, it stops with `GitHub rate limit: reset is N minutes away; try again later`.
123+
124+Pushes go through Diffinery's [push protection](/docs/security/push-protection) like any other push. Opening the recreated pull requests starts their `pull_request` workflows, and pushing the default branch starts its `push` workflows.
125+
126+### Example output
127+
128+```text
129+✓ Created acme/website (private)
130+Cloning https://github.com/acme/website …
131+Pushing 14 branches and 31 tags to acme/website …
132+✓ Copied 9 labels
133+Importing 120 issues and pull requests …
134+ GitHub #1 → #1 Set up the build
135+ GitHub #2 → #2 Add a contact form
136+ ...
137+
138+Imported acme/website → acme/website
139+ git: 14 branches, 31 tags
140+ default: main
141+ variables: 3
142+ labels: 9
143+ issues: 97 (412 comments)
144+ pulls: 4 recreated, 2 skipped
145+ #118: head branch is in a fork
146+ #119: branch spike/search missing
147+
148+GitHub does not reveal secret values. Set these 2 secrets:
149+ diffinery secret set NPM_TOKEN -R acme/website
150+ diffinery secret set SENTRY_DSN -R acme/website
151+
152+https://diffinery.com/acme/website
153+```
154+
155+The command exits with status 1 if any branch or tag failed to push, and 0 otherwise. With `--json` it prints an object with `repo`, `created`, `branches`, `tags`, `pushFailures`, `defaultBranch`, `labels`, `issues` and `pulls` (lists of `{from, to}` number pairs), `skippedPulls` (`{number, reason}`), `comments`, `variables`, `secrets` and `warnings`.
156+
157+### Running it again
158+
159+Running the command again for the same target pushes new commits and tags. Pushes are not forced, so a branch that changed on both sides fails to push and is listed in the summary. Variables are overwritten with GitHub's values.
160+
161+> **Important:** `--issues` is not idempotent. A second run creates every issue again (labels are only updated). Import issues once, after you have checked the Git import.
162+
163+## Move with plain Git
164+
165+If you only need the code, copy it with Git. Create an empty repository on Diffinery first (web: `https://diffinery.com/new`, or CLI), using the same default branch name as on GitHub:
166+
167+```sh
168+diffinery repo create website --org acme --private --default-branch main
169+```
170+
171+Then copy every branch and tag:
172+
173+```sh
174+git clone --mirror https://github.com/acme/website.git
175+cd website.git
176+git push https://diffinery.com/acme/website.git 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
177+```
178+
179+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`).
180+
181+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:
182+
183+```sh
184+# Push the default branch in chunks of 2,000 commits, then everything else
185+for sha in $(git rev-list --reverse --first-parent main | awk 'NR % 2000 == 0'); do
186+ git push https://diffinery.com/acme/website.git "$sha:refs/heads/main"
187+done
188+git push https://diffinery.com/acme/website.git 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
189+```
190+
191+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:
192+
193+```sh
194+git push -o allow-secrets="historic keys, all rotated" https://diffinery.com/acme/website.git 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
195+```
196+
197+If GitHub's default branch isn't the one you created the repository with, change it under **Settings → General → Default branch**.
198+
199+## Move releases
200+
201+`diffinery migrate github` doesn't copy releases. Recreate them with the GitHub-compatible releases API once the tags are on Diffinery (after the Git import). 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:
202+
203+```sh
204+export DIFFINERY_TOKEN=dfy_... # write scope or higher
205+SRC=acme/website # on GitHub
206+DST=acme/website # on Diffinery
207+API=https://diffinery.com/api/v3
208+
209+gh release list -R "$SRC" --limit 1000 --json tagName --jq 'reverse | .[].tagName' |
210+while read -r tag; do
211+ gh release view "$tag" -R "$SRC" --json tagName,name,body,isDraft,isPrerelease \
212+ --jq '{tag_name: .tagName, name: .name, body: .body, draft: .isDraft, prerelease: .isPrerelease}' > release.json
213+ id=$(curl -fsS -X POST "$API/repos/$DST/releases" \
214+ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "content-type: application/json" \
215+ --data-binary @release.json | jq -r .id)
216+ rm -rf assets && mkdir assets
217+ gh release download "$tag" -R "$SRC" -D assets 2>/dev/null || true
218+ for f in assets/*; do
219+ [ -f "$f" ] || continue
220+ name=$(jq -rn --arg n "$(basename "$f")" '$n | @uri')
221+ curl -fsS -o /dev/null -X POST "https://diffinery.com/api/uploads/repos/$DST/releases/$id/assets?name=$name" \
222+ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "content-type: application/octet-stream" \
223+ --data-binary @"$f"
224+ echo "$tag: uploaded $(basename "$f")"
225+ done
226+done
227+```
228+
229+Things to know:
230+
231+- 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.
232+- Each published release 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.
233+- Release notes, titles, draft and pre-release state are copied. Authors and dates are not: the releases are created by you, now.
234+- An asset can be up to 100 MB. Larger GitHub assets can't be copied.
235+- Source code archives (**Source code (zip)** and **Source code (tar.gz)**) are not assets: Diffinery builds them from the tag. See [Source code archives](/docs/repositories/overview#source-code-archives).
236+
237+See [Releases](/docs/collaboration/releases) for the API in full.
238+
239+## Move Git LFS files
240+
241+Neither `git clone --mirror` nor `diffinery migrate github` copies Git LFS files, only the pointers to them. If the repository uses LFS, copy the files from a clone that can fetch them:
242+
243+```sh
244+git clone https://github.com/acme/website.git website-lfs
245+cd website-lfs
246+git lfs fetch --all origin
247+git lfs push --all https://diffinery.com/acme/website.git
248+```
249+
250+Files over 100 MB can't be uploaded to Diffinery. See [Git LFS](/docs/repositories/git-lfs).
251+
252+## Move secrets and variables
253+
254+### Secrets
255+
256+GitHub never shows secret values, so you set them again on Diffinery from where you keep them (a password manager, your cloud provider's console). Get the list of names with `diffinery migrate github` (it prints them) or with GitHub's CLI (`gh secret list -R acme/website`). Then:
257+
258+```sh
259+# Prompts for the value without echoing it
260+diffinery secret set NPM_TOKEN -R acme/website
261+
262+# Or pipe it in
263+printf '%s' "$NPM_TOKEN" | diffinery secret set NPM_TOKEN -R acme/website
264+
265+# Organization secret, available to every repository in acme
266+diffinery secret set SENTRY_DSN --org acme
267+```
268+
269+Or with the API (an admin role on the repository, and not an agent):
270+
271+```sh
272+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/actions/secrets/NPM_TOKEN \
273+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
274+ -d '{"value": "npm_..."}'
275+```
276+
277+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_`.
278+
279+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).
280+
281+### Environments and their secrets
282+
283+Diffinery has deployment environments with their own secrets, variables and branch policies, like GitHub. They have no page in the web UI yet: create them with the REST API, as a repository admin who is a person (agents can't manage environments, secrets or variables).
284+
285+```sh
286+# Create the environment
287+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production \
288+ -H "Authorization: Bearer dfy_..."
289+
290+# Add its secrets
291+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/DEPLOY_KEY \
292+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
293+ -d '{"value": "..."}'
294+
295+# Add its variables
296+curl -X POST https://diffinery.com/api/v3/repos/acme/website/environments/production/variables \
297+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
298+ -d '{"name": "DEPLOY_URL", "value": "https://www.example.com"}'
299+```
300+
301+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](/docs/repositories/branch-protection) rule for the branch you deploy from (or set the environment's branch policy). Required reviewers and wait timers are not supported yet; the API refuses them. See [Environments](/docs/actions/environments).
302+
303+### Variables
304+
305+`diffinery migrate github` copies repository variables. To copy them yourself:
306+
307+```sh
308+gh variable list -R acme/website --json name,value --jq '.[] | [.name, .value] | @tsv' |
309+while IFS=$'\t' read -r name value; do
310+ diffinery variable set "$name" --body "$value" -R acme/website
311+done
312+```
313+
314+Diffinery has repository and environment variables (see [Environments and their secrets](#environments-and-their-secrets)) but no organization variables: copy organization variables into each repository that uses them. See [Secrets and variables](/docs/actions/secrets-and-variables).
315+
316+## Update remotes and local clones
317+
318+Everyone with a clone points it at Diffinery:
319+
320+```sh
321+git remote set-url origin https://diffinery.com/acme/website.git
322+git fetch origin
323+```
324+
325+Or keep GitHub as a second remote while you move:
326+
327+```sh
328+git remote rename origin github
329+git remote add origin https://diffinery.com/acme/website.git
330+git fetch origin
331+git branch --set-upstream-to=origin/main main
332+```
333+
334+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](/docs/repositories/git).
335+
336+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.
337+
338+## Workflows
339+
340+Diffinery runs GitHub Actions workflows from `.github/workflows/` as they are: the same triggers, expressions, matrices, `needs`, job outputs, caches, artifacts, secrets, OIDC, `actions/checkout`, `actions/setup-node`, `setup-python`, `setup-go`, and remote JavaScript and composite actions. `runs-on: ubuntu-latest` runs on Diffinery's hosted runners.
341+
342+Things to check after the move:
343+
344+| If a workflow... | Do this on Diffinery |
345+| --- | --- |
346+| Uses `runs-on: windows-*` or `macos-*` | Windows and macOS hosted runners are not supported yet. Run the job on a self-hosted runner (`diffinery runner`). See [Runners](/docs/actions/runners). |
347+| Uses `actions/github-script` | Not supported yet. Replace it with a `run:` step that calls the REST API with `curl`. |
348+| Pushes commits or tags using `GITHUB_TOKEN` | The job token can read the repository but not push to it. Store a personal or agent token as a secret and use that. |
349+| Uses environments (`environment: production`) | Create the environment and its secrets and variables with the API (see [Environments and their secrets](#environments-and-their-secrets)). Required reviewers and wait timers are not supported yet. |
350+| 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. |
351+| Checks out Git LFS files (`lfs: true`) | Add a `git lfs pull` step after `actions/checkout`. See [Git LFS](/docs/repositories/git-lfs). |
352+| Publishes images to `ghcr.io` | Use the registry at `diffinery.com` instead. See [Container registry](/docs/packages/container-registry). |
353+| Hard-codes `github.com` or `api.github.com` | Use `${{ github.server_url }}` (`https://diffinery.com`) and `${{ github.api_url }}` (`https://diffinery.com/api/v3`). |
354+| Should run only on one forge | Guard it with `if: github.server_url == 'https://github.com'` (or `'https://diffinery.com'`). |
355+
356+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](/docs/actions/compatibility).
357+
358+## Scripts and tools that call GitHub's API
359+
360+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:
361+
362+```js
363+import { Octokit } from "@octokit/rest";
364+
365+const octokit = new Octokit({ auth: process.env.DIFFINERY_TOKEN, baseUrl: "https://diffinery.com/api/v3" });
366+const { data } = await octokit.rest.repos.listReleases({ owner: "acme", repo: "website" });
367+```
368+
369+Inside workflows, `${{ github.api_url }}` is already `https://diffinery.com/api/v3`. Differences to check, including endpoints and features that are not supported yet (such as GraphQL), are listed in the [REST API reference](/docs/reference/rest-api).
370+
371+## Keep GitHub as upstream and mirror to Diffinery
372+
373+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.
374+
375+Set it up:
376+
377+1. Create the Diffinery repository (for example `acme/website`).
378+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.
379+3. On GitHub, add it as a repository secret named `DIFFINERY_TOKEN`.
380+4. Add this workflow on GitHub as `.github/workflows/mirror-to-diffinery.yml`:
381+
382+```yaml
383+name: Mirror to Diffinery
384+
385+on:
386+ push:
387+ workflow_dispatch:
388+
389+permissions:
390+ contents: read
391+
392+# One mirror at a time, in push order.
393+concurrency:
394+ group: mirror-to-diffinery
395+ cancel-in-progress: false
396+
397+jobs:
398+ mirror:
399+ # The mirrored copy of this file also exists on Diffinery; do nothing there.
400+ if: ${{ github.server_url == 'https://github.com' }}
401+ runs-on: ubuntu-latest
402+ env:
403+ DIFFINERY: https://diffinery.com
404+ TARGET: acme/website
405+ TOKEN: ${{ secrets.DIFFINERY_TOKEN }}
406+ steps:
407+ - name: Copy Actions secrets
408+ env:
409+ S_NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
410+ S_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
411+ run: |
412+ set -euo pipefail
413+ for name in NPM_TOKEN SENTRY_DSN; do
414+ v="S_$name"
415+ [ -n "${!v}" ] || { echo "::warning::secret $name is empty here; not copied"; continue; }
416+ jq -n --arg v "${!v}" '{value: $v}' | curl -fsS -o /dev/null -X PUT \
417+ "$DIFFINERY/api/v3/repos/$TARGET/actions/secrets/$name" \
418+ -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data-binary @-
419+ echo "secret $name copied"
420+ done
421+
422+ - uses: actions/checkout@v4
423+ with:
424+ fetch-depth: 0
425+ # lfs: true # if the repository uses Git LFS
426+
427+ - name: Push every branch and tag
428+ run: |
429+ set -euo pipefail
430+ git fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*'
431+ refs=$(git for-each-ref --format='+%(refname):refs/heads/%(refname:lstrip=3)' refs/remotes/origin | grep -v ':refs/heads/HEAD$')
432+ git -c "http.$DIFFINERY/.extraHeader=Authorization: Bearer $TOKEN" \
433+ push -q "$DIFFINERY/$TARGET.git" $refs '+refs/tags/*:refs/tags/*'
434+ # If the repository uses Git LFS:
435+ # git lfs ls-files | grep -q . && git -c "http.$DIFFINERY/.extraHeader=Authorization: Bearer $TOKEN" lfs push --all "$DIFFINERY/$TARGET.git"
436+```
437+
438+How it works:
439+
440+- 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.
441+- 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`).
442+- The secrets step sends each value straight from GitHub's secret store to Diffinery's 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.
443+- The `if:` guard keeps the job from running on Diffinery, which also runs `.github/workflows/` files once they are mirrored.
444+- If push protection blocks a mirrored push, a repository admin can let it through with `-o allow-secrets=<reason>` on the `git push` line, for example from a `workflow_dispatch` input. Leave it empty normally.
445+
446+### Wait for Diffinery's runs
447+
448+To make the GitHub workflow fail when the workflows on Diffinery fail, add a step that polls Diffinery's runs for the pushed commit:
449+
450+```yaml
451+ - name: Wait for Actions on Diffinery
452+ if: github.ref == 'refs/heads/main'
453+ run: |
454+ set -euo pipefail
455+ sha=$(git rev-parse HEAD)
456+ api() { curl -fsS "$DIFFINERY/api/v3/repos/$TARGET$1" -H "Authorization: Bearer $TOKEN"; }
457+ for i in $(seq 1 240); do
458+ runs=$(api "/actions/runs?head_sha=$sha&per_page=100")
459+ total=$(jq '.workflow_runs | length' <<<"$runs")
460+ pending=$(jq '[.workflow_runs[] | select(.status != "completed")] | length' <<<"$runs")
461+ [ "$total" -gt 0 ] && [ "$pending" = 0 ] && break
462+ sleep 15
463+ done
464+ jq -r '.workflow_runs[] | "\(.name) [\(.event)]: \(.status) \(.conclusion) \(.html_url)"' <<<"$runs"
465+ [ "$(jq '[.workflow_runs[] | select(.conclusion != "success")] | length' <<<"$runs")" = 0 ]
466+```
467+
468+`GET /api/v3/repos/{owner}/{repo}/actions/runs?head_sha=<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.
469+
470+## Checklist
471+
472+1. Get a Diffinery account, create the organization, and create an `admin`-scoped token.
473+2. Import the repository: `diffinery migrate github acme/website --to acme/website` (add `--issues` once you are happy with the Git import), or push with plain Git.
474+3. Copy Git LFS files if the repository uses LFS.
475+4. Check the default branch, description and visibility under **Settings → General**.
476+5. Set every secret printed by the import (`diffinery secret set NAME -R acme/website`), and organization secrets with `--org`.
477+6. Check the variables under **Settings → Secrets and variables**, and recreate environments with their secrets and variables (see [Environments and their secrets](#environments-and-their-secrets)).
478+7. Recreate branch protection rules under **Settings → Branch protection**, using the check names Diffinery shows on pull requests (`<workflow name> / <job name>`). As on GitHub, a rule that names a branch exactly wins over wildcard rules, and otherwise the oldest matching rule applies. See [Branch protection](/docs/repositories/branch-protection).
479+8. Add collaborators under **Settings → Collaborators**, and invite your team to the organization.
480+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](/docs/repositories/webhooks).
481+10. Copy releases (see [Move releases](#move-releases)) and set topics with `PUT /api/v3/repos/acme/website/topics` (see [Topics](/docs/repositories/overview#topics)).
482+11. 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](/docs/collaboration/checks-and-statuses).
483+12. 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).
484+13. Create agent accounts for AI tools and connect them over MCP. See [Agents](/docs/agents/overview).
485+14. Update remotes on every clone, and URLs in scripts, manifests and READMEs.
486+15. When the team has moved, archive the GitHub repository or remove the mirror workflow.
487+
488+## Troubleshooting
489+
490+| Message | Cause and fix |
491+| --- | --- |
492+| `not logged in: run ...` (naming `diffinery login` and `DIFFINERY_TOKEN`) | Log in to Diffinery first with `diffinery login`, or set `DIFFINERY_TOKEN`. |
493+| `GitHub /repos/acme/website: 404 Not Found` | The GitHub repository name is wrong, or it is private and `GH_TOKEN` is missing or can't see it. |
494+| `git clone --mirror failed: ...` | Git couldn't clone from GitHub. Check `GH_TOKEN` (it needs Contents read access) and that Git is installed. |
495+| `this token is limited to repositories and can't change account settings` | The Diffinery token is restricted to some repositories, so it can't create the target repository. Create an unrestricted `admin` token. |
496+| `agents cannot manage variables` or `agents may not push to protected or default branches` | You ran the import with an agent's token. Use a token of your own account. |
497+| `admin access required` | The Diffinery token is not `admin`-scoped, or you are not an admin of the target repository (organization members get write only). Use an `admin` token and ask an organization owner to make you an admin. |
498+| `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. |
499+| A ref fails with `secret detected (push protection)` | Old commits contain something that looks like a credential. Rotate it, then push that ref with plain Git and `-o allow-secrets=<reason>` as a repository admin. See [Move with plain Git](#move-with-plain-git). |
500+| A ref fails with `push exceeds 100 MB` | Push that branch's history in steps, oldest first, as shown in [Move with plain Git](#move-with-plain-git). |
501+| A ref fails with `fetch first` or a non-fast-forward error | The branch already exists on Diffinery with other commits. Delete it there, or push it with `+` (force) using plain Git. |
502+| `could not read ... from GitHub (403; the token may lack access)` | The GitHub token can't read that data. Give it the permissions in [GitHub token permissions](#github-token-permissions), or copy that data by hand. |
503+| `GitHub rate limit: reset is N minutes away; try again later` (plus `or set GH_TOKEN` when no GitHub token is set) | Wait for the reset, or set `GH_TOKEN` to get a higher limit. |
504+| Workflows don't start after the import | Check the **Actions** tab for runs that failed to parse, and run `diffinery workflow validate` in the repository. |
addedapps/diffinery/docs/nav.json+104−0View file
@@ -1,0 +1,104 @@
1+[
2+ {
3+ "title": "Get started",
4+ "pages": [
5+ "index",
6+ "quickstart",
7+ "concepts",
8+ "migrate-from-github"
9+ ]
10+ },
11+ {
12+ "title": "Accounts",
13+ "pages": [
14+ "accounts/passkeys-and-sign-in",
15+ "accounts/invitations",
16+ "accounts/organizations",
17+ "accounts/permissions",
18+ "accounts/tokens",
19+ "accounts/notifications",
20+ "accounts/profile-and-preferences"
21+ ]
22+ },
23+ {
24+ "title": "Repositories",
25+ "pages": [
26+ "repositories/overview",
27+ "repositories/git",
28+ "repositories/git-lfs",
29+ "repositories/browsing-code",
30+ "repositories/branch-protection",
31+ "repositories/webhooks"
32+ ]
33+ },
34+ {
35+ "title": "Collaboration",
36+ "pages": [
37+ "collaboration/issues",
38+ "collaboration/pull-requests",
39+ "collaboration/merge-queue",
40+ "collaboration/checks-and-statuses",
41+ "collaboration/releases"
42+ ]
43+ },
44+ {
45+ "title": "Actions",
46+ "pages": [
47+ "actions/overview",
48+ "actions/workflow-syntax",
49+ "actions/events",
50+ "actions/expressions",
51+ "actions/runners",
52+ "actions/docker",
53+ "actions/reusable-workflows",
54+ "actions/secrets-and-variables",
55+ "actions/environments",
56+ "actions/oidc",
57+ "actions/caching-and-artifacts",
58+ "actions/compatibility"
59+ ]
60+ },
61+ {
62+ "title": "Packages",
63+ "pages": [
64+ "packages/container-registry"
65+ ]
66+ },
67+ {
68+ "title": "Agents and AI",
69+ "pages": [
70+ "agents/overview",
71+ "agents/mcp",
72+ "agents/sandboxes",
73+ "agents/docs-for-agents"
74+ ]
75+ },
76+ {
77+ "title": "Security",
78+ "pages": [
79+ "security/overview",
80+ "security/push-protection",
81+ "security/audit-log"
82+ ]
83+ },
84+ {
85+ "title": "Administration",
86+ "pages": [
87+ "admin/site-administration",
88+ "self-hosting/deploy",
89+ "self-hosting/configuration",
90+ "self-hosting/architecture"
91+ ]
92+ },
93+ {
94+ "title": "Reference",
95+ "pages": [
96+ "reference/rest-api",
97+ "api",
98+ "reference/cli",
99+ "reference/mcp-tools",
100+ "reference/webhook-events",
101+ "reference/limits"
102+ ]
103+ }
104+]
addedapps/diffinery/docs/packages/container-registry.md+586−0View file
@@ -1,0 +1,586 @@
1+---
2+title: Container registry
3+description: Push and pull container images at diffinery.com, link them to repositories, control who can use them, and publish them from workflows instead of ghcr.io.
4+---
5+
6+Diffinery hosts container images at `diffinery.com`, 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.
7+
8+## Image names
9+
10+```text
11+diffinery.com/<owner>/<name>[:<tag>|@sha256:<digest>]
12+```
13+
14+| Part | Rules |
15+| --- | --- |
16+| `<owner>` | A user or organization on Diffinery. Agents can't own packages. |
17+| `<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 `-`. |
18+| Whole name | `<owner>/<name>` is at most 255 characters. |
19+| `<tag>` | Up to 128 characters: letters, digits, `_`, `.` and `-`, not starting with `.` or `-`. |
20+| `<digest>` | `sha256:` followed by 64 lowercase hex characters. Other digest algorithms aren't supported. |
21+
22+Image names are lowercase. The owner part is matched without regard to case, so `diffinery.com/acme/website` refers to the organization `Acme` too.
23+
24+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.
25+
26+## Sign in
27+
28+The registry doesn't take passwords. Sign in with any username and a token as the password.
29+
30+### With a personal access token
31+
32+Create a token under **Settings → Access tokens** (see [Personal access tokens](/docs/accounts/tokens)). Its scope decides what it can do in the registry:
33+
34+| Token scope | Registry access |
35+| --- | --- |
36+| `read` | Pull |
37+| `write` | Pull and push |
38+| `admin` | Pull, push and delete versions and packages, change package settings |
39+
40+A token restricted to some repositories only reaches packages linked to one of those repositories, plus public packages, which anyone can pull.
41+
42+```sh
43+echo "$DIFFINERY_TOKEN" | docker login diffinery.com -u octocat --password-stdin
44+```
45+
46+```text
47+Login Succeeded
48+```
49+
50+The same works with other clients:
51+
52+```sh
53+echo "$DIFFINERY_TOKEN" | podman login diffinery.com -u octocat --password-stdin
54+crane auth login diffinery.com -u octocat -p "$DIFFINERY_TOKEN"
55+```
56+
57+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.
58+
59+### With an agent token
60+
61+An [agent](/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).
62+
63+### In a workflow
64+
65+A job signs in with its `GITHUB_TOKEN`, as with ghcr.io:
66+
67+```yaml
68+ - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin
69+```
70+
71+What the token can do depends on the job's `packages` permission. See [Workflows](#workflows).
72+
73+## Push and pull
74+
75+### Push an image
76+
77+Tag the image with its Diffinery name and push it:
78+
79+```sh
80+docker build -t diffinery.com/acme/website:1.4.0 .
81+docker push diffinery.com/acme/website:1.4.0
82+```
83+
84+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).
85+
86+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.
87+
88+### Pull an image
89+
90+```sh
91+docker pull diffinery.com/acme/website:1.4.0
92+docker pull diffinery.com/acme/website@sha256:4f9a...
93+```
94+
95+Pulls work with every client, from anywhere, with no size limit.
96+
97+### Multi-platform images and OCI artifacts
98+
99+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.
100+
101+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.
102+
103+Schema 1 manifests are refused with `only schemaVersion 2 manifests (OCI or Docker v2) are supported`.
104+
105+## Visibility
106+
107+| Visibility | Who can pull |
108+| --- | --- |
109+| **Private** (default) | The owning user, the members of the owning organization, and people with access to the linked repository. See [Permissions](#permissions). |
110+| **Public** | Anyone, without signing in. |
111+
112+There are only these two settings. Visibility only controls pulling. Pushing and deleting always need the permissions described below.
113+
114+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:
115+
116+```sh
117+curl -X PATCH https://diffinery.com/api/v3/orgs/acme/packages/container/website \
118+ -H "Authorization: Bearer dfy_..." \
119+ -H "Content-Type: application/json" \
120+ -d '{"visibility": "public"}'
121+```
122+
123+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.
124+
125+## Link a package to a repository
126+
127+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.
128+
129+### How a package gets linked
130+
131+A new package is linked when it's created in one of these ways:
132+
133+- **By a workflow.** A package created by a workflow's `GITHUB_TOKEN` is linked to the workflow's repository.
134+- **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`.
135+- **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.
136+
137+```dockerfile
138+LABEL org.opencontainers.image.source="https://diffinery.com/acme/website"
139+LABEL org.opencontainers.image.description="The acme.example website"
140+```
141+
142+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.
143+
144+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.
145+
146+### Change the linked repository
147+
148+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:
149+
150+```sh
151+curl -X PATCH https://diffinery.com/api/v3/orgs/acme/packages/container/website \
152+ -H "Authorization: Bearer dfy_..." \
153+ -H "Content-Type: application/json" \
154+ -d '{"repository": "website"}'
155+```
156+
157+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.
158+
159+## Permissions
160+
161+Each caller gets one of four roles on a package:
162+
163+| Role | Can |
164+| --- | --- |
165+| None | Nothing. The package looks like it doesn't exist. |
166+| Pull | Pull images, list tags, view the package page |
167+| Push | Pull, push images and tags |
168+| Admin | Push, delete tags, versions and the package, change visibility and the linked repository |
169+
170+### People
171+
172+A person gets the highest role that any of these give them:
173+
174+| Relationship | Role |
175+| --- | --- |
176+| The package belongs to your user account | Admin |
177+| You're an owner or admin of the organization that owns the package | Admin |
178+| You're a member of the organization that owns the package | Pull, and you can create new packages under the organization |
179+| You're the organization member who created the package (or whose agent did), and it isn't linked to a repository | Admin |
180+| The package is linked to a repository where you have `read` | Pull |
181+| The package is linked to a repository where you have `write` or `maintain` | Push |
182+| The package is linked to a repository where you have `admin` | Admin |
183+| The package is public | Pull |
184+| You're a site administrator | Admin |
185+
186+See [Permissions](/docs/accounts/permissions) for repository roles.
187+
188+### Agents
189+
190+An agent's role through its owner is at most push:
191+
192+| Agent | Role |
193+| --- | --- |
194+| Owned by the user that owns the package | Push |
195+| Owned by the organization that owns the package | Push |
196+| Owned by an owner or admin of the organization that owns the package | Push |
197+| Owned by a member of the organization that owns the package | Pull, 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. |
198+
199+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.
200+
201+### Personal and agent tokens
202+
203+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.
204+
205+For a server that only pulls, create a `read` token restricted to the repository the image is linked to.
206+
207+### Workflows
208+
209+A workflow's `GITHUB_TOKEN` follows the job's `packages` permission:
210+
211+| `permissions` in the workflow or job | `packages` permission |
212+| --- | --- |
213+| No `permissions` anywhere | `write` |
214+| `write-all` or `packages: write` | `write` |
215+| `read-all` or `packages: read` | `read` |
216+| A mapping without `packages`, or `packages: none` | none |
217+
218+The job's `permissions` replace the workflow's. With that permission, the token gets these roles:
219+
220+| Package | `read` | `write` |
221+| --- | --- | --- |
222+| Linked to the workflow's repository | Pull | Admin: push and delete versions |
223+| Same owner as the workflow's repository, not linked to any repository | Pull | Pull |
224+| Same owner, linked to another repository | Pull | Pull |
225+| A new name under the same owner | Can't create | Push: creates the package, linked to the workflow's repository |
226+| Another owner's public package | Pull | Pull |
227+| Another owner's private package, or a new name under another owner | None | None |
228+
229+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.
230+
231+The token stops working when the job finishes. Sandbox tokens (`dst_`) used in [sandboxes](/docs/agents/sandboxes) follow the same rules, with `write` when the person who created the sandbox can write to the repository and `read` otherwise.
232+
233+## Package pages
234+
235+### An owner's packages
236+
237+`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.
238+
239+### The package page
240+
241+`https://diffinery.com/<owner>/-/packages/container/<name>`, for example `https://diffinery.com/acme/-/packages/container/website/worker`, shows:
242+
243+- 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.").
244+- **Use this image**: the `docker login` and `docker pull` commands, and a workflow snippet that signs in with `GITHUB_TOKEN`.
245+- **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.
246+- For admins, a **Settings** box with **Visibility** and **Linked repository**, and a **Delete this package** box.
247+
248+### The repository's Packages tab
249+
250+`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.
251+
252+## Use the registry in workflows
253+
254+### Build and push
255+
256+```yaml
257+name: Image
258+on:
259+ push:
260+ branches: [main]
261+ tags: ["v*"]
262+
263+jobs:
264+ image:
265+ runs-on: ubuntu-latest
266+ permissions:
267+ contents: read
268+ packages: write
269+ steps:
270+ - uses: actions/checkout@v4
271+ - run: echo "IMAGE=diffinery.com/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
272+ - uses: docker/setup-buildx-action@v3
273+ - uses: docker/login-action@v3
274+ with:
275+ registry: diffinery.com
276+ username: ${{ github.actor }}
277+ password: ${{ secrets.GITHUB_TOKEN }}
278+ - uses: docker/build-push-action@v6
279+ with:
280+ context: .
281+ push: true
282+ platforms: linux/amd64,linux/arm64
283+ tags: |
284+ ${{ env.IMAGE }}:${{ github.sha }}
285+ ${{ env.IMAGE }}:latest
286+```
287+
288+`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,,}`.
289+
290+More examples, including plain `docker build` and `docker push` in a `run:` step, are in [Docker in workflows](/docs/actions/docker#push-images-to-the-diffinery-registry).
291+
292+### Use an image as a job container or service
293+
294+```yaml
295+jobs:
296+ test:
297+ runs-on: ubuntu-latest
298+ permissions:
299+ packages: read
300+ container:
301+ image: diffinery.com/acme/build-tools:2
302+ credentials:
303+ username: ${{ github.actor }}
304+ password: ${{ secrets.GITHUB_TOKEN }}
305+ services:
306+ api:
307+ image: diffinery.com/acme/website:${{ github.sha }}
308+ credentials:
309+ username: ${{ github.actor }}
310+ password: ${{ secrets.GITHUB_TOKEN }}
311+ steps:
312+ - run: curl -fsS http://api:8080/health
313+```
314+
315+With `packages: read`, a workflow can pull every package of its own owner and every public package. A later job of the same run (or another workflow) can also pull an image that an earlier job pushed in a `run:` step:
316+
317+```yaml
318+ - run: |
319+ echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin
320+ docker pull "diffinery.com/acme/website:${{ github.sha }}"
321+ docker run --rm "diffinery.com/acme/website:${{ github.sha }}" npm test
322+```
323+
324+### Delete old versions from a workflow
325+
326+A workflow with `packages: write` can delete versions of packages linked to its repository through the REST API:
327+
328+```yaml
329+ - name: Delete untagged versions
330+ env:
331+ TOKEN: ${{ secrets.GITHUB_TOKEN }}
332+ run: |
333+ api="$GITHUB_API_URL/orgs/acme/packages/container/website/versions"
334+ curl -fsS -H "Authorization: Bearer $TOKEN" "$api?per_page=100" \
335+ | jq -r '.[] | select(.metadata.container.tags | length == 0) | .id' \
336+ | while read -r id; do
337+ curl -fsS -X DELETE -H "Authorization: Bearer $TOKEN" "$api/$id"
338+ done
339+```
340+
341+## Delete versions and packages
342+
343+Deleting needs the admin role on the package. Anything that pulls a deleted tag or digest stops working.
344+
345+### On the web
346+
347+- **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."
348+- **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."
349+
350+### With the REST API
351+
352+```sh
353+# Delete one version (and its tags)
354+curl -X DELETE -H "Authorization: Bearer dfy_..." \
355+ https://diffinery.com/api/v3/orgs/acme/packages/container/website/versions/42
356+
357+# Delete the package
358+curl -X DELETE -H "Authorization: Bearer dfy_..." \
359+ https://diffinery.com/api/v3/orgs/acme/packages/container/website
360+```
361+
362+Both return `204 No Content`.
363+
364+### With the registry API
365+
366+```sh
367+# Remove one tag; the version stays, untagged
368+curl -X DELETE -u octocat:dfy_... https://diffinery.com/v2/acme/website/manifests/1.4.0
369+
370+# Delete a version and every tag pointing at it
371+curl -X DELETE -u octocat:dfy_... https://diffinery.com/v2/acme/website/manifests/sha256:4f9a...
372+```
373+
374+Both return `202 Accepted`. Tools such as `crane delete` and `regctl` use these calls.
375+
376+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.
377+
378+## Storage cleanup
379+
380+Layers are stored once even when several images or packages use them. Diffinery cleans up storage on a schedule:
381+
382+- Uploads that weren't finished are discarded after a day.
383+- 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.
384+
385+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.
386+
387+## REST API
388+
389+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`.
390+
391+| Request | Description |
392+| --- | --- |
393+| `GET /user/packages?package_type=container` | Your own packages |
394+| `GET /users/{user}/packages` | A user's packages you can pull. `?visibility=private` or `?visibility=public` filters. |
395+| `GET /orgs/{org}/packages` | An organization's packages you can pull, with the same filter |
396+| `GET /{owner path}/packages/container/{name}` | One package |
397+| `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. |
398+| `DELETE /{owner path}/packages/container/{name}` | Delete the package |
399+| `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. |
400+| `GET /{owner path}/packages/container/{name}/versions/{id}` | One version |
401+| `DELETE /{owner path}/packages/container/{name}/versions/{id}` | Delete a version and its tags |
402+| `GET /repos/{owner}/{repo}/packages` | Diffinery only: packages linked to a repository |
403+
404+`{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`.
405+
406+A package:
407+
408+```json
409+{
410+ "id": "0f3c2a9d8e7b6a5c4d3e2f1a0b9c8d7e",
411+ "name": "website",
412+ "package_type": "container",
413+ "owner": { "login": "acme", "type": "Organization" },
414+ "version_count": 12,
415+ "visibility": "private",
416+ "description": "The acme.example website",
417+ "image": "diffinery.com/acme/website",
418+ "url": "https://diffinery.com/api/v3/orgs/acme/packages/container/website",
419+ "html_url": "https://diffinery.com/acme/-/packages/container/website",
420+ "repository": {
421+ "id": "8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
422+ "name": "website",
423+ "full_name": "acme/website",
424+ "html_url": "https://diffinery.com/acme/website"
425+ },
426+ "created_at": "2026-09-01T10:00:00.000Z",
427+ "updated_at": "2026-10-12T08:15:00.000Z"
428+}
429+```
430+
431+A version:
432+
433+```json
434+{
435+ "id": 42,
436+ "name": "sha256:4f9a...",
437+ "url": "https://diffinery.com/api/v3/orgs/acme/packages/container/website/versions/42",
438+ "package_html_url": "https://diffinery.com/acme/-/packages/container/website",
439+ "html_url": "https://diffinery.com/acme/-/packages/container/website/sha256:4f9a...",
440+ "created_at": "2026-10-12T08:15:00.000Z",
441+ "updated_at": "2026-10-12T08:15:00.000Z",
442+ "media_type": "application/vnd.oci.image.index.v1+json",
443+ "size": 48213377,
444+ "platforms": ["linux/amd64", "linux/arm64"],
445+ "pushed_by": "octocat",
446+ "metadata": { "package_type": "container", "container": { "tags": ["1.4.0", "latest"] } }
447+}
448+```
449+
450+Every endpoint is also in the [API reference](/docs/api).
451+
452+## Registry API
453+
454+The OCI distribution API is served at `https://diffinery.com/v2/`. It supports:
455+
456+| Feature | Notes |
457+| --- | --- |
458+| 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 |
459+| Blob downloads | `GET` and `HEAD`, with `Range` requests |
460+| 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. |
461+| 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 |
462+| Referrers | `GET /v2/<owner>/<name>/referrers/<digest>`, with an `artifactType` filter |
463+| Catalog | `GET /v2/_catalog` lists the packages you can pull, with `n` and `last` |
464+
465+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`.
466+
467+## Size limits
468+
469+| Limit | Value |
470+| --- | --- |
471+| One request | 100 MB. Diffinery runs behind Cloudflare, which refuses larger request bodies. |
472+| One manifest | 4 MiB. A larger one fails with `413 MANIFEST_INVALID: manifests are limited to 4 MiB`. |
473+| Image name | 255 characters |
474+| Pull size | No limit |
475+
476+`docker push` and most other clients send each layer in a single request. That affects where you can push large layers from:
477+
478+- **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.
479+- **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.
480+
481+See [Limits](/docs/reference/limits) for the rest of Diffinery's limits.
482+
483+## Use images outside Diffinery
484+
485+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:
486+
487+```sh
488+kubectl create secret docker-registry diffinery \
489+ --docker-server=diffinery.com \
490+ --docker-username=octocat \
491+ --docker-password=dfy_...
492+```
493+
494+```yaml
495+apiVersion: v1
496+kind: Pod
497+metadata:
498+ name: website
499+spec:
500+ imagePullSecrets:
501+ - name: diffinery
502+ containers:
503+ - name: web
504+ image: diffinery.com/acme/website:1.4.0
505+```
506+
507+Tokens expire after at most 366 days, so plan to rotate the secret. Public packages need no secret.
508+
509+## Migrating from ghcr.io
510+
511+1. Replace `ghcr.io` with `diffinery.com` in image names, in `docker login` commands, and in `registry:` for `docker/login-action`.
512+2. Keep `permissions: packages: write` and `secrets.GITHUB_TOKEN`; they work the same way.
513+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.
514+4. Lowercase image names built from `github.repository`, as on ghcr.io.
515+5. Copy existing images you still need.
516+
517+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:
518+
519+```sh
520+crane auth login ghcr.io -u octocat -p "$GITHUB_PAT"
521+crane auth login diffinery.com -u octocat -p "$DIFFINERY_TOKEN"
522+crane copy ghcr.io/acme/website:1.4.0 diffinery.com/acme/website:1.4.0
523+```
524+
525+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`:
526+
527+```yaml
528+name: Copy images from ghcr.io
529+on:
530+ workflow_dispatch:
531+ inputs:
532+ tag:
533+ type: string
534+ required: true
535+
536+jobs:
537+ copy:
538+ runs-on: ubuntu-latest
539+ permissions:
540+ packages: write
541+ steps:
542+ - run: echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u octocat --password-stdin
543+ - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login diffinery.com -u "${{ github.actor }}" --password-stdin
544+ - run: |
545+ docker pull "ghcr.io/acme/website:${{ inputs.tag }}"
546+ docker tag "ghcr.io/acme/website:${{ inputs.tag }}" "diffinery.com/acme/website:${{ inputs.tag }}"
547+ docker push "diffinery.com/acme/website:${{ inputs.tag }}"
548+```
549+
550+`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`.
551+
552+## Differences from ghcr.io
553+
554+- 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).
555+- A workflow's token with `packages: read` can pull every package of its owner.
556+- The link to a repository can be changed after creation, on the package page or with `PATCH`.
557+- Package settings, visibility and deletion are on the package page; there is no separate access list per package.
558+- A single request is limited to 100 MB outside hosted runners.
559+- Only container packages exist. npm, Maven, NuGet and RubyGems packages aren't hosted.
560+
561+## Troubleshooting
562+
563+| Message | Cause and fix |
564+| --- | --- |
565+| `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. |
566+| `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. |
567+| `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)`. |
568+| `name unknown: no user or organization named <owner>` | Check the owner in the image name. |
569+| `name invalid: invalid image name "...": use diffinery.com/<owner>/<name> with lowercase letters, digits, '.', '_', '-' and '/'` | Lowercase the name and remove other characters. |
570+| `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`. |
571+| `denied: pushing to acme/website is not allowed: this token only has the read scope` | Use a token with the `write` scope. |
572+| `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. |
573+| `denied: deleting from acme/website is not allowed` | Deleting needs the admin role, and an `admin`-scoped token. |
574+| `... 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). |
575+| `... the workflow's GITHUB_TOKEN has packages: read; add permissions: packages: write to push` | Change the permission to `write`. |
576+| `... 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. |
577+| `... 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. |
578+| `... 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. |
579+| `... the job token is no longer valid` | The job has finished. Use the token only while the job runs. |
580+| 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). |
581+| `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. |
582+| `manifest blob unknown: manifest sha256:... must be pushed before the index that lists it` | Push the per-platform manifests before the index. |
583+| `blob upload unknown: upload not found; it may have expired` | The upload was idle for more than a day or was cancelled. Push again. |
584+| `manifest invalid: only schemaVersion 2 manifests (OCI or Docker v2) are supported` | Rebuild the image with a current Docker or Buildx. |
585+| `404 Diffinery hosts container packages only (got <type>)` | The API path uses a package type other than `container`. |
586+| `422 visibility must be private or public` | The `PATCH` body has another `visibility` value. Use `private` or `public`. |
addedapps/diffinery/docs/quickstart.md+264−0View file
@@ -1,0 +1,264 @@
1+---
2+title: Quickstart
3+description: Go from an invite to a working Diffinery repository: passkey, token, CLI, first push, workflow run, pull request, release and an AI agent over MCP.
4+---
5+
6+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.
7+
8+The examples use the organization `acme`, the repository `acme/website`, the user `octocat` and the agent `review-bot`. Replace them with your own names.
9+
10+## What you need
11+
12+- A browser that supports passkeys (current Chrome, Safari, Edge or Firefox).
13+- Git 2.26 or newer (`git --version`).
14+- Node.js 22 or later, for the `diffinery` CLI (`node --version`).
15+- An invite to Diffinery. Diffinery is invite-only: a site administrator, an organization owner or admin, or a repository admin sends it.
16+
17+## 1. Join with your invite and create a passkey
18+
19+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.
20+
21+1. Open the invite link from the email (`https://diffinery.com/invite/...`). Invite links work for 14 days.
22+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/<username>/...`.
23+3. Click **Create a passkey and join** and complete your browser's prompt.
24+
25+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**.
26+
27+> **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](/docs/accounts/passkeys-and-sign-in).
28+
29+## 2. Create a token
30+
31+Git, the CLI, the REST API and MCP clients authenticate with a personal access token, not a passkey.
32+
33+1. Go to **Settings → Access tokens** (`https://diffinery.com/settings/tokens`).
34+2. Under **Create a token**, enter a **Name** (for example `laptop`), keep **Expires in (days)** at 90 (1 to 366), and pick a **Scope**:
35+
36+ | Scope | Allows |
37+ | --- | --- |
38+ | **Read** | Clone, fetch and view only. Every change is refused (`this token needs the write scope`). |
39+ | **Write** (default) | Push, issues, pull requests, releases, runs and creating repositories (up to the maintain role). |
40+ | **Admin** | Everything you can do, including repository settings and secrets, and creating organizations, agents, invitations and other tokens. |
41+
42+ For this guide, keep **Write**. Leave **Restrict to repositories** empty.
43+3. Click **Create token** and copy the token. It starts with `dfy_` and is shown only once.
44+
45+Diffinery emails you whenever a token is created on your account. See [Personal access tokens](/docs/accounts/tokens).
46+
47+## 3. Install the CLI and log in
48+
49+Install the `diffinery` command-line client (needs Node.js 22 or later; make sure `~/.local/bin` exists and is on your `PATH`):
50+
51+```sh
52+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
53+```
54+
55+Log in with the token and let Git use it too:
56+
57+```sh
58+diffinery login # paste the dfy_ token when asked
59+diffinery whoami # prints octocat
60+diffinery auth setup-git # git clone/fetch/push to diffinery.com now use the token
61+```
62+
63+`diffinery login` saves the server URL and token to `~/.config/diffinery/config.json` (readable only by you). The `DIFFINERY_TOKEN` and `DIFFINERY_URL` environment variables override it, which is handy in CI. See [CLI reference](/docs/reference/cli).
64+
65+Without the CLI, Git asks for a username and password: enter any username and the token as the password.
66+
67+## 4. Create a repository
68+
69+Pick one of these.
70+
71+**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**.
72+
73+**CLI.**
74+
75+```sh
76+diffinery repo create website --org acme --private -d "Marketing site"
77+```
78+
79+**REST API.**
80+
81+```sh
82+curl -X POST https://diffinery.com/api/v3/orgs/acme/repos \
83+ -H "Authorization: Bearer dfy_..." \
84+ -H "content-type: application/json" \
85+ -d '{"name": "website", "description": "Marketing site", "visibility": "private"}'
86+```
87+
88+Leave out `--org acme` (or use `POST /api/v3/user/repos`) to create it in your own account. See [Repositories](/docs/repositories/overview).
89+
90+## 5. Push your code
91+
92+To bring over a repository that lives on GitHub, with its branches, tags, variables and optionally its issues and open pull requests, use `diffinery migrate github` instead; see [Migrate from GitHub](/docs/migrate-from-github).
93+
94+From an existing project:
95+
96+```sh
97+cd website
98+git remote add origin https://diffinery.com/acme/website.git
99+git push -u origin main
100+```
101+
102+Or start from scratch:
103+
104+```sh
105+mkdir website && cd website
106+echo "# website" > README.md
107+git init -b main
108+git add README.md
109+git commit -m "first commit"
110+git remote add origin https://diffinery.com/acme/website.git
111+git push -u origin main
112+```
113+
114+Open `https://diffinery.com/acme/website` to see the files (with syntax highlighting) and the rendered README.
115+
116+Every push is checked for leaked credentials. 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](/docs/security/push-protection) and [Git over HTTPS](/docs/repositories/git).
117+
118+## 6. Add a workflow and watch it run
119+
120+Diffinery runs GitHub Actions workflows from `.github/workflows/`. Add one:
121+
122+```yaml
123+# .github/workflows/ci.yml
124+name: CI
125+
126+on:
127+ push:
128+ branches: [main]
129+ pull_request:
130+
131+jobs:
132+ test:
133+ runs-on: ubuntu-latest
134+ steps:
135+ - uses: actions/checkout@v4
136+ - name: Show what we are building
137+ run: |
138+ echo "Event: ${{ github.event_name }}, commit: ${{ github.sha }}"
139+ ls -la
140+```
141+
142+```sh
143+git add .github/workflows/ci.yml
144+git commit -m "Add CI"
145+git push
146+```
147+
148+The push starts a run. Watch it:
149+
150+- **Web UI**: open the repository's **Actions** tab, click the run, then the `test` job. The log streams live while the job runs.
151+- **CLI**: run these inside your clone (the CLI finds the repository from the `origin` remote, or pass `-R acme/website`):
152+
153+ ```sh
154+ diffinery run list # newest runs first, with their ID
155+ diffinery run watch <run-id> # follow it until it finishes; exits non-zero if it fails
156+ diffinery run logs <job-id> # print a job's log
157+ ```
158+
159+- **API**: `GET https://diffinery.com/api/v3/repos/acme/website/actions/runs`.
160+
161+`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](/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](/docs/actions/overview).
162+
163+## 7. Open a pull request
164+
165+Make a change on a branch and push it:
166+
167+```sh
168+git switch -c update-readme
169+echo "Our marketing site." >> README.md
170+git commit -am "Describe the project"
171+git push -u origin update-readme
172+```
173+
174+The push output includes a link: `Create a pull request for 'update-readme':` followed by the compare URL.
175+
176+**Web UI.** Open that link (or the repository's **Pull requests** tab, then **New pull request**). On the compare page, fill in:
177+
178+- **Title**,
179+- **Intent**: one or two sentences on what the change is meant to do, for human and AI reviewers,
180+- **Description** (Markdown),
181+
182+then click **Create pull request**.
183+
184+**CLI.**
185+
186+```sh
187+diffinery pr create --title "Describe the project" \
188+ --intent "Add a one-line description of the site to the README." \
189+ --body "Small docs change."
190+```
191+
192+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:
193+
194+```sh
195+diffinery pr merge 1 --squash --delete-branch
196+```
197+
198+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](/docs/collaboration/pull-requests) and [Branch protection](/docs/repositories/branch-protection).
199+
200+## 8. Publish a release
201+
202+A release turns a tag into a version people can download, with release notes and attached files.
203+
204+**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.
205+
206+**REST API** (GitHub-compatible, so Octokit and similar tools work too):
207+
208+```sh
209+curl -X POST https://diffinery.com/api/v3/repos/acme/website/releases \
210+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
211+ -d '{"tag_name": "v0.1.0", "name": "First release", "generate_release_notes": true}'
212+```
213+
214+Publishing creates the tag if it doesn't exist and starts workflows that listen for `on: release`. See [Releases](/docs/collaboration/releases).
215+
216+To deploy from a workflow with its own secrets and branch rules, use an environment (`environment: production` on a job); see [Environments](/docs/actions/environments).
217+
218+## 9. Connect an AI agent over MCP
219+
220+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.
221+
222+### Create the agent
223+
224+**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.
225+
226+**CLI.** Creating an agent is an account-level action, so the CLI needs a token with the `admin` scope (the `write` token from step 2 gets `this token needs the admin scope`). Create an `admin` token under **Settings → Access tokens** first, then:
227+
228+```sh
229+DIFFINERY_TOKEN=dfy_... diffinery agent create review-bot --org acme
230+```
231+
232+This prints the agent's token once (scope `write`, valid for 90 days). 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.
233+
234+### Point your MCP client at Diffinery
235+
236+Diffinery's MCP server is at `https://diffinery.com/mcp` (Streamable HTTP). Give the client the agent's token as a bearer token:
237+
238+```json
239+{
240+ "mcpServers": {
241+ "diffinery": {
242+ "type": "http",
243+ "url": "https://diffinery.com/mcp",
244+ "headers": { "Authorization": "Bearer dfy_..." }
245+ }
246+ }
247+}
248+```
249+
250+`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.
251+
252+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](/docs/agents/mcp) and the [MCP tool reference](/docs/reference/mcp-tools).
253+
254+## Next steps
255+
256+- [Concepts](/docs/concepts): accounts, roles, tokens, visibility, Actions and agents in one page.
257+- [Migrate from GitHub](/docs/migrate-from-github): import a GitHub repository with `diffinery migrate github` (branches, tags, variables and, optionally, issues and open pull requests), then set its secrets again.
258+- [Branch protection](/docs/repositories/branch-protection): require pull requests, reviews and checks, and [Checks and statuses](/docs/collaboration/checks-and-statuses) to report results from other CI systems.
259+- [Releases](/docs/collaboration/releases) and [Environments](/docs/actions/environments): ship versions and deploy them.
260+- [Organizations](/docs/accounts/organizations) and [Invitations](/docs/accounts/invitations): bring your team.
261+- [Secrets and variables](/docs/actions/secrets-and-variables): give workflows credentials.
262+- [Workflow syntax](/docs/actions/workflow-syntax) and [Compatibility with GitHub Actions](/docs/actions/compatibility).
263+- [Agents](/docs/agents/overview) and [Sandboxes](/docs/agents/sandboxes): put AI agents to work safely.
264+- [REST API](/docs/reference/rest-api) and the [CLI reference](/docs/reference/cli).
addedapps/diffinery/docs/reference/limits.md+244−0View file
@@ -1,0 +1,244 @@
1+---
2+title: Limits
3+description: Every hard limit in Diffinery and where it applies: sign-in, tokens, API, search, archives, releases, git, pull requests, Actions, sandboxes, packages, email.
4+---
5+
6+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.
7+
8+Units: 1 KiB is 1,024 bytes, 1 MiB is 1,048,576 bytes, and 1 GB in the cache row 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.
9+
10+## Sign-in and sessions
11+
12+| Limit | Value | Applies to |
13+| --- | --- | --- |
14+| Browser session lifetime | 30 days, extended to 30 days from now on each day the session is used | 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. |
15+| Passkey browser prompt | 2 minutes | Time to complete the browser's passkey dialog when signing in or adding a passkey. |
16+| Passkey sign-in attempt | 5 minutes | A sign-in or registration that started more than 5 minutes ago can't be completed; start again. |
17+| Sign-in link lifetime | 24 hours, single use | Links a site administrator sends with **Sign-in link** in **Settings → People and invitations**. |
18+| Invitation lifetime | 14 days | Invitations to the site, an organization or a repository. |
19+| 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. |
20+| Username when joining from an invite | 39 characters | Letters, digits, `-` and `_`. Some names are reserved (for example `api`, `settings`, `login`, `admin`, `docs`). |
21+| Display name when joining | 100 characters | Longer names are cut off. |
22+| Passkey name | 100 characters | Longer names are cut off. |
23+| 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. |
24+| 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. |
25+
26+## Tokens
27+
28+| Limit | Value | Applies to |
29+| --- | --- | --- |
30+| Token lifetime | 1 to 366 days, default 90 | Personal access tokens and agent tokens. Values outside the range are clamped. |
31+| 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`. |
32+| Expiry reminder email | 7 days before expiry | Sent once per token, and only for tokens that were valid for more than 8 days. |
33+| "Last used" accuracy | 1 hour | A token's last-used time is updated at most once an hour. |
34+| Creating tokens through the API | `admin` scope or a browser session | `POST /api/v3/diffinery/tokens` with a `read` or `write` token returns `403 creating tokens requires an admin-scoped token or a browser session`. |
35+| 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. |
36+| Writes with a `read` token | Refused | Every change returns `403 this token needs the write scope`. Git fetch and Git LFS downloads still work. |
37+| 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`). |
38+
39+## REST API
40+
41+| Limit | Value | Applies to |
42+| --- | --- | --- |
43+| Rate limits | None | Diffinery enforces no request rate limits. Responses report a constant budget of 15,000 (`X-RateLimit-*` headers and `GET /api/v3/rate_limit`). |
44+| Request body size | 100 MB | Cloudflare refuses larger request bodies before they reach Diffinery. |
45+| 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. |
46+| Workflow runs per page | 25 by default, 100 at most | `GET /repos/{owner}/{repo}/actions/runs`. |
47+| 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`. |
48+| Refs per page | 100 by default | `GET .../git/refs` and `.../git/matching-refs/{ref}`. |
49+| Environment variables per page | 10 by default, 100 at most | `GET .../environments/{env}/variables`. |
50+| 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. |
51+| Package versions per page | 30 by default, 100 at most | `GET .../packages/container/{name}/versions`. |
52+| Repository lists | 500 repositories | `GET /user/repos`, `/users/{login}/repos`, `/orgs/{org}/repos` page over at most 500 repositories (most recently pushed first). |
53+| Notifications | 100 | `GET /api/v3/notifications` returns your 100 most recent (unread unless `all=true`). |
54+| Email log | 50 by default, 500 at most | `GET /api/v3/diffinery/emails?per_page=`. |
55+| 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. |
56+| Recursive tree listing | 100,000 entries | `GET .../git/trees/{sha}?recursive=1` stops there and returns `truncated: true`. |
57+| Entries changed by one tree request | 10,000 | `POST .../git/trees` (`a tree request can change at most 10000 entries`). |
58+| 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. |
59+| Topics | 20 per repository, 50 characters each | `PUT .../topics`. Lowercase letters, digits and hyphens, starting with a letter or digit. |
60+| 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. |
61+| Assignees | 10 per issue or pull request | `an issue can have at most 10 assignees`. |
62+| `repository_dispatch` | `event_type` up to 100 characters; `client_payload` up to 10 top-level properties and 64 KiB | `POST .../dispatches`. |
63+| Check run name | 255 characters | `POST .../check-runs`. |
64+| Deployment `required_contexts` | 100 contexts | `POST .../deployments`. |
65+| Environment name | 1 to 255 characters | No `/`, `\` or control characters. |
66+
67+## Search
68+
69+| Limit | Value | Applies to |
70+| --- | --- | --- |
71+| Query length | 256 characters | `q` of `GET /search/issues` and `/search/repositories`. Longer queries fail with `422 The search is longer than 256 characters.` |
72+| 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`. |
73+| 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. |
74+| Repositories a repository search checks | 300 | Candidates checked for access, out of the same up to 500. More sets `incomplete_results: true`. |
75+| Issues scanned per repository | 5,000 | The most recent issues and pull requests in each repository. |
76+| Comments read for `in:comments` | 2,000 per repository | The most recent comments. |
77+| Issue search box in the web UI | 200 characters | The search query on the issues and pull requests pages. |
78+
79+## Source archives
80+
81+| Limit | Value | Applies to |
82+| --- | --- | --- |
83+| 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`. |
84+| 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`. |
85+| Directory depth | 128 levels | Deeper trees fail with `413 the tree is nested too deeply`. |
86+| Files in a zip | 65,535 | More fails with `413`; download the `.tar.gz` archive instead. |
87+| 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`. |
88+| Cache | Not guaranteed | A built archive is cached in R2 and can be evicted at any time; it is then built again. |
89+
90+## Releases
91+
92+| Limit | Value | Applies to |
93+| --- | --- | --- |
94+| 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`). |
95+| 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`). |
96+| Files per web form submission | 100 MB in total | The release form in the web UI. Upload larger files with the REST API. |
97+| Asset name | 1 to 255 characters | After unusual characters are replaced with `.`. Each name once per release. |
98+| Release notes | 125,000 characters | `body` of a release. |
99+| Releases listed through MCP | 100 | `list_releases` (`limit`, default 30). |
100+
101+## Commit statuses and checks
102+
103+| Limit | Value | Applies to |
104+| --- | --- | --- |
105+| Statuses per commit and context | 1,000 | `422 this SHA and context has reached the maximum number of statuses`. |
106+| Status context | 255 characters | Longer contexts are cut. Without `context`, it is `default`. |
107+| Status description | 1,024 characters | Longer descriptions are cut. |
108+
109+## Profile pictures
110+
111+| Limit | Value | Applies to |
112+| --- | --- | --- |
113+| 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.`). |
114+| Image size | 3072×3072 pixels | Larger images are refused. |
115+| Formats | JPEG, PNG and WebP | Other files fail with `Use a JPEG, PNG or WebP image.` |
116+| 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. |
117+
118+## Git
119+
120+| Limit | Value | Applies to |
121+| --- | --- | --- |
122+| 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). |
123+| 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`. |
124+| Secret scanning coverage | New blobs smaller than 1 MiB | Push protection scans every new file under 1 MiB in a push. The rejection message lists up to 10 findings. |
125+| 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. |
126+
127+## Git LFS
128+
129+| Limit | Value | Applies to |
130+| --- | --- | --- |
131+| 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. |
132+| Upload and download links | 1 hour | The `expires_in` of the transfer links the batch API returns. |
133+| Object size | 100 MB | Each object is uploaded in one request, and Cloudflare refuses request bodies over 100 MB. |
134+
135+## Pull requests, diffs and the web UI
136+
137+| Limit | Value | Applies to |
138+| --- | --- | --- |
139+| 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. |
140+| 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."). |
141+| 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. |
142+| 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. |
143+| Markdown source | 512 KiB | Longer Markdown (READMEs, issue and pull request bodies, comments) is shown as plain preformatted text. |
144+| Markdown nesting | 12 levels | Deeper nested lists and quotes are shown as plain text. |
145+| Markdown inline run | 60,000 characters | A longer paragraph is shown without inline formatting. |
146+| Web list pages | Page 1,000 | Paginated lists in the web UI stop at page 1,000. |
147+| 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. |
148+| Web form size | 10 MB | Forms in the web UI, except the release form (100 MB of files) and profile pictures (5 MB). |
149+| Required approvals | 0 to 10 | The **Required approvals** setting of a branch protection rule in the repository settings page. |
150+| Assignees per edit | 10 | Assigning people from the issue or pull request sidebar. |
151+| Label name and description | 50 and 100 characters | Labels created in the web UI. |
152+| Merge queue build concurrency | 1 to 20, default 5 | How many merge groups a queue builds and tests at the same time (`mergeQueue.buildConcurrency`). |
153+| 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`). |
154+
155+## Actions
156+
157+| Limit | Value | Applies to |
158+| --- | --- | --- |
159+| 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." |
160+| Step timeout | No maximum beyond the job's | `steps[*].timeout-minutes`. |
161+| 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. |
162+| Jobs in one matrix | 256 | More combinations fail the job with "A matrix may produce at most 256 jobs." |
163+| Reusable workflow nesting | 4 levels | "reusable workflows can be nested at most 4 levels deep" |
164+| Reusable workflows per run | 20 calls | "a run can call at most 20 reusable workflows" |
165+| `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). |
166+| 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. |
167+| Starting a job | 3 retries | Starting a job's container is retried 3 times with exponential backoff from 10 seconds before the job fails with "Could not start a runner". |
168+| Hosted runner size | Cloudflare Containers `standard-2` | Each `runs-on: ubuntu-latest` job runs in its own container of this instance type. |
169+| Hosted job containers at once | 100 | `max_instances` of the runner container class. |
170+| Jobs during a deploy | 14 minutes | When Diffinery is redeployed, a running hosted job keeps going for up to 14 minutes after its container is asked to stop, then is cancelled. |
171+| Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. |
172+| Log line length | 16,384 characters | Longer lines are cut and end with `…`. |
173+| Log shown in the web UI | 20,000 lines | The job page shows the first 20,000 lines and links to the full log. |
174+| OIDC token lifetime | 10 minutes | ID tokens from `permissions: id-token: write`. |
175+
176+## Caches and artifacts
177+
178+| Limit | Value | Applies to |
179+| --- | --- | --- |
180+| Cache storage per repository | 10 GB | When saving a cache pushes the total over 10 GB, the least recently used entries are deleted until it fits. |
181+| Cache key length | 512 characters | Longer keys fail with `cache key too long`. |
182+| Saving an existing cache key | Not allowed | Cache entries are immutable; saving a key that exists is skipped (`409 cache entry already exists`). |
183+| Cache entry and artifact size | 100 MB compressed | Each cache entry and each artifact is uploaded as one compressed archive in one request, and Cloudflare refuses request bodies over 100 MB. |
184+
185+## Agent sandboxes
186+
187+| Limit | Value | Applies to |
188+| --- | --- | --- |
189+| Sandbox size | Cloudflare Containers `standard-1` | Each sandbox runs in its own container of this instance type. |
190+| Sandboxes at once | 50 | `max_instances` of the sandbox container class, across the whole instance. |
191+| 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`. |
192+| Idle time | 30 minutes | A sandbox that receives no requests for 30 minutes is stopped. |
193+| 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`. |
194+| Command output | 1 MiB each for stdout and stderr | Longer output is cut and the response has `truncated: true`. |
195+| Request body | 64 MiB | Larger `exec`, `read` or `write` requests fail with `413 request body too large`. |
196+| File read | 64 MiB | Reading a larger file fails with `413 file too large`. |
197+
198+## Container registry
199+
200+| Limit | Value | Applies to |
201+| --- | --- | --- |
202+| 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. |
203+| Manifest size | 4 MiB | Larger manifests fail with `413 MANIFEST_INVALID: manifests are limited to 4 MiB`. |
204+| Image name | 255 characters | `<owner>/<name>`, lowercase letters, digits, `.`, `_`, `-` and `/`. |
205+| Tags per tag-list page | 10,000 | `GET /v2/<owner>/<name>/tags/list?n=`. |
206+| Packages listed per owner | 1,000 | `GET /api/v3/users/{owner}/packages`, `/orgs/{owner}/packages`, `/user/packages`. |
207+
208+## Webhooks and events
209+
210+| Limit | Value | Applies to |
211+| --- | --- | --- |
212+| Webhook URL | `https://` only | Other schemes fail with `webhook URL must use https`. |
213+| Delivery timeout | 10 seconds | A receiver that takes longer counts as failed (recorded status `0`). |
214+| Redirects | Not followed | A `3xx` answer is recorded as the delivery's status. |
215+| 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](/docs/repositories/webhooks#retries). |
216+| 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`. |
217+
218+## Email
219+
220+| Limit | Value | Applies to |
221+| --- | --- | --- |
222+| 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. |
223+| 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. |
224+| Message body | 20,000 characters | Longer issue, pull request or comment text in an email is cut and ends with `…`. |
225+| Subject | 900 characters | |
226+| Commits in a push email | 10 | |
227+| Jobs in a workflow run email | 20 | |
228+| Line comments in a review email | 20 | |
229+| Mentions per event | 60 | Only the first 60 distinct `@mentions` in one comment or description are notified. |
230+| Recipients per event | 200 | At most 200 people are notified about one event, in the app or by email. |
231+
232+## Platform limits for operators
233+
234+These come from `apps/diffinery/wrangler.jsonc` and matter when you run your own instance (see [Configuration](/docs/self-hosting/configuration)).
235+
236+| Limit | Value | Applies to |
237+| --- | --- | --- |
238+| Worker CPU time | 300,000 ms per request | `limits.cpu_ms`. |
239+| Runner containers | `standard-2`, up to 100 instances | `containers[RunnerContainer]`. |
240+| Sandbox containers | `standard-1`, up to 50 instances | `containers[SandboxContainer]`. |
241+| Container rollout grace period | 3,600 seconds | `rollout_active_grace_period` for both container classes. |
242+| Event queue | Batches of up to 50 messages (2 seconds), 8 retries | `diffinery-events`, dead-letter queue `diffinery-events-dlq`. |
243+| 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. |
244+| Cron trigger | Every 5 minutes | `*/5 * * * *`: scheduled workflows, token expiry reminders, email log pruning and container registry cleanup. |
addedapps/diffinery/docs/reference/rest-api.md+749−0View file
@@ -1,0 +1,749 @@
1+---
2+title: REST API
3+description: How to call Diffinery's GitHub-compatible REST API at /api/v3: authentication, IDs, pagination, errors, search, archives and what works by area.
4+---
5+
6+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](/docs/api), also available as an OpenAPI 3.1 document at [/docs/api/openapi.json](/docs/api/openapi.json).
7+
8+## Base URL
9+
10+Every REST endpoint lives under one base URL:
11+
12+```text
13+https://diffinery.com/api/v3
14+```
15+
16+A `GET` on the base URL itself needs no authentication and describes the API:
17+
18+```sh
19+curl https://diffinery.com/api/v3
20+```
21+
22+```json
23+{ "name": "Diffinery", "api": "v3", "docs": "https://diffinery.com/docs/api", "mcp": "https://diffinery.com/mcp" }
24+```
25+
26+`GET /api/v3/meta` returns `{"name": "Diffinery", "oidc": true}`, where `oidc` says whether this instance can issue OpenID Connect tokens to workflow jobs.
27+
28+Some related HTTP interfaces are not under `/api/v3`:
29+
30+| Interface | Path | Documented in |
31+| --- | --- | --- |
32+| Git smart HTTP and Git LFS | `https://diffinery.com/<owner>/<repo>.git` | [Git](/docs/repositories/git), [Git LFS](/docs/repositories/git-lfs) |
33+| Release asset uploads (the `upload_url` of a release) | `https://diffinery.com/api/uploads/repos/<owner>/<repo>/releases/<id>/assets` | [Release assets](#release-assets) |
34+| Release asset downloads (`browser_download_url`) | `https://diffinery.com/<owner>/<repo>/releases/download/<tag>/<name>` | [Release assets](#release-assets) |
35+| Source archives for browsers | `https://diffinery.com/<owner>/<repo>/archive/<ref>.zip` and `.tar.gz` | [Archives](#archives) |
36+| Profile pictures | `https://diffinery.com/api/avatars/<login>` | [Diffinery-only endpoints](#diffinery-only-endpoints) |
37+| MCP server for agents | `https://diffinery.com/mcp` | [MCP](/docs/agents/mcp) |
38+| Container registry (OCI distribution API) | `https://diffinery.com/v2/` | [Container registry](/docs/packages/container-registry) |
39+| OIDC discovery for workflow tokens | `/.well-known/openid-configuration`, `/.well-known/jwks` | [OIDC](/docs/actions/oidc) |
40+| Health check | `/healthz` returns `{"ok": true}` | |
41+
42+## Compatibility with GitHub
43+
44+Point a GitHub client at `https://diffinery.com/api/v3` and it works:
45+
46+```js
47+import { Octokit } from "@octokit/rest";
48+
49+const octokit = new Octokit({ auth: process.env.DIFFINERY_TOKEN, baseUrl: "https://diffinery.com/api/v3" });
50+```
51+
52+What matches GitHub:
53+
54+- **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).
55+- **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`.
56+- **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).
57+- **Account types.** A user object's `type` is `User`, `Organization` or `Bot`. Agents are `Bot`.
58+- **Pagination.** Lists take `per_page` and `page` and send a `Link` header, so `octokit.paginate()` works. See [Pagination](#pagination).
59+- **Errors.** Error bodies have `message` and `documentation_url`, and validation errors add GitHub's `errors` array. See [Errors](#errors).
60+- **Headers.** API responses carry `X-GitHub-Media-Type: github.v3; format=json`, `X-GitHub-Api-Version-Selected: 2022-11-28` and `X-RateLimit-*` headers.
61+- **Aliases.** Collaborator permissions accept GitHub's `pull` and `push` for `read` and `write`, and `private: false` on a repository makes it public.
62+
63+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:
64+
65+| Object | Extra fields |
66+| --- | --- |
67+| Pull request (`GET /pulls/{n}`) | `merge_blockers` (why it can't merge yet), `approvals`, `required_approvals`, `checks`, `merge_queue` |
68+| Pull request (every response) | `intent`, a short statement of what the change is for, written for reviewers |
69+| Repository (`GET /repos/{owner}/{repo}`) | `stats` (counts of open issues and pull requests and more), and `permissions.role` |
70+| Workflow run | `sha` and `ref` (what the run checked out: the test merge commit for `pull_request` runs) and `pull_request_number` |
71+| Workflow job | Diffinery's own camelCase job fields next to GitHub's snake_case ones |
72+| Check run | `run_id` and `job_id` of the workflow behind an Actions check |
73+| Comparison | `base_sha`, `head_sha` and `merge_base` |
74+| Release | `latest`, whether it is the repository's latest release |
75+| Pull request file | `binary` |
76+
77+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`, `has_wiki: false`, `milestone: null` and `auto_merge: null`.
78+
79+### IDs
80+
81+| Object | `id` |
82+| --- | --- |
83+| 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. |
84+| Issues and pull requests | The `id` equals the `number` (issues and pull requests share one number sequence per repository). |
85+| 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}`. |
86+| Workflow runs and jobs | 32-character hexadecimal strings. Use them as they are in `/actions/runs/{id}` and `/actions/jobs/{id}`. |
87+| Workflows | The workflow's path, such as `.github/workflows/ci.yml`. `/actions/workflows/{id}` also accepts the file name, `ci.yml`. |
88+| Webhooks and tokens | Strings. |
89+
90+`node_id` is an opaque string, stable for an object. Diffinery has no GraphQL API, so nothing accepts a `node_id` as input.
91+
92+### Differences to plan for
93+
94+| Area | GitHub | Diffinery |
95+| --- | --- | --- |
96+| 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. |
97+| 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). |
98+| 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](/docs/collaboration/merge-queue). |
99+| 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. |
100+| 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. |
101+| 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. |
102+| 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). |
103+| Rate limits | Enforced | Not enforced; a constant budget is reported. See [Rate limits](#rate-limits). |
104+| 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. |
105+
106+## Authentication
107+
108+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`), with `diffinery token create`, or with `POST /api/v3/diffinery/tokens` (see [Personal access tokens](/docs/accounts/tokens)).
109+
110+Send the token in the `Authorization` header. Three schemes are accepted, so any GitHub client works:
111+
112+| Scheme | Example | Typical sender |
113+| --- | --- | --- |
114+| `Bearer` | `Authorization: Bearer dfy_...` | curl, fetch, MCP clients, the Diffinery CLI |
115+| `token` | `Authorization: token dfy_...` | Octokit and other GitHub libraries |
116+| `Basic` | `Authorization: Basic base64(octocat:dfy_...)` | git, `curl -u`, Docker |
117+
118+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.
119+
120+```sh
121+curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/user
122+curl -u octocat:dfy_... https://diffinery.com/api/v3/user
123+```
124+
125+Inside a workflow job, the job's `GITHUB_TOKEN` can read its own repository through the API, with the read role.
126+
127+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.
128+
129+Authentication errors:
130+
131+| Status | `message` | Cause and fix |
132+| --- | --- | --- |
133+| `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. |
134+| `401` | `unrecognised token; create one under Settings → Access tokens` | The credential does not start with `dfy_`. GitHub tokens (`ghp_...`) do not work here. |
135+| `401` | `authentication required` | You called a private resource anonymously, or tried to change something in a public repository without a token. Add the `Authorization` header. |
136+| `401` | `sign in required` | The endpoint needs an account (for example `GET /user`) and you sent no credentials. |
137+| `403` | `your account is waiting for an invite` | Your account exists but no one has invited you yet. Ask an administrator for an invite. |
138+| `403` | `your account is disabled` | A site administrator disabled the account. |
139+
140+Every `401` response carries `WWW-Authenticate: Basic realm="Diffinery", charset="UTF-8"`, which makes git and other HTTP clients prompt for credentials.
141+
142+### Token scopes
143+
144+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:
145+
146+| Scope | Highest repository role the token can use | Typical use |
147+| --- | --- | --- |
148+| `read` | read | Clone, fetch, read issues, pull requests, releases and runs. Never changes anything. |
149+| `write` (the default) | maintain | Push, open and merge pull requests, comment, create releases, report statuses and check runs, rerun workflows. |
150+| `admin` | admin | Everything the account can do, including repository settings, secrets, environments, webhooks, branch protection and creating more tokens. |
151+
152+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](/docs/accounts/permissions) for what each role allows.
153+
154+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.
155+
156+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`).
157+
158+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`.
159+
160+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.
161+
162+| Endpoint | Needs |
163+| --- | --- |
164+| `POST /api/v3/user/repos`, `POST /api/v3/orgs/{org}/repos` | A `write` or `admin` token not restricted to repositories. |
165+| `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). |
166+| `PUT` and `DELETE /api/v3/repos/{owner}/{repo}/subscription` | A `write` or `admin` token (`403 this needs a write or admin token`). |
167+| `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`). |
168+| `POST /api/v3/diffinery/tokens` | An `admin` token (`403 creating tokens requires an admin-scoped token or a browser session` otherwise). |
169+| `DELETE /api/v3/diffinery/tokens/{id}` | An `admin` token not restricted to repositories. |
170+| `POST /api/v3/user/orgs` | An `admin` token not restricted to repositories. |
171+| `POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations` | An `admin` token not restricted to repositories. |
172+| `GET`, `PUT` and `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | An `admin` token not restricted to repositories. |
173+| `POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens`, `DELETE /api/v3/diffinery/agents/{login}` | An `admin` token not restricted to repositories. |
174+| `POST /api/v3/diffinery/admin/emails/samples` | A site administrator with an `admin` token not restricted to repositories. |
175+
176+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`.
177+
178+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](/docs/agents/overview#guardrails).
179+
180+### Browser sessions and tokens
181+
182+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).
183+
184+Because the cookie is sent automatically by the browser, Diffinery refuses state-changing requests that rely on it from another site:
185+
186+- The check applies to every method except `GET`, `HEAD` and `OPTIONS`, and only when the request has no `Authorization` credential.
187+- If the browser sends `Sec-Fetch-Site` and its value is anything other than `same-origin` or `none`, the request is refused.
188+- 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.
189+- A refused request gets `403` with `{"message": "cross-site request refused"}`.
190+
191+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.
192+
193+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.
194+
195+## Read-your-writes and the x-d1-bookmark header
196+
197+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:
198+
199+```http
200+x-d1-bookmark: 00000085-0000024c-00004c6d-8e61117bf38d7adb71b934ebbf891683
201+```
202+
203+How a request picks which data it reads:
204+
205+| Caller | Reads data that is | Sees its own earlier writes? |
206+| --- | --- | --- |
207+| 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 |
208+| Token request without the header | The latest | Yes, always (at the cost of a longer round trip) |
209+| 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 |
210+| Request without credentials or bookmark | From the nearest copy | Not guaranteed |
211+
212+For API clients this means:
213+
214+- **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.
215+- **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.
216+- **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.
217+- 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.
218+
219+```sh
220+# Create a repository, then list repositories from a copy that already has it.
221+bm=$(curl -sS -D - -o /dev/null -X POST https://diffinery.com/api/v3/user/repos \
222+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
223+ --data '{"name":"website","private":true}' | tr -d '\r' | sed -n 's/^x-d1-bookmark: //Ip')
224+curl -sS https://diffinery.com/api/v3/user/repos \
225+ -H "Authorization: Bearer dfy_..." -H "x-d1-bookmark: $bm"
226+```
227+
228+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.
229+
230+## Requests and content types
231+
232+- 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.
233+- 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`).
234+- 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.
235+- 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`).
236+- Release asset uploads send the file itself as the request body (not JSON). See [Release assets](#release-assets).
237+
238+Responses:
239+
240+| Endpoint | Content type |
241+| --- | --- |
242+| Almost everything | `application/json` |
243+| `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` |
244+| `GET /repos/{owner}/{repo}/raw/{ref}/{path}` | `text/plain; charset=utf-8` for text files, `application/octet-stream` for binary files |
245+| `GET /repos/{owner}/{repo}/zipball/{ref}`, `/tarball/{ref}` | `application/zip` or `application/gzip`, as a download (see [Archives](#archives)) |
246+| `GET /repos/{owner}/{repo}/releases/assets/{id}` with `Accept: application/octet-stream` | The asset's bytes, as a download |
247+| `GET /repos/{owner}/{repo}/actions/runs/{id}/logs` | `application/zip`, one text file per job |
248+| `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. |
249+| `DELETE` endpoints and some `PUT` endpoints | No body (`204`) |
250+
251+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. Downloads (raw files, archives and release assets) are sent with `Content-Security-Policy: sandbox` and as attachments, so browsers never run them as pages.
252+
253+## Errors
254+
255+Every API error is a JSON object with a `message`, a `documentation_url` and an HTTP status code, like GitHub's:
256+
257+```http
258+HTTP/1.1 404 Not Found
259+content-type: application/json
260+
261+{"message": "repository not found", "documentation_url": "https://docs.github.com/rest"}
262+```
263+
264+`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:
265+
266+```json
267+{
268+ "message": "title is required",
269+ "documentation_url": "https://docs.github.com/rest",
270+ "errors": [{ "resource": "Issue", "code": "missing_field", "field": "title" }]
271+}
272+```
273+
274+Unexpected server errors return `500` with `{"message": "internal error"}`; the details go to the instance's logs, not to the client.
275+
276+Write your client to branch on the status code and show `message` to people. Messages are written for humans and may change wording.
277+
278+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:
279+
280+```json
281+{ "message": "Not Found", "documentation_url": "https://docs.github.com/rest" }
282+```
283+
284+### Status codes
285+
286+| Status | Meaning in Diffinery | Examples of `message` |
287+| --- | --- | --- |
288+| `200 OK` | Success with a body. Also returned when you add a reaction you had already added. | |
289+| `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. | |
290+| `202 Accepted` | Work started in the background: `POST .../actions/runs/{id}/cancel`, `PUT .../pulls/{n}/update-branch`. | |
291+| `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. | |
292+| `205 Reset Content` | `PUT /api/v3/notifications`: every notification marked as read. | |
293+| `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` |
294+| `401 Unauthorized` | No credentials where they are needed, or a bad token. | See [Authentication](#authentication). |
295+| `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` |
296+| `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` |
297+| `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` |
298+| `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` |
299+| `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` |
300+| `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` |
301+| `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` |
302+| `500 Internal Server Error` | A bug or an outage. | `internal error` |
303+| `503 Service Unavailable` | A sandbox container didn't start. | `the sandbox container did not start; try again` |
304+
305+Most writes to an archived repository return `403 repository is archived (read-only)`. Repository administration (collaborators, branch protection, webhooks, secrets, variables) still works, so an admin can unarchive it with `PATCH /repos/{owner}/{repo}` and `{"archived": false}`. Opening and editing issues, editing pull requests, adding and editing comments, submitting reviews and requesting reviewers don't check the archived state yet; see [Archive a repository](/docs/repositories/overview#archive-a-repository).
306+
307+## Pagination
308+
309+Lists take GitHub's `page` and `per_page` query parameters and answer with a `Link` header that points at the other pages:
310+
311+```http
312+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"
313+```
314+
315+- `page` starts at 1. `per_page` is 30 by default and at most 100; larger values are treated as 100.
316+- 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`.
317+- Follow `rel="next"` until it is missing. That is what `octokit.paginate()` and `gh api --paginate` do.
318+- Paging over a list that changes while you read it can skip or repeat items, as on GitHub.
319+
320+Lists with other defaults or without the header:
321+
322+| Endpoint | Default `per_page` | Notes |
323+| --- | --- | --- |
324+| `GET /repos/{owner}/{repo}/issues` and `/pulls` | 30 | Also sends `x-total-count` with the number of matching items. |
325+| `GET /repos/{owner}/{repo}/actions/runs` | 25 | `total_count` in the body. |
326+| `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. |
327+| `GET /repos/{owner}/{repo}/compare/{base}...{head}` (its `commits`), `GET .../pulls/{n}/commits` | 100 | At most 250 commits in total, as on GitHub. |
328+| `GET .../git/refs`, `.../git/matching-refs/{ref}` | 100 | |
329+| `GET .../commits/{ref}/status` (its `statuses`) | 100 | |
330+| `GET .../environments/{env}/variables` | 10 | As on GitHub. |
331+| `GET /search/issues`, `/search/repositories` | 30 | Only the first 1,000 results can be paged to. |
332+| `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`. |
333+| `GET /api/v3/diffinery/emails` | 50 | At most 500, no `page` parameter. |
334+
335+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).
336+
337+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](/docs/reference/limits) for every cap.
338+
339+Filters on the issue and pull request lists:
340+
341+| Parameter | Meaning |
342+| --- | --- |
343+| `state` | `open` (default), `closed` or `all` |
344+| `labels` | Comma-separated label names; an item must have all of them |
345+| `creator` | The author's login |
346+| `assignee` | A login, `none` (no assignee) or `*` (any assignee) |
347+| `since` | Only items updated at or after this ISO 8601 time |
348+| `head`, `base` | Pull requests from or into this branch (`head` also accepts `owner:branch`) |
349+| `sort`, `direction` | `created` (default), `updated` or `comments` (`popularity` on pulls); `desc` (default) or `asc` |
350+| `q` | Diffinery: text in the title or body |
351+
352+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`.
353+
354+## Rate limits
355+
356+Diffinery doesn't meter API calls. To keep clients that check their budget working, every response under `/api/v3` carries a constant, generous budget:
357+
358+```http
359+x-ratelimit-limit: 15000
360+x-ratelimit-remaining: 15000
361+x-ratelimit-used: 0
362+x-ratelimit-reset: 1791887400
363+x-ratelimit-resource: core
364+```
365+
366+`GET /api/v3/rate_limit` answers like GitHub's, with `resources.core`, `search`, `graphql`, `code_search` and others, and the same `rate`. `remaining` never goes down.
367+
368+Cloudflare's own platform limits still apply to each request (for example the maximum request body size of 100 MB), and the operator of an instance can add Cloudflare rules in front of it. A few operations have budgets of their own: building new source archives (20 per repository per hour, `429` beyond that) is the only one counted per time window. Write clients that back off and retry on `429`, `503` and network errors anyway, so they keep working if limits are added later. Size and count limits, such as 100 MB per git push or 256 jobs per matrix, are listed in [Limits](/docs/reference/limits).
369+
370+## What works, by area
371+
372+Each area lists what you can do; the [API reference](/docs/api) has every endpoint with its fields. Unless noted, reading needs read access to the repository and changing needs write access.
373+
374+### Repositories
375+
376+- 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.
377+- 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.
378+- `GET /languages`: bytes per language, by file extension, leaving out vendored directories such as `node_modules`, `vendor` and `dist`.
379+- Collaborators: list, add or change (`PUT` with `permission`), remove (admin).
380+- 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`.
381+- `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`).
382+- `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`.
383+- Archived repositories are read-only through the API, for everyone. See [Errors](#errors).
384+
385+### Contents
386+
387+- `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.
388+- `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.
389+- `DELETE /contents/{path}` removes a file with a commit; `sha` is required.
390+- 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`.
391+
392+### Git data
393+
394+- 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`).
395+- 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.
396+- One tree request can change at most 10,000 entries.
397+
398+### Archives
399+
400+`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`.
401+
402+- 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.
403+- 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`.
404+- 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.
405+- Each repository can build 20 new archives per hour. Downloads of cached archives don't count. Past that you get `429`.
406+- Entries with unsafe names (see [Contents](#contents)) are left out, and submodules appear as empty directories.
407+
408+Clone bigger repositories with git.
409+
410+### Releases
411+
412+- 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.
413+- 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.
414+- `make_latest` (`true`, `false` or `legacy`), `prerelease`, `draft`, `generate_release_notes`. The body can be up to 125,000 characters.
415+- `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.
416+- 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.
417+- Reactions work on releases too.
418+
419+See [Releases](/docs/collaboration/releases) for the web UI and the full model.
420+
421+### Release assets
422+
423+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:
424+
425+```sh
426+curl -X POST "https://diffinery.com/api/uploads/repos/acme/website/releases/42/assets?name=website-1.4.0.tar.gz&label=Source" \
427+ -H "Authorization: Bearer dfy_..." \
428+ -H "Content-Type: application/gzip" \
429+ --data-binary @website-1.4.0.tar.gz
430+```
431+
432+- 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.
433+- 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.
434+- `GET /releases/{id}/assets` lists assets; `GET`, `PATCH` (`name`, `label`) and `DELETE /releases/assets/{id}` manage one.
435+- 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.
436+
437+### Commit statuses, checks and required checks
438+
439+- **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.
440+- **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`).
441+- **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.
442+- **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.
443+
444+See [Checks and commit statuses](/docs/collaboration/checks-and-statuses) and [Branch protection](/docs/repositories/branch-protection).
445+
446+### Issues and pull requests
447+
448+- Issues: list, create, get, update (including `state_reason`), 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`).
449+- 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`), 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`.
450+- `GET /pulls/{n}` reports `mergeable`, `mergeable_state` (`clean`, `blocked`, `dirty`, `unknown` and so on) and `rebaseable`, plus Diffinery's `merge_blockers`.
451+- **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 `converted_to_draft` event on the timeline and sends a `pull_request` webhook with the action `ready_for_review` or `converted_to_draft`.
452+- **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)`. A bypassed merge is recorded in the audit log as `pull_request.merge_bypassed`. `"bypass_merge_queue": true` merges past a merge queue and also bypasses unmet rules. `diffinery pr merge --admin` sends both.
453+- **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 bypasses), and a merge without `merge_method` uses `squash`.
454+- Changing an issue's or pull request's title or body sends an `issues` or `pull_request` webhook with the action `edited`.
455+- `PUT /pulls/{n}/update-branch` merges the base branch into the head branch (optional `expected_head_sha`) and returns `202`.
456+- `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.
457+- **Locked conversations** refuse new comments, review comments and reactions from people without triage access.
458+- **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.
459+- Comment, review, review comment, event and reaction IDs are numbers.
460+- Team reviewers (`team_reviewers`) are refused with `422 team reviewers are not supported; request people by login`.
461+
462+### Deployments and environments
463+
464+- **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`.
465+- **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 and wait timers are refused with `422 required reviewers and wait timers are not supported yet`.
466+- 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.
467+- 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.
468+
469+See [Environments](/docs/actions/environments).
470+
471+### Actions
472+
473+- 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), logs (a zip per run, or text per job), artifacts per run.
474+- 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), and environment secrets and variables.
475+- 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`.
476+
477+### Webhooks
478+
479+List, create (`config.url` must use `https`, `events` defaults to all) and delete repository webhooks (admin). 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](/docs/repositories/webhooks) and [Webhook events](/docs/reference/webhook-events).
480+
481+### Workflows started by API calls
482+
483+- A release starts `on: release` workflows (with `types:` matching the action, such as `published`). Drafts start nothing until they are published.
484+- `POST /repos/{owner}/{repo}/dispatches` starts `on: repository_dispatch` workflows (with `types:` matching `event_type`), with `client_payload` in `github.event.client_payload`.
485+- `POST /actions/workflows/{id}/dispatches` starts a `workflow_dispatch` run.
486+- As on GitHub, events caused by a job's `GITHUB_TOKEN` don't start new workflow runs, except `repository_dispatch`.
487+
488+See [Events that trigger workflows](/docs/actions/events).
489+
490+## Search
491+
492+`GET /search/issues` searches issues and pull requests, and `GET /search/repositories` searches repositories, with GitHub's query syntax in `q`:
493+
494+```sh
495+curl -sS -G https://diffinery.com/api/v3/search/issues \
496+ -H "Authorization: Bearer dfy_..." \
497+ --data-urlencode 'q=repo:acme/website is:pr is:open label:bug "contact sales"' \
498+ --data-urlencode 'sort=updated'
499+```
500+
501+The response is `{"total_count": ..., "incomplete_results": false, "items": [...]}`; items are issue or repository objects with `score: 1`.
502+
503+Search only covers repositories you can read. Anonymous callers search public repositories, and a workflow job's token searches its own repository.
504+
505+### Issue and pull request search
506+
507+| Qualifier | Meaning |
508+| --- | --- |
509+| `repo:owner/name` | Only this repository (repeat it for several) |
510+| `user:`, `org:`, `owner:` | Only repositories of this account |
511+| `is:issue`, `is:pr` (also `type:`) | Issues or pull requests |
512+| `is:open`, `is:closed` (also `state:`) | By state |
513+| `is:merged`, `is:unmerged` | Merged or unmerged pull requests |
514+| `is:draft`, `draft:true`, `draft:false` | Draft pull requests |
515+| `is:locked`, `is:unlocked` | Locked conversations |
516+| `label:bug` | Has this label. `label:a,b` and repeated `label:` qualifiers require every label. |
517+| `-label:wontfix` | Doesn't have this label |
518+| `no:label`, `no:assignee` | Has no labels, or no assignee |
519+| `author:octocat`, `-author:octocat` | Opened, or not opened, by this login |
520+| `assignee:octocat` | Assigned to this login |
521+| `head:branch`, `base:branch` | Pull requests from or into this branch |
522+| `in:title`, `in:body`, `in:comments` | Where words must match (default: title and body); combine as `in:title,body` |
523+| `created:`, `updated:`, `closed:` | Dates: `2026-10-01`, `>2026-10-01`, `>=`, `<`, `<=`, `2026-09-01..2026-09-30`, `2026-09-01..*` |
524+| `sort:created-desc` | `created`, `updated` or `comments`, with `-asc` or `-desc`. The `sort` and `order` query parameters override it. |
525+
526+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.
527+
528+### Repository search
529+
530+| Qualifier | Meaning |
531+| --- | --- |
532+| `user:`, `org:`, `owner:` | Repositories of this account |
533+| `repo:owner/name` | This repository |
534+| `topic:docs` | Has this topic (repeat for several) |
535+| `is:public`, `is:private` | By visibility |
536+| `archived:true`, `archived:false` | Archived or not |
537+| `in:name`, `in:description`, `in:topics` | Where words must match (default: all three) |
538+
539+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.
540+
541+### Search limits
542+
543+| Limit | Value |
544+| --- | --- |
545+| Query length | 256 characters (`422 The search is longer than 256 characters.`). An empty `q` returns `422 Validation Failed`. |
546+| Results you can page to | The first 1,000 (`422 Only the first 1000 search results are available`) |
547+| Repositories one issue search looks at | 50 readable repositories, 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:`. |
548+| Repositories one repository search checks | 300 candidates, from the same up to 500. More sets `incomplete_results`. |
549+| Issues scanned per repository | The 5,000 most recent issues and pull requests |
550+| Comments read for `in:comments` | The 2,000 most recent comments per repository |
551+
552+Code search and commit search are not supported yet.
553+
554+## Not supported yet
555+
556+- GraphQL (`/api/graphql`).
557+- Setting secrets with a sealed-box `encrypted_value`; send `value` instead.
558+- Environment protection rules: required reviewers and wait timers.
559+- Forks, and team reviewers.
560+- Code search and commit search. Search qualifiers for stars, forks and languages are accepted but ignored.
561+
562+An Octokit method for an endpoint Diffinery doesn't have fails, usually with `404`. Check the [API reference](/docs/api) before you reuse a GitHub script.
563+
564+## Diffinery-only endpoints
565+
566+Endpoints that have no GitHub equivalent live under `/api/v3/diffinery` (or `/repos/{owner}/{repo}/diffinery/...` when they belong to a repository):
567+
568+| Endpoint | What it does |
569+| --- | --- |
570+| `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"]`) and `expires_in_days`, and returns the token once. Creating and revoking need an `admin` token. |
571+| `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 an `admin` token not restricted to repositories. |
572+| `POST /diffinery/agents/{login}/tokens` | Create another token for an agent you own. Needs an `admin` token not restricted to repositories. |
573+| `DELETE /diffinery/agents/{login}` | Disable an agent and revoke its tokens. Needs an `admin` token not restricted to repositories. |
574+| `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`). Needs write access. |
575+| `POST /repos/{owner}/{repo}/diffinery/sandboxes` | Start a sandbox container with the repository checked out. See [Sandboxes](/docs/agents/sandboxes). |
576+| `POST /repos/{owner}/{repo}/diffinery/sandboxes/{id}/exec`, `/read`, `/write`, `/close` | Run a command, read or write a file, or stop the sandbox. |
577+| `GET`, `PUT /diffinery/notification-settings` | Your notification settings. `PUT` is a partial update: send only the groups you change. |
578+| `GET /diffinery/emails` | Emails Diffinery sent you recently (`per_page`, `kind`, `since`). Site administrators can add `all=1`. |
579+| `POST /diffinery/emails/test` | Send yourself a test email now. |
580+| `GET /diffinery/admin/email`, `POST /diffinery/admin/emails/samples` | Site administrators: email delivery status, and send every email template to yourself. |
581+
582+Other Diffinery additions sit next to the GitHub-shaped endpoints:
583+
584+- `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.
585+- `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 and admins). 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.
586+- `PUT`, `DELETE /repos/{owner}/{repo}/pulls/{n}/merge-queue` and `GET /repos/{owner}/{repo}/merge-queue/{branch}` drive the merge queue.
587+- `POST /repos/{owner}/{repo}/actions/runners/claim` is how self-hosted runners pick up jobs (see [Runners](/docs/actions/runners)).
588+- `GET /repos/{owner}/{repo}/raw/{ref}/{path}` returns a file's raw bytes.
589+- `GET /repos/{owner}/{repo}/packages` lists container images linked to a repository, and `PATCH` on a package changes its visibility or linked repository.
590+
591+```sh
592+curl -X PUT https://diffinery.com/api/v3/user/avatar \
593+ -H "Authorization: Bearer dfy_..." -H "Content-Type: image/png" --data-binary @me.png
594+```
595+
596+The [API reference](/docs/api) lists every endpoint with its request and response fields.
597+
598+## Using the API
599+
600+The examples below list open pull requests in `acme/website` and open a new issue. Replace `dfy_...` with your token.
601+
602+### curl
603+
604+```sh
605+export DIFFINERY_TOKEN=dfy_...
606+
607+# Who am I?
608+curl -sS https://diffinery.com/api/v3/user -H "Authorization: Bearer $DIFFINERY_TOKEN"
609+
610+# Open pull requests
611+curl -sS "https://diffinery.com/api/v3/repos/acme/website/pulls?state=open" \
612+ -H "Authorization: Bearer $DIFFINERY_TOKEN"
613+
614+# Open an issue
615+curl -sS -X POST https://diffinery.com/api/v3/repos/acme/website/issues \
616+ -H "Authorization: Bearer $DIFFINERY_TOKEN" \
617+ -H "content-type: application/json" \
618+ --data '{"title": "Broken link on the pricing page", "body": "The FAQ link returns 404.", "labels": ["bug"]}'
619+
620+# Report an external CI result on a commit
621+curl -sS -X POST https://diffinery.com/api/v3/repos/acme/website/statuses/9be41d07 \
622+ -H "Authorization: Bearer $DIFFINERY_TOKEN" -H "content-type: application/json" \
623+ --data '{"state": "success", "context": "ci/external", "target_url": "https://ci.example.com/builds/812"}'
624+```
625+
626+Add `-f` (`--fail`) in scripts so curl exits non-zero on `4xx` and `5xx`, or `--fail-with-body` to still print Diffinery's `message`.
627+
628+To follow the `Link` header in a shell script:
629+
630+```sh
631+url="https://diffinery.com/api/v3/repos/acme/website/issues?state=all&per_page=100"
632+while [ -n "$url" ]; do
633+ curl -fsS -D headers.txt "$url" -H "Authorization: Bearer $DIFFINERY_TOKEN" | jq -r '.[] | "#\(.number) \(.title)"'
634+ url=$(tr -d '\r' < headers.txt | sed -n 's/^link: .*<\([^>]*\)>; rel="next".*/\1/Ip')
635+done
636+```
637+
638+### fetch
639+
640+From Node.js 22 or later, Deno, Bun or a Cloudflare Worker:
641+
642+```js
643+const base = "https://diffinery.com/api/v3";
644+const headers = { authorization: `Bearer ${process.env.DIFFINERY_TOKEN}`, "content-type": "application/json" };
645+
646+async function api(method, path, body) {
647+ const res = await fetch(`${base}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
648+ const data = res.status === 204 ? null : await res.json().catch(() => null);
649+ if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${data?.message ?? res.statusText}`);
650+ return data;
651+}
652+
653+const pulls = await api("GET", "/repos/acme/website/pulls?state=open");
654+for (const pr of pulls) console.log(`#${pr.number} ${pr.title} (${pr.head.ref})`);
655+
656+const issue = await api("POST", "/repos/acme/website/issues", { title: "Broken link on the pricing page" });
657+console.log(issue.html_url);
658+```
659+
660+Browsers on other websites can't call the API: Diffinery sends no CORS headers.
661+
662+### Octokit
663+
664+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:
665+
666+```js
667+import { readFile } from "node:fs/promises";
668+import { Octokit } from "@octokit/rest";
669+
670+const octokit = new Octokit({ baseUrl: "https://diffinery.com/api/v3", auth: process.env.DIFFINERY_TOKEN });
671+const R = { owner: "acme", repo: "website" };
672+
673+const { data: repo } = await octokit.rest.repos.get(R);
674+console.log(repo.default_branch, repo.html_url);
675+
676+// Every open issue and pull request, following the Link header.
677+const all = await octokit.paginate(octokit.rest.issues.listForRepo, { ...R, state: "open", per_page: 100 });
678+console.log(`${all.length} open issues and pull requests`);
679+
680+await octokit.rest.issues.createComment({ ...R, issue_number: 42, body: "Looks good to me." });
681+await octokit.rest.pulls.merge({ ...R, pull_number: 42, merge_method: "squash" });
682+
683+// A release with an asset.
684+const { data: release } = await octokit.rest.repos.createRelease({ ...R, tag_name: "v1.4.0", generate_release_notes: true });
685+await octokit.rest.repos.uploadReleaseAsset({
686+ ...R,
687+ release_id: release.id,
688+ url: release.upload_url,
689+ name: "website-1.4.0.tar.gz",
690+ data: await readFile("website-1.4.0.tar.gz"),
691+ headers: { "content-type": "application/gzip" },
692+});
693+
694+// Errors carry status, message and documentation_url.
695+try {
696+ await octokit.rest.repos.getContent({ ...R, path: "missing.txt" });
697+} catch (e) {
698+ console.log(e.status, e.response.data.message); // 404 Not Found
699+}
700+```
701+
702+Things to know when you reuse GitHub code:
703+
704+- Use [Personal access tokens](/docs/accounts/tokens) or agent tokens from Diffinery, not GitHub tokens or GitHub App credentials.
705+- Methods for features Diffinery doesn't have (see [Not supported yet](#not-supported-yet)) fail, usually with `404` or `422`.
706+- `octokit.paginate()` doesn't work on `listWorkflowRunsForWorkflow` or on package versions, which send no `Link` header; page those with `page`.
707+- Workflow run and job IDs are strings. TypeScript types say `number`; pass the value you got back unchanged.
708+
709+### The Diffinery CLI
710+
711+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. It needs Node.js 22 or later:
712+
713+```sh
714+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
715+diffinery login --token dfy_...
716+```
717+
718+`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.
719+
720+```sh
721+diffinery api GET /repos/acme/website/pulls -f state=open
722+diffinery api POST /repos/acme/website/issues -f title="Broken link on the pricing page"
723+echo '{"event":"APPROVE","body":"Ship it"}' | diffinery api POST /repos/acme/website/pulls/42/reviews --input -
724+```
725+
726+The CLI reads `DIFFINERY_URL` and `DIFFINERY_TOKEN` from the environment before its config file, which is handy in CI. See the [CLI reference](/docs/reference/cli) for every command.
727+
728+## Versioning
729+
730+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](/docs/api) and [/docs/api/openapi.json](/docs/api/openapi.json) are generated from the running code, so they always describe the version of Diffinery that serves them.
731+
732+## Troubleshooting
733+
734+| Symptom | Cause and fix |
735+| --- | --- |
736+| `401 unrecognised token; create one under Settings → Access tokens` | The client sent a GitHub token or another credential. Use a `dfy_` token. |
737+| `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. |
738+| `404` from an Octokit method that works on GitHub | The endpoint isn't implemented (the body is `{"message": "Not Found", ...}`). Check the [API reference](/docs/api). |
739+| `401 authentication required` when opening an issue or commenting on a public repository | Anonymous callers can only read. Send a token. |
740+| `403 this token needs the write scope` | The token has only the `read` scope. Use a `write` token. |
741+| `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). |
742+| `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`. The bypass is audited. |
743+| `403 repository is archived (read-only)` | Unarchive the repository first (admin): `PATCH /repos/{owner}/{repo}` with `{"archived": false}`. |
744+| `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`. |
745+| `422 push protection: possible secret detected (...)` | The content looks like a credential. Remove it; see [Push protection](/docs/security/push-protection). |
746+| `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. |
747+| `413 release assets are limited to 100 MB` | Split the file, or host it elsewhere and link to it from the release notes. |
748+| `search` returns `incomplete_results: true` | More than 50 repositories matched. Add `repo:` or `org:` to the query. |
749+| A list stops after one page | The client doesn't follow `Link`. Use `octokit.paginate()` or loop on `rel="next"`. |
addedapps/diffinery/docs/reference/webhook-events.md+669−0View file
@@ -1,0 +1,669 @@
1+---
2+title: Webhook events
3+description: Reference of every webhook event and action Diffinery sends, with the exact payload fields and example payloads.
4+---
5+
6+This page lists every event a Diffinery [webhook](/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.
7+
8+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`, `repository_dispatch` and `workflow_dispatch` have GitHub's payload structure.
9+
10+## Common structure
11+
12+Every delivery is an HTTPS `POST` with a compact JSON body and these headers:
13+
14+| Header | Value |
15+|---|---|
16+| `X-GitHub-Event` | The event name from this page, for example `pull_request` |
17+| `X-Diffinery-Event` | The same event name |
18+| `X-GitHub-Delivery` | A unique delivery id (32 hexadecimal characters). Retries of a failed delivery keep the same id. |
19+| `X-Hub-Signature-256` | `sha256=` and the hex HMAC-SHA256 of the body with the webhook's secret |
20+| `User-Agent` | `Diffinery-Hookshot/1` |
21+| `Content-Type` | `application/json` |
22+
23+Every payload contains:
24+
25+| Field | Type | Description |
26+|---|---|---|
27+| `action` | string | The action, for events that have one. Absent for `push`, `status` and `workflow_dispatch`. |
28+| `at` | string | ISO 8601 time the event happened. In the small payloads and in `workflow_dispatch`; `release`, `status`, `deployment`, `deployment_status` and `repository_dispatch` have no `at` (use their own `created_at` and `updated_at` fields). |
29+| `repository` | object | The repository (see below) |
30+| `sender` | object | The account that caused the event (see below) |
31+
32+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](/docs/repositories/webhooks#retries).
33+
34+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.
35+
36+### The repository object
37+
38+```json
39+{
40+ "id": 6628874657374920,
41+ "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg",
42+ "name": "website",
43+ "full_name": "acme/website",
44+ "owner": { "login": "acme" },
45+ "html_url": "https://diffinery.com/acme/website",
46+ "clone_url": "https://diffinery.com/acme/website.git"
47+}
48+```
49+
50+| Field | Description |
51+|---|---|
52+| `id` | The repository's numeric id, the same number the REST API returns. It does not change when the repository is renamed. |
53+| `node_id` | An opaque string id for the repository, as in the REST API |
54+| `name` | Repository name |
55+| `full_name` | `owner/name` |
56+| `owner.login` | Login of the owning user or organization |
57+| `html_url` | Web address of the repository |
58+| `clone_url` | HTTPS clone URL |
59+
60+### The sender object
61+
62+A person:
63+
64+```json
65+{ "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" }
66+```
67+
68+An agent, with the login of the person or organization it acts for:
69+
70+```json
71+{ "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "octocat" }
72+```
73+
74+| Field | Description |
75+|---|---|
76+| `id` | Account id (32 hexadecimal characters, unlike the numeric `repository.id`), or an id such as `system:merge-queue` for Diffinery's own actors |
77+| `login` | Login of the account |
78+| `type` | `User` for people, `Bot` for agents and for Diffinery's own actors |
79+| `on_behalf_of` | Only for agents: the login of the agent's owner |
80+
81+Diffinery's own actors appear as `Bot` senders: `diffinery-merge-queue[bot]` (merge queue), `diffinery-scheduler` (scheduled workflow runs).
82+
83+## push
84+
85+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.
86+
87+| Field | Type | Description |
88+|---|---|---|
89+| `ref` | string | The full ref, for example `refs/heads/main` or `refs/tags/v1.2.0` |
90+| `before` | string | Commit SHA the ref pointed to before; 40 zeros when the ref was created |
91+| `after` | string | Commit SHA the ref points to now; 40 zeros when the ref was deleted |
92+| `forced` | boolean | `true` when the update was not a fast-forward |
93+| `created` | boolean | `true` when the ref was created |
94+| `deleted` | boolean | `true` when the ref was deleted |
95+| `at` | string | Time of the push |
96+
97+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.
98+
99+```json
100+{
101+ "ref": "refs/heads/main",
102+ "before": "6f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6",
103+ "after": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d",
104+ "forced": false,
105+ "created": false,
106+ "deleted": false,
107+ "at": "2026-10-13T09:30:00.000Z",
108+ "repository": {
109+ "id": 6628874657374920,
110+ "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg",
111+ "name": "website",
112+ "full_name": "acme/website",
113+ "owner": { "login": "acme" },
114+ "html_url": "https://diffinery.com/acme/website",
115+ "clone_url": "https://diffinery.com/acme/website.git"
116+ },
117+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" }
118+}
119+```
120+
121+A new branch:
122+
123+```json
124+{
125+ "ref": "refs/heads/fix-typo",
126+ "before": "0000000000000000000000000000000000000000",
127+ "after": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d",
128+ "forced": false,
129+ "created": true,
130+ "deleted": false,
131+ "at": "2026-10-13T09:31:12.000Z",
132+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
133+ "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "octocat" }
134+}
135+```
136+
137+## pull_request
138+
139+Sent for pull request activity.
140+
141+| Action | Sent when | Sender |
142+|---|---|---|
143+| `opened` | A pull request is created | Its author |
144+| `synchronize` | The head branch moved to a new commit | The pusher |
145+| `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 |
146+| `ready_for_review` | A draft is marked ready for review (**Ready for review**, `diffinery pr ready`, or `PATCH` with `"draft": false`) | Who marked it |
147+| `converted_to_draft` | An open pull request is turned back into a draft (`diffinery pr ready --undo`, or `PATCH` with `"draft": true`) | Who converted it |
148+| `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 |
149+| `reopened` | A closed pull request is reopened | Who reopened it |
150+| `assigned` | One or more people are newly assigned | Who assigned them |
151+| `review_requested` | One or more reviewers are newly requested | Who requested the review |
152+| `enqueued` | The pull request was added to a merge queue | Who added it |
153+| `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]` |
154+
155+| Field | Type | Description |
156+|---|---|---|
157+| `action` | string | One of the actions above |
158+| `number` | number | The pull request number |
159+| `headRef` | string | Head branch name |
160+| `headSha` | string | Head commit SHA |
161+| `baseRef` | string | Base branch name |
162+| `at` | string | Time of the event |
163+| `assignees` | array of strings | Only for `assigned`: logins newly assigned |
164+| `reviewers` | array of strings | Only for `review_requested`: logins newly requested |
165+| `pull_request.number` | number | The pull request number |
166+| `pull_request.html_url` | string | Web address of the pull request |
167+| `pull_request.head.ref` | string | Head branch name |
168+| `pull_request.head.sha` | string | Head commit SHA |
169+| `pull_request.base.ref` | string | Base branch name |
170+| `pull_request.merged` | boolean | `true` only when the pull request was merged (action `closed`) |
171+
172+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}`.
173+
174+Opened:
175+
176+```json
177+{
178+ "action": "opened",
179+ "number": 42,
180+ "headRef": "fix-typo",
181+ "headSha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d",
182+ "baseRef": "main",
183+ "at": "2026-10-13T09:32:40.000Z",
184+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
185+ "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "octocat" },
186+ "pull_request": {
187+ "number": 42,
188+ "html_url": "https://diffinery.com/acme/website/pull/42",
189+ "head": { "ref": "fix-typo", "sha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d" },
190+ "base": { "ref": "main" },
191+ "merged": false
192+ }
193+}
194+```
195+
196+Merged (delivered as `closed`):
197+
198+```json
199+{
200+ "action": "closed",
201+ "number": 42,
202+ "headRef": "fix-typo",
203+ "headSha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d",
204+ "baseRef": "main",
205+ "at": "2026-10-13T10:05:11.000Z",
206+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
207+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" },
208+ "pull_request": {
209+ "number": 42,
210+ "html_url": "https://diffinery.com/acme/website/pull/42",
211+ "head": { "ref": "fix-typo", "sha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d" },
212+ "base": { "ref": "main" },
213+ "merged": true
214+ }
215+}
216+```
217+
218+A merge also sends a `push` event for the base branch.
219+
220+Review requested:
221+
222+```json
223+{
224+ "action": "review_requested",
225+ "number": 42,
226+ "headRef": "fix-typo",
227+ "headSha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d",
228+ "baseRef": "main",
229+ "at": "2026-10-13T09:33:05.000Z",
230+ "reviewers": ["octocat"],
231+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
232+ "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "octocat" },
233+ "pull_request": {
234+ "number": 42,
235+ "html_url": "https://diffinery.com/acme/website/pull/42",
236+ "head": { "ref": "fix-typo", "sha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d" },
237+ "base": { "ref": "main" },
238+ "merged": false
239+ }
240+}
241+```
242+
243+The `assigned` payload has the same shape with `"assignees": ["octocat"]` instead of `reviewers`.
244+
245+Ready for review (`edited` and `converted_to_draft` look the same apart from `action`):
246+
247+```json
248+{
249+ "action": "ready_for_review",
250+ "number": 42,
251+ "headRef": "fix-typo",
252+ "headSha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d",
253+ "baseRef": "main",
254+ "at": "2026-10-13T09:45:10.000Z",
255+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
256+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" },
257+ "pull_request": {
258+ "number": 42,
259+ "html_url": "https://diffinery.com/acme/website/pull/42",
260+ "head": { "ref": "fix-typo", "sha": "2b7e9d4c1a3f5e7d9b0c2a4e6f8d0b1c3e5a7f9d" },
261+ "base": { "ref": "main" },
262+ "merged": false
263+ }
264+}
265+```
266+
267+## pull_request_review
268+
269+Sent when someone submits a review on a pull request.
270+
271+| Action | Sent when |
272+|---|---|
273+| `submitted` | A review is submitted (approve, request changes, or comment) |
274+
275+| Field | Type | Description |
276+|---|---|---|
277+| `action` | string | `submitted` |
278+| `number` | number | The pull request number |
279+| `state` | string | `APPROVED`, `CHANGES_REQUESTED` or `COMMENTED` (uppercase) |
280+| `at` | string | Time of the review |
281+
282+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.
283+
284+```json
285+{
286+ "action": "submitted",
287+ "number": 42,
288+ "state": "APPROVED",
289+ "at": "2026-10-13T09:50:00.000Z",
290+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
291+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" }
292+}
293+```
294+
295+## issues
296+
297+Sent for issue activity. Pull requests send `pull_request` events instead.
298+
299+| Action | Sent when |
300+|---|---|
301+| `opened` | An issue is created |
302+| `edited` | The title or body changed (web UI, `PATCH /issues/{number}`, or the `update_issue` MCP tool) |
303+| `closed` | An issue is closed, including by a merged pull request that says `fixes #12` |
304+| `reopened` | A closed issue is reopened |
305+| `assigned` | One or more people are newly assigned |
306+
307+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.
308+
309+| Field | Type | Description |
310+|---|---|---|
311+| `action` | string | One of the actions above |
312+| `number` | number | The issue number |
313+| `at` | string | Time of the event |
314+| `assignees` | array of strings | Only for `assigned`: logins newly assigned |
315+| `issue.number` | number | The issue number |
316+| `issue.html_url` | string | Web address of the issue |
317+
318+```json
319+{
320+ "action": "opened",
321+ "number": 12,
322+ "at": "2026-10-13T08:15:00.000Z",
323+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
324+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" },
325+ "issue": { "number": 12, "html_url": "https://diffinery.com/acme/website/issues/12" }
326+}
327+```
328+
329+```json
330+{
331+ "action": "assigned",
332+ "number": 12,
333+ "at": "2026-10-13T08:20:00.000Z",
334+ "assignees": ["review-bot"],
335+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
336+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" },
337+ "issue": { "number": 12, "html_url": "https://diffinery.com/acme/website/issues/12" }
338+}
339+```
340+
341+## issue_comment
342+
343+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.
344+
345+| Action | Sent when |
346+|---|---|
347+| `created` | A comment is added |
348+
349+| Field | Type | Description |
350+|---|---|---|
351+| `action` | string | `created` |
352+| `number` | number | The issue or pull request number |
353+| `commentId` | string | The comment's id |
354+| `at` | string | Time of the comment |
355+| `issue.number` | number | The issue or pull request number |
356+| `issue.html_url` | string | `https://diffinery.com/{owner}/{repo}/issues/{number}`; for a pull request this address redirects to the pull request |
357+
358+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.
359+
360+```json
361+{
362+ "action": "created",
363+ "number": 42,
364+ "commentId": "3e2d1c0b9a8f4e7d6c5b4a3f2e1d0c9b",
365+ "at": "2026-10-13T09:40:00.000Z",
366+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
367+ "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "octocat" },
368+ "issue": { "number": 42, "html_url": "https://diffinery.com/acme/website/issues/42" }
369+}
370+```
371+
372+## workflow_run
373+
374+Sent when a [Diffinery Actions](/docs/actions/overview) workflow run is created and when it finishes.
375+
376+| Action | Sent when |
377+|---|---|
378+| `requested` | A run is created, for any trigger (push, pull request, schedule, manual dispatch, merge queue, workflow run, release, repository dispatch) |
379+| `completed` | The run finished |
380+
381+| Field | Type | Description |
382+|---|---|---|
383+| `action` | string | `requested` or `completed` |
384+| `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}`. |
385+| `conclusion` | string | Only for `completed`: `success`, `failure` or `cancelled` |
386+| `at` | string | Time of the event |
387+
388+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.
389+
390+```json
391+{
392+ "action": "completed",
393+ "runId": "8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a",
394+ "conclusion": "success",
395+ "at": "2026-10-13T09:36:21.000Z",
396+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
397+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" }
398+}
399+```
400+
401+## release
402+
403+Sent for release activity, including activity on drafts. See [Releases](/docs/collaboration/releases).
404+
405+| Action | Sent when |
406+|---|---|
407+| `created` | A release is created, as a draft or published |
408+| `published` | A release is published: created as published, or a draft is published |
409+| `released` | A full (not pre-release) release is published, or a published pre-release becomes a full release |
410+| `prereleased` | A pre-release is published, or a published release becomes a pre-release |
411+| `edited` | A release is edited without publishing or unpublishing it (also for drafts) |
412+| `unpublished` | A published release is turned back into a draft |
413+| `deleted` | A release is deleted |
414+
415+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.
416+
417+| Field | Type | Description |
418+|---|---|---|
419+| `action` | string | One of the actions above |
420+| `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` |
421+| `repository` | object | The repository |
422+| `sender` | object | Who made the change |
423+
424+The `release.reactions` counts in webhook payloads are always zero; read reactions with the REST API.
425+
426+```json
427+{
428+ "action": "published",
429+ "release": {
430+ "url": "https://diffinery.com/api/v3/repos/acme/website/releases/7",
431+ "assets_url": "https://diffinery.com/api/v3/repos/acme/website/releases/7/assets",
432+ "upload_url": "https://diffinery.com/api/uploads/repos/acme/website/releases/7/assets{?name,label}",
433+ "html_url": "https://diffinery.com/acme/website/releases/tag/v1.2.0",
434+ "id": 7,
435+ "author": {
436+ "login": "octocat",
437+ "id": 7220136606554077,
438+ "node_id": "VXNlcjphMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MA",
439+ "avatar_url": "https://diffinery.com/api/avatars/octocat",
440+ "gravatar_id": "",
441+ "url": "https://diffinery.com/api/v3/users/octocat",
442+ "html_url": "https://diffinery.com/octocat",
443+ "repos_url": "https://diffinery.com/api/v3/users/octocat/repos",
444+ "organizations_url": "https://diffinery.com/api/v3/users/octocat/orgs",
445+ "type": "User",
446+ "user_view_type": "public",
447+ "site_admin": false
448+ },
449+ "node_id": "UmVsZWFzZTo3",
450+ "tag_name": "v1.2.0",
451+ "target_commitish": "main",
452+ "name": "Website 1.2.0",
453+ "draft": false,
454+ "immutable": false,
455+ "prerelease": false,
456+ "created_at": "2026-10-13T10:00:00Z",
457+ "updated_at": "2026-10-13T10:00:00Z",
458+ "published_at": "2026-10-13T10:00:00Z",
459+ "assets": [],
460+ "tarball_url": "https://diffinery.com/api/v3/repos/acme/website/tarball/v1.2.0",
461+ "zipball_url": "https://diffinery.com/api/v3/repos/acme/website/zipball/v1.2.0",
462+ "body": "## What's Changed\n* Fix broken footer links by @octocat in https://diffinery.com/acme/website/pull/41\n\n**Full Changelog**: https://diffinery.com/acme/website/compare/v1.1.0...v1.2.0",
463+ "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 },
464+ "latest": true
465+ },
466+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
467+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" }
468+}
469+```
470+
471+> **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.
472+
473+## status
474+
475+Sent every time a commit status is posted with `POST /repos/{owner}/{repo}/statuses/{sha}`. There is no `action`. See [Checks and commit statuses](/docs/collaboration/checks-and-statuses).
476+
477+| Field | Type | Description |
478+|---|---|---|
479+| `id` | number | The status id |
480+| `sha` | string | The commit |
481+| `name` | string | The repository's full name, `owner/name` |
482+| `state` | string | `pending`, `success`, `failure` or `error` |
483+| `context` | string | The status context (`default` when none was given) |
484+| `description` | string or null | The description |
485+| `target_url` | string or null | The link to the build |
486+| `commit.sha` | string | The commit, again |
487+| `created_at`, `updated_at` | string | When the status was posted (ISO 8601 with milliseconds) |
488+
489+```json
490+{
491+ "id": 17,
492+ "sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d",
493+ "name": "acme/website",
494+ "state": "success",
495+ "context": "ci/external",
496+ "description": "Build #812 passed in 3m 12s",
497+ "target_url": "https://ci.example.com/builds/812",
498+ "commit": { "sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d" },
499+ "created_at": "2026-10-13T09:40:00.000Z",
500+ "updated_at": "2026-10-13T09:40:00.000Z",
501+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
502+ "sender": { "id": "c1b2a3d4e5f60718293a4b5c6d7e8f9a", "login": "ci-bot", "type": "User" }
503+}
504+```
505+
506+Unlike GitHub's `status` payload, `commit` holds only the SHA and there is no `branches` list. Check runs don't send webhook events.
507+
508+## deployment
509+
510+Sent when a deployment is created with `POST /repos/{owner}/{repo}/deployments`.
511+
512+| Action | Sent when |
513+|---|---|
514+| `created` | A deployment is created |
515+
516+| Field | Type | Description |
517+|---|---|---|
518+| `action` | string | `created` |
519+| `deployment.id` | number | The deployment id |
520+| `deployment.sha` | string | The commit being deployed |
521+| `deployment.ref` | string | The ref as it was given (branch, tag or SHA) |
522+| `deployment.task` | string | The task, `deploy` by default |
523+| `deployment.environment` | string | The environment, `production` by default |
524+| `deployment.description` | string or null | The description |
525+| `deployment.payload` | any | The `payload` sent when creating the deployment: the JSON value, or the string when it wasn't JSON. `{}` by default. |
526+| `deployment.created_at` | string | Creation time (ISO 8601 with milliseconds) |
527+
528+Workflow jobs with `environment:` record a deployment too, but they don't send a `deployment` event: watch `deployment_status` for those.
529+
530+```json
531+{
532+ "action": "created",
533+ "deployment": {
534+ "id": 3,
535+ "sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d",
536+ "ref": "main",
537+ "task": "deploy",
538+ "environment": "production",
539+ "description": "Deploy website 1.2.0",
540+ "payload": { "version": "1.2.0" },
541+ "created_at": "2026-10-13T10:05:00.000Z"
542+ },
543+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
544+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" }
545+}
546+```
547+
548+## deployment_status
549+
550+Sent when a deployment gets a new status:
551+
552+- with `POST /repos/{owner}/{repo}/deployments/{id}/statuses`;
553+- when a workflow job with `environment:` starts (`in_progress`, sender: who started the run);
554+- when that job finishes (`success`; `inactive` when it was cancelled or skipped; `failure` otherwise; sender: who started the run).
555+
556+When a `success` status makes older deployments to the same environment `inactive`, those `inactive` statuses don't send events.
557+
558+| Action | Sent when |
559+|---|---|
560+| `created` | A status is added |
561+
562+| Field | Type | Description |
563+|---|---|---|
564+| `action` | string | `created` |
565+| `deployment_status.id` | number | The status id |
566+| `deployment_status.state` | string | `error`, `failure`, `inactive`, `in_progress`, `queued`, `pending` or `success` |
567+| `deployment_status.description` | string | The description, up to 140 characters; empty when none |
568+| `deployment_status.environment` | string | The environment |
569+| `deployment_status.environment_url` | string | The deployed site's URL; empty when none |
570+| `deployment_status.log_url` | string | The deployment's log (for jobs, the job's page); empty when none |
571+| `deployment_status.created_at` | string | When the status was added (ISO 8601 with milliseconds) |
572+| `deployment.id`, `.sha`, `.ref`, `.task`, `.environment` | | The deployment the status belongs to |
573+
574+```json
575+{
576+ "action": "created",
577+ "deployment_status": {
578+ "id": 5,
579+ "state": "success",
580+ "description": "",
581+ "environment": "production",
582+ "environment_url": "https://www.acme.example",
583+ "log_url": "https://diffinery.com/acme/website/actions/runs/8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a/job/2f1e0d9c8b7a69584736251403f2e1d0",
584+ "created_at": "2026-10-13T10:07:44.000Z"
585+ },
586+ "deployment": {
587+ "id": 3,
588+ "sha": "9c4d8e2f1a0b3c5d7e9f2a4b6c8d0e1f3a5b7c9d",
589+ "ref": "main",
590+ "task": "deploy",
591+ "environment": "production"
592+ },
593+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
594+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" }
595+}
596+```
597+
598+## repository_dispatch
599+
600+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](/docs/actions/events)).
601+
602+```sh
603+curl -sS -X POST -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \
604+ https://diffinery.com/api/v3/repos/acme/website/dispatches \
605+ -d '{ "event_type": "deploy-docs", "client_payload": { "version": "1.2.0" } }'
606+```
607+
608+| Field | Type | Description |
609+|---|---|---|
610+| `action` | string | The `event_type` from the request (1 to 100 characters) |
611+| `branch` | string | The repository's default branch |
612+| `client_payload` | object | The `client_payload` from the request (`{}` when none): at most 10 top-level keys and 64 KB |
613+
614+```json
615+{
616+ "action": "deploy-docs",
617+ "branch": "main",
618+ "client_payload": { "version": "1.2.0" },
619+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
620+ "sender": { "id": "f0e1d2c3b4a5968778695a4b3c2d1e0f", "login": "review-bot", "type": "Bot", "on_behalf_of": "octocat" }
621+}
622+```
623+
624+## workflow_dispatch
625+
626+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`.
627+
628+| Field | Type | Description |
629+|---|---|---|
630+| `ref` | string | The full ref the workflow runs on, for example `refs/heads/main` or `refs/tags/v1.2.0` |
631+| `workflow` | string | The workflow file's path, for example `.github/workflows/deploy.yml` |
632+| `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. |
633+| `at` | string | Time of the dispatch |
634+
635+```json
636+{
637+ "ref": "refs/heads/main",
638+ "workflow": ".github/workflows/deploy.yml",
639+ "inputs": { "environment": "staging", "version": "1.2.0", "dry-run": false },
640+ "at": "2026-10-13T10:15:00.000Z",
641+ "repository": { "id": 6628874657374920, "node_id": "UmVwb3NpdG9yeTo1YzBlOGI3ZjJkMWE0ZTNiOWY2YTdjOGQ5ZTBmMWEyYg", "name": "website", "full_name": "acme/website", "owner": { "login": "acme" }, "html_url": "https://diffinery.com/acme/website", "clone_url": "https://diffinery.com/acme/website.git" },
642+ "sender": { "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "login": "octocat", "type": "User" }
643+}
644+```
645+
646+The workflow's own inputs are in the [workflow_dispatch trigger](/docs/actions/events#workflow_dispatch).
647+
648+## Events that are never delivered
649+
650+Audit entries (sign-ins, push protection decisions, settings changes) are not sent to webhooks. They are in the [audit log](/docs/security/audit-log). Check runs and check suites (GitHub's `check_run` and `check_suite`) send no events either; commit statuses send `status`.
651+
652+## Comparing with GitHub payloads
653+
654+| Event | Fields GitHub sends that Diffinery does not |
655+|---|---|
656+| `push` | `commits`, `head_commit`, `pusher`, `compare`, `base_ref` |
657+| `pull_request` | The full pull request object (title, body, user, labels, state, draft, timestamps), and `changes` for `edited` |
658+| `pull_request_review` | `review` and `pull_request` objects |
659+| `issues` | The full issue object, and `changes` for `edited` |
660+| `issue_comment` | `comment` object, full `issue` object |
661+| `workflow_run` | `workflow_run` and `workflow` objects |
662+| `release` | Nothing in the `release` object, which has the REST API shape |
663+| `status` | `branches` and the full `commit` object |
664+| `deployment` | Most `deployment` fields (`url`, `creator`, `statuses_url`, `transient_environment`, `production_environment`, ...) |
665+| `deployment_status` | Most `deployment_status` fields (`url`, `creator`, `target_url`, ...) and the full `deployment` object |
666+| `repository_dispatch` | Nothing: the same `action`, `branch` and `client_payload` |
667+| `workflow_dispatch` | Nothing: the same `inputs`, `ref` and `workflow` (inputs keep their types) |
668+
669+All events also lack GitHub's `installation` and `organization` objects, and the `repository` object has only the fields shown above. Use the [REST API](/docs/api) to fetch any detail you need, keyed by `number`, `commentId` or `runId`. See [Webhooks](/docs/repositories/webhooks) for delivery behavior and signature verification.
addedapps/diffinery/docs/repositories/branch-protection.md+413−0View file
@@ -1,0 +1,413 @@
1+---
2+title: Branch protection
3+description: Protect branches with rules for pull requests, approvals, required checks, force pushes, deletion, agents and merge queues, from the web UI or the API.
4+---
5+
6+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.
7+
8+## Concepts
9+
10+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.
11+
12+Two protections apply even when a repository has no rules at all:
13+
14+- The default branch cannot be deleted.
15+- 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)).
16+
17+Some pull request rules also apply without any branch protection:
18+
19+- 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.
20+- A pull request opened by an agent needs at least one approval from a person.
21+
22+### Patterns
23+
24+| Pattern | Matches | Does not match |
25+|---|---|---|
26+| `main` | `main` | `main2`, `feature/main` |
27+| `release/*` | `release/1.0`, `release/next` | `release/1.0/hotfix`, `release` |
28+| `release/**` | `release/1.0`, `release/1.0/hotfix` | `release` |
29+| `**` | every branch | |
30+| `v?` | `v1`, `v2` | `v10` |
31+
32+- `*` matches any characters except `/` (within one path segment).
33+- `**` matches any characters, including `/`.
34+- `?` matches exactly one character other than `/`.
35+- Everything else matches literally, and the pattern must match the whole branch name.
36+- Patterns cannot contain spaces.
37+
38+### Which rule applies
39+
40+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:
41+
42+1. A rule whose pattern is exactly the branch name wins. For branch `main`, a `main` rule beats `*` and `**`.
43+2. Otherwise, the oldest matching rule wins: the one created first.
44+
45+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.
46+
47+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.
48+
49+The same rule decides pushes, merges, merge blockers, the merge queue, the **protected** badge and environment branch policies that allow only protected branches.
50+
51+## Rule settings
52+
53+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.
54+
55+| JSON field | UI label | Type | Default | Effect |
56+|---|---|---|---|---|
57+| `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. |
58+| `requiredApprovals` | Required approvals | number | `0` | Approving reviews from people needed before a pull request can merge. The UI accepts 0 to 10. |
59+| `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. |
60+| `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). |
61+| `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). |
62+| `allowForcePush` | Allow force pushes | boolean | `false` | Allows non-fast-forward updates to matching branches. |
63+| `allowDeletion` | Allow deletion | boolean | `false` | Allows deleting matching branches. |
64+| `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. |
65+| `enforceAdmins` | Do not allow administrators to bypass these rules | boolean | `false` | Applies the rules to repository admins too. |
66+| `agentsMayPush` | Allow agents to push directly | boolean | `false` | Lets agents push to matching branches. |
67+| `agentsMayMerge` | Allow agents to merge pull requests | boolean | `false` | Lets agents merge pull requests into matching branches. |
68+| `mergeQueue` | Require a merge queue | object | none | Pull requests merge through a merge queue. See [Merge queue settings](#merge-queue-settings). |
69+
70+### Required checks
71+
72+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](/docs/collaboration/merge-queue) is used):
73+
74+- **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`.
75+- **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.
76+
77+Copy names exactly as they appear in a pull request's checks list. See [Checks and statuses](/docs/collaboration/checks-and-statuses) for how to report them.
78+
79+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:
80+
81+1. If the rule binds the check to a reporter in `requiredCheckSources`, only that reporter's reports count.
82+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.
83+3. Reports from agents never count, whoever they are and whatever the binding says.
84+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.
85+
86+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.
87+
88+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.
89+
90+> **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.
91+
92+### Binding a check to a reporter
93+
94+`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.
95+
96+```json
97+{
98+ "requirePullRequest": true,
99+ "requiredChecks": ["ci / test", "ci/external"],
100+ "requiredCheckSources": { "ci/external": "ci-bot", "ci / test": "diffinery-actions" }
101+}
102+```
103+
104+- Logins are matched without regard to case and are stored in lowercase.
105+- 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>'`.
106+- A value that is not an object fails with `requiredCheckSources must map check names to a reporter login or diffinery-actions`.
107+- An entry only has an effect for a name that is also in `requiredChecks`.
108+- Binding a check to an agent's login makes it impossible to satisfy, because agents' reports never count.
109+
110+`requiredCheckSources` can only be set through the API. The **Branch protection** page does not show it.
111+
112+> **Important:** Saving a rule from the **Branch protection** page in the web UI replaces the whole rule with the form's fields, which removes `requiredCheckSources`. After editing a rule in the web UI, send the binding again with `PUT .../protection`.
113+
114+### Approvals
115+
116+Diffinery counts each reviewer's most recent approving or "request changes" review:
117+
118+- Only approvals from people count. Agent approvals are advisory and never count.
119+- You cannot approve your own pull request, and approving requires the write role.
120+- With `dismissStaleReviews`, only approvals given on the pull request's current head commit count.
121+- If a pull request was opened by an agent, it needs at least one approval from a person even when `requiredApprovals` is `0`.
122+
123+### Agent rules
124+
125+Agents are accounts that act for a person or organization (see [Agents](/docs/agents/overview)). By default:
126+
127+- 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.
128+- 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.
129+- Agents cannot create, change or delete branch protection rules (`agents cannot change branch protection`).
130+- 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.
131+
132+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.
133+
134+### Merge queue settings
135+
136+`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](/docs/collaboration/merge-queue) for how the queue works.
137+
138+| JSON field | UI label | Default | Allowed values |
139+|---|---|---|---|
140+| `method` | Merge method | `"merge"` | `"merge"` (Create a merge commit), `"squash"` (Squash and merge), `"rebase"` (Rebase and merge) |
141+| `buildConcurrency` | Build concurrency | `5` | 1 to 20: how many queued pull requests are built and checked at once |
142+| `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 |
143+
144+Values outside the allowed range are clamped. An unknown `method` becomes `"merge"`. `"mergeQueue": true` turns the queue on with the defaults.
145+
146+## Who can bypass the rules
147+
148+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](/docs/accounts/permissions).
149+
150+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.
151+
152+| Action | Admin bypass (when `enforceAdmins` is off) |
153+|---|---|
154+| 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. |
155+| 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. 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)`. |
156+| 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 asks for a bypass as above. |
157+| 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 also bypasses unmet rules. |
158+| Deleting the default branch | Never allowed. |
159+| Agent rules | Never bypassed by admin status for merges. |
160+
161+A merge that bypassed unmet blockers is recorded in the [audit log](/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 `blockers` and `base` branch in the detail. See [Admin bypass](/docs/collaboration/pull-requests#admin-bypass).
162+
163+With `enforceAdmins` on, admins follow the same rules as everyone else.
164+
165+## Managing rules in the web UI
166+
167+You need the admin role on the repository.
168+
169+1. Open the repository, for example `https://diffinery.com/acme/website`.
170+2. Select **Settings**, then **Branch protection** in the sidebar (`https://diffinery.com/acme/website/settings/branches`).
171+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.
172+4. Set **Required approvals**, enter **Required status checks** as a comma-separated list of check names, and check the options you want.
173+5. To use a merge queue, check **Require a merge queue** and choose the **Merge method**, **Build concurrency** and **Check timeout (minutes)**.
174+6. Select **Create rule**.
175+
176+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**. 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.
177+
178+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.
179+
180+## Managing rules with the API
181+
182+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.
183+
184+| Method and path | Purpose |
185+|---|---|
186+| `GET /api/v3/repos/{owner}/{repo}/branches/{pattern}/protection` | Read the rule stored for exactly this pattern |
187+| `PUT /api/v3/repos/{owner}/{repo}/branches/{pattern}/protection` | Create or replace the rule for this pattern |
188+| `DELETE /api/v3/repos/{owner}/{repo}/branches/{pattern}/protection` | Delete the rule for this pattern |
189+
190+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`.
191+
192+`PUT` replaces the whole rule with the JSON body. Fields you leave out are turned off.
193+
194+> **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.
195+
196+### Create a rule with curl
197+
198+```sh
199+curl -sS -X PUT \
200+ -H "Authorization: Bearer dfy_..." \
201+ -H "Content-Type: application/json" \
202+ https://diffinery.com/api/v3/repos/acme/website/branches/main/protection \
203+ -d '{
204+ "requirePullRequest": true,
205+ "requiredApprovals": 1,
206+ "dismissStaleReviews": true,
207+ "requiredChecks": ["ci / test", "ci / lint"]
208+ }'
209+```
210+
211+The response echoes the pattern and the rules you sent:
212+
213+```json
214+{
215+ "pattern": "main",
216+ "rules": {
217+ "requirePullRequest": true,
218+ "requiredApprovals": 1,
219+ "dismissStaleReviews": true,
220+ "requiredChecks": ["ci / test", "ci / lint"]
221+ }
222+}
223+```
224+
225+### Create a rule with the CLI
226+
227+The `diffinery api` command sends an authenticated request (install the CLI with `curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery`, which needs Node.js 22 or later):
228+
229+```sh
230+cat > rules.json <<'EOF'
231+{ "requirePullRequest": true, "requiredApprovals": 2, "enforceAdmins": true }
232+EOF
233+diffinery api PUT /repos/acme/website/branches/main/protection --input rules.json
234+diffinery api GET /repos/acme/website/branches/main/protection
235+diffinery api DELETE /repos/acme/website/branches/main/protection
236+```
237+
238+See [CLI reference](/docs/reference/cli) and [REST API](/docs/api).
239+
240+### Example rules
241+
242+A default branch that only changes through reviewed pull requests with green CI, and that admins cannot bypass:
243+
244+```json
245+{
246+ "requirePullRequest": true,
247+ "requiredApprovals": 2,
248+ "dismissStaleReviews": true,
249+ "requiredChecks": ["ci / test"],
250+ "requireLinearHistory": true,
251+ "enforceAdmins": true
252+}
253+```
254+
255+Release branches (`PUT .../branches/release%2F*/protection`) that may be force-pushed and deleted after a release:
256+
257+```json
258+{
259+ "allowForcePush": true,
260+ "allowDeletion": true
261+}
262+```
263+
264+Agent working branches (`PUT .../branches/bots%2F**/protection`) where agents push directly and merge their own pull requests:
265+
266+```json
267+{
268+ "agentsMayPush": true,
269+ "agentsMayMerge": true
270+}
271+```
272+
273+A default branch that merges through a merge queue, squashing each pull request:
274+
275+```json
276+{
277+ "requirePullRequest": true,
278+ "requiredApprovals": 1,
279+ "requiredChecks": ["ci / test"],
280+ "mergeQueue": { "method": "squash", "buildConcurrency": 3, "checkTimeoutMinutes": 30 }
281+}
282+```
283+
284+## What happens on a push
285+
286+For each ref in a push (and for each branch update from the API, the web editor or MCP), Diffinery checks, in order:
287+
288+1. The ref name is valid, and you have at least the write role.
289+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.
290+3. Moving an existing tag needs the maintain role.
291+4. Agents are stopped at the default branch and protected branches unless `agentsMayPush` is on.
292+5. The default branch cannot be deleted.
293+6. If no rule matches, the update is accepted. If you are an admin and the rule does not set `enforceAdmins`, it is accepted.
294+7. Deletion needs `allowDeletion`.
295+8. `requirePullRequest` rejects every direct update.
296+9. A non-fast-forward update needs `allowForcePush`.
297+10. With `requireLinearHistory`, new commits must not include merge commits (the first 1,000 new commits are checked).
298+
299+A rejected ref shows the reason in git's output:
300+
301+```text
302+ ! [remote rejected] main -> main (protected branch: changes must be made through a pull request)
303+```
304+
305+Through the API, the same reasons come back as `403` errors with a `message`.
306+
307+| Reason | Meaning |
308+|---|---|
309+| `permission denied` | You need the write role. |
310+| `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`. |
311+| `cannot delete the default branch` | Choose another default branch first. |
312+| `protected branch: deletion not allowed` | The rule does not set `allowDeletion`. |
313+| `protected branch: changes must be made through a pull request` | The rule sets `requirePullRequest`. |
314+| `protected branch: force push not allowed` | The update is not a fast-forward and the rule does not set `allowForcePush`. |
315+| `protected branch: merge commits not allowed (linear history)` | The pushed commits include a merge commit. |
316+| `only maintainers may move tags` | Moving an existing tag needs the maintain role. |
317+| `merge queue branches are read-only` | `gh-readonly-queue/...` branches belong to the merge queue. |
318+| `refs/pull/ is managed by Diffinery for pull requests` | Pull request refs cannot be pushed. |
319+| `missing objects` | The update points at a commit the server doesn't have. Push the commits too, or fetch the full history first. |
320+
321+## Merge blockers
322+
323+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`.
324+
325+| Blocker text | Cause |
326+|---|---|
327+| `already merged` | The pull request is merged. |
328+| `pull request is closed` | Reopen it first. |
329+| `base branch <name> no longer exists` | The base branch was deleted. |
330+| `pull request is a draft` | Draft pull requests cannot merge. |
331+| `merge conflicts in <n> file(s)` | Resolve the conflicts on the head branch. |
332+| `needs <n> more approving review(s)` | Approvals from people are short of `requiredApprovals` (or of 1 for an agent's pull request). |
333+| `changes requested by <logins>` | These reviewers' latest review requests changes. |
334+| `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). |
335+| `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`. |
336+| `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`. |
337+| `branch is behind its base; update it (linear history required)` | With `requireLinearHistory`, the head branch must contain the latest base commit. |
338+| `write access is required to merge` | Your role is below write. |
339+| `agents may not merge into this branch; a human must merge` | An agent tried to merge into the default branch or a protected branch without `agentsMayMerge`. |
340+
341+Example response from `GET /api/v3/repos/acme/website/pulls/42`, trimmed:
342+
343+```json
344+{
345+ "number": 42,
346+ "mergeable": true,
347+ "rebaseable": true,
348+ "mergeable_state": "blocked",
349+ "merge_blockers": [
350+ "needs 1 more approving review(s)",
351+ "required check 'ci / test' is in_progress"
352+ ],
353+ "approvals": 0,
354+ "required_approvals": 1,
355+ "checks": [{ "name": "ci / test", "state": "in_progress" }]
356+}
357+```
358+
359+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:
360+
361+| Value | Meaning |
362+|---|---|
363+| `clean` | No blockers: the pull request can merge now. |
364+| `blocked` | One or more blockers other than conflicts, listed in `merge_blockers`. |
365+| `dirty` | The head conflicts with the base. |
366+| `draft` | The pull request is a draft. |
367+| `unknown` | The pull request is merged or closed, or its base branch no longer exists. |
368+
369+`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.
370+
371+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`:
372+
373+```json
374+{
375+ "message": "pull request is not mergeable: needs 1 more approving review(s); required check 'ci / test' is in_progress",
376+ "blockers": ["needs 1 more approving review(s)", "required check 'ci / test' is in_progress"]
377+}
378+```
379+
380+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)`.
381+
382+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.
383+
384+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](/docs/collaboration/merge-queue) and [Pull requests](/docs/collaboration/pull-requests).
385+
386+## Differences from GitHub
387+
388+- 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)).
389+- 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.
390+- Reports from agents never satisfy a required check.
391+- Agent controls (`agentsMayPush`, `agentsMayMerge`) and the human-approval requirement for agents' pull requests have no GitHub equivalent.
392+- Required Actions check names include the workflow name: `ci / test`.
393+- Rules have no options for restricting pushes to specific users or teams, requiring signed commits, requiring conversation resolution or requiring code owner reviews.
394+- A "changes requested" review blocks merging even on branches without a rule.
395+- An admin's merge past unmet rules must be asked for with `bypass_rules` (or `bypass_merge_queue`) and is written to the audit log as `pull_request.merge_bypassed`.
396+- When several rules match, the choice follows GitHub: a rule naming the branch exactly wins, then the oldest rule.
397+
398+## Troubleshooting
399+
400+| Symptom | Cause and fix |
401+|---|---|
402+| `branch not protected` from `GET .../protection` | No rule has exactly that pattern. List rules on the **Branch protection** page, and URL-encode `/` as `%2F`. |
403+| 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. |
404+| 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. |
405+| `invalid source for required check '<name>'` | A value in `requiredCheckSources` is not a valid login. Use the reporter's login or `diffinery-actions`. |
406+| `requiredCheckSources` disappeared | The rule was saved from the web UI, which doesn't keep it. Send it again with `PUT .../protection`. |
407+| An agent's pull request needs an approval even with `requiredApprovals: 0` | Agent-authored pull requests always need one approval from a person. |
408+| An approval disappeared after a push | `dismissStaleReviews` is on. Ask the reviewer to approve the new commits. |
409+| Your admin push was rejected | The rule sets `enforceAdmins`, or your token has the `write` scope instead of `admin`. |
410+| 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. |
411+| `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`. |
412+| 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). |
413+| `agents cannot change branch protection` | Change rules as a person, from the web UI or with your own token. |
addedapps/diffinery/docs/repositories/browsing-code.md+324−0View file
@@ -1,0 +1,324 @@
1+---
2+title: Browsing code
3+description: 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.
4+---
5+
6+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.
7+
8+Public repositories can be browsed without signing in. Private repositories need read access. Editing needs write access.
9+
10+## Page header and avatars
11+
12+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.
13+
14+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.
15+
16+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](/docs/accounts/profile-and-preferences).
17+
18+## Repository home page
19+
20+`https://diffinery.com/acme/website` shows the default branch:
21+
22+- A **branch and tag switcher**, with links to the **Branches** and **Tags** pages and their counts.
23+- **Add file** (if you can write to the branch) and **History**.
24+- 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.
25+- The README of the directory, rendered below the file list (see [READMEs and Markdown](#readmes-and-markdown)).
26+
27+The sidebar has:
28+
29+| Section | Contents |
30+| --- | --- |
31+| **Clone** | The clone URL with a **Copy** button, and a reminder to use a token as the password. See [Git over HTTPS](/docs/repositories/git). |
32+| **About** | Description, **Default branch**, **Open issues**, **Open pull requests**, **Refs**, **Objects**, **Size** (stored Git objects, compressed) and **Last push**. |
33+| **Topics** | The repository's topics, shown only when it has some. Topics are set with the REST API; see [Topics](/docs/repositories/overview#topics). |
34+| **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](/docs/collaboration/releases). |
35+| **Shortcuts** | The main [keyboard shortcuts](#keyboard-shortcuts). |
36+
37+### Empty repositories
38+
39+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.
40+
41+## Files and folders
42+
43+| Page | URL |
44+| --- | --- |
45+| Folder | `/acme/website/tree/<ref>/<path>` |
46+| File | `/acme/website/blob/<ref>/<path>` |
47+| Raw file | `/acme/website/raw/<ref>/<path>` |
48+
49+`<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.
50+
51+### Folder view
52+
53+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.
54+
55+### File view
56+
57+The file header shows the number of lines, the size, and "executable" for executable files, with these buttons:
58+
59+| Button | Does |
60+| --- | --- |
61+| **Copy path** | Copies the file's path in the repository. |
62+| **Preview** / **Code** | Markdown files only: switch between the rendered document and the source (`?plain=1`). |
63+| **Raw** | Opens the raw file. |
64+| **History** | Commits that changed this file. |
65+| **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. |
66+
67+How the content is shown:
68+
69+| File | Shown as |
70+| --- | --- |
71+| 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`). |
72+| Markdown (`.md`, `.markdown`, `.mdown`, `.mkd`) | Rendered, with **Code** to see the source. |
73+| PNG, JPEG, GIF, WebP, AVIF, ICO and BMP images | The image. |
74+| Other binary files | "Binary file", its size, and a **Download** button. |
75+| Text over 1 MB | "This file is too large to display", with a link to the raw file. |
76+
77+SVG files are shown as highlighted XML source (open **Raw** to see the image).
78+
79+### Syntax highlighting
80+
81+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](/docs/accounts/profile-and-preferences).
82+
83+Highlighting applies to:
84+
85+| Where | What is highlighted |
86+| --- | --- |
87+| File view | The whole file, when it has at most 2,000 lines and about 200 KB of text. |
88+| 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. |
89+| 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. |
90+
91+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.
92+
93+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.
94+
95+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).
96+
97+### Raw files
98+
99+`/acme/website/raw/<ref>/<path>` returns the file itself:
100+
101+- 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`.
102+- Raw files are served with a sandboxing Content Security Policy, so HTML and SVG files can't run scripts on `diffinery.com`.
103+- When `<ref>` is a full commit SHA, browsers cache the response for a day. For branches and tags, for 60 seconds.
104+
105+Scripts can also use the REST API, which accepts a token:
106+
107+```sh
108+curl -H "Authorization: Bearer dfy_..." \
109+ https://diffinery.com/api/v3/repos/acme/website/raw/main/package.json
110+```
111+
112+## READMEs and Markdown
113+
114+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.
115+
116+The same Markdown renderer is used for files, READMEs, issues, pull requests, comments and job summaries. It supports:
117+
118+- headings (`#` to `######`), each with a link anchor,
119+- paragraphs and hard line breaks,
120+- bold, italic, strikethrough and inline code,
121+- fenced code blocks, with [syntax highlighting](#syntax-highlighting) when the fence names a known language,
122+- blockquotes and horizontal rules,
123+- ordered, unordered and task lists, nested,
124+- tables,
125+- links, images, and bare URLs that become links.
126+
127+Rules that differ from GitHub:
128+
129+- Raw HTML is always escaped and shown as text, never rendered. `<details>`, `<img>`, `<br>` and similar tags appear literally.
130+- Links and images may use `http://`, `https://`, a `#fragment`, or a relative path. Other schemes (`javascript:`, `data:`, `mailto:` and so on) are dropped.
131+- 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.
132+- Documents larger than 512 KB are shown as plain text.
133+
134+## Edit files in the browser
135+
136+You need write access. Editing works on branches only (not tags or commits), for text files up to 1 MB.
137+
138+### Edit a file
139+
140+1. Open the file and click **Edit** (or open `/acme/website/edit/<branch>/<path>`).
141+2. Change the content. To rename or move the file, change the path field at the top.
142+3. In **Commit changes**, edit the commit message (by default `Update` followed by the file name) and optionally add an extended description.
143+4. Choose where to commit:
144+ - **Commit directly to the `<branch>` branch**, or
145+ - **Create a new branch for this commit and start a pull request:**, with a branch name filled in as `<your username>-patch-<number>`.
146+5. Click **Commit changes**.
147+
148+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)).
149+
150+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.
151+
152+The editor keeps the file's Windows line endings (CRLF) if it had them, and keeps the executable bit and symbolic links.
153+
154+### Create a file
155+
156+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.
157+
158+### Delete a file
159+
160+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.
161+
162+### What the commit looks like
163+
164+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`.
165+
166+### Editor errors
167+
168+| Message | Meaning |
169+| --- | --- |
170+| `branch moved; refresh and retry` | Someone pushed to the branch while you were editing. Copy your change, reload, and apply it again. |
171+| `<path> already exists` | A new file would overwrite an existing one. Edit that file instead. |
172+| `branch <name> already exists` | Pick another name for the new branch. |
173+| `files can only be edited on a branch` | You opened the editor on a tag or commit. Switch to a branch. |
174+| `binary files cannot be edited in the browser` | Edit it locally and push. |
175+| `enter a valid file path` | The path is empty or contains `..` or `.git`. |
176+| `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. |
177+| `a commit message is required` | Fill in the commit message. |
178+
179+Without a browser or a Git client, you can change files with the REST API:
180+
181+- `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.
182+- `DELETE /api/v3/repos/{owner}/{repo}/contents/{path}` deletes one file, with `message` and the file's `sha`.
183+- `POST /api/v3/repos/{owner}/{repo}/diffinery/commits` (or the MCP `commit_files` tool) changes several files in one commit.
184+
185+Branch protection and agent guardrails apply to all of them, as for a `git push`. `PUT .../contents` (and `POST .../git/blobs`) also scan the new content for credentials and refuse it with `push protection: possible secret detected (...)` (HTTP 422). See [REST API](/docs/reference/rest-api) and [MCP](/docs/agents/mcp).
186+
187+## Commits
188+
189+`/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.
190+
191+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.
192+
193+> **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".
194+
195+### Commit view
196+
197+`/acme/website/commit/<sha>` shows:
198+
199+- the message (title and body), author and committer with times, the **signed** badge,
200+- the parent commits, the full SHA with **Copy**, and **Browse files** (the tree at this commit),
201+- 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](/docs/collaboration/checks-and-statuses)),
202+- 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.
203+
204+Diff limits, on commit, compare and pull request pages:
205+
206+| Limit | What you see |
207+| --- | --- |
208+| More than 300 changed files | Only the first 300 are shown, with "showing the first 300 files". |
209+| A file with more than 800 diff lines | The file starts collapsed. Click its header to open it. |
210+| 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. |
211+| Binary files | "Binary file not shown." |
212+
213+Diff lines are syntax highlighted within the limits in [Syntax highlighting](#syntax-highlighting).
214+
215+## Branches
216+
217+`/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.
218+
219+| Action | How | Needs |
220+| --- | --- | --- |
221+| Compare with the default branch | **Compare** on the branch's row | read |
222+| Create a branch | **Create a branch**: enter a **Branch name**, pick **From** (an existing branch), click **Create branch** | write |
223+| 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`. |
224+
225+The same operations through Git and the API:
226+
227+```sh
228+git push origin main:refs/heads/release/2.0 # create from main
229+git push origin --delete release/2.0 # delete
230+
231+curl -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/branches
232+curl -X POST -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
233+ https://diffinery.com/api/v3/repos/acme/website/git/refs \
234+ -d '{"ref": "refs/heads/release/2.0", "sha": "<commit sha>"}'
235+curl -X DELETE -H "Authorization: Bearer dfy_..." \
236+ https://diffinery.com/api/v3/repos/acme/website/git/refs/heads/release/2.0
237+```
238+
239+## Tags
240+
241+`/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](/docs/repositories/git#tags) for who can create, move and delete them.
242+
243+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](/docs/collaboration/releases).
244+
245+## Download a branch, tag or commit
246+
247+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](/docs/repositories/overview#source-code-archives).
248+
249+## Compare and open pull requests
250+
251+`/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:
252+
253+- how many commits the head is ahead of and behind the base,
254+- the commits on the head that aren't on the base (up to 250),
255+- the combined diff from the merge base to the head.
256+
257+When both sides are branches, the head has new commits, and you have write access, the page has an **Open a pull request** form:
258+
259+| Field | Notes |
260+| --- | --- |
261+| **Title** | Prefilled from the commit message when there is one commit, otherwise from the branch name. |
262+| **Intent** | What the change is meant to do, for human and AI reviewers. Optional, but agents should always fill it in. |
263+| **Description** | Markdown. |
264+| **Create as draft** | Opens a draft pull request. |
265+
266+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](/docs/collaboration/pull-requests).
267+
268+The API returns the same comparison: `GET /api/v3/repos/acme/website/compare/main...feature/login` (three dots, base first).
269+
270+## Command palette
271+
272+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.
273+
274+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:
275+
276+| Group | Entries |
277+| --- | --- |
278+| Repository (inside a repository) | Code, Issues, New issue, Pull requests, Actions, Branches, Tags, Commits, Compare, Settings |
279+| Go to | Dashboard, New repository, Notifications, Settings, Tokens, Agents |
280+| Repository | Every repository you can see (up to 300) |
281+| 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) |
282+| Issue / PR | Type a number such as `42` or `#42` to jump to that issue or pull request |
283+
284+## Keyboard shortcuts
285+
286+Shortcuts don't fire while you type in a text field.
287+
288+| Keys | Action | Where |
289+| --- | --- | --- |
290+| **Ctrl K** / **Cmd K** | Open the command palette | Everywhere, when signed in |
291+| **t** | Find a file (opens the palette) | In a repository |
292+| **g** then **c** | Go to the code | In a repository |
293+| **g** then **i** | Go to issues | In a repository |
294+| **g** then **p** | Go to pull requests | In a repository |
295+| **g** then **a** | Go to Actions | In a repository |
296+| **g** then **s** | Go to settings | In a repository |
297+| **g** then **d** | Go to your dashboard | Everywhere |
298+| **g** then **n** | Go to notifications | Everywhere |
299+| **/** | Focus the search box | Issue and pull request lists |
300+
301+Press the second key of a **g** sequence within 1.5 seconds.
302+
303+## Live updates
304+
305+Code pages (files, folders, commits, branches, tags, compare) show the repository as it was when the page loaded. Reload to see new pushes.
306+
307+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](/docs/actions/overview) and [Pull requests](/docs/collaboration/pull-requests).
308+
309+## The same data through the API
310+
311+| Data | REST API (`https://diffinery.com/api/v3`) |
312+| --- | --- |
313+| Folder listing or file (base64) | `GET /repos/{owner}/{repo}/contents/{path}?ref={ref}` |
314+| Raw file | `GET /repos/{owner}/{repo}/raw/{ref}/{path}` |
315+| Commit list | `GET /repos/{owner}/{repo}/commits?sha={ref}&path={path}&per_page=30&page=1` |
316+| One commit with its diff | `GET /repos/{owner}/{repo}/commits/{ref}` |
317+| Compare | `GET /repos/{owner}/{repo}/compare/{base}...{head}` |
318+| Branches, tags | `GET /repos/{owner}/{repo}/branches`, `GET /repos/{owner}/{repo}/tags` |
319+| README of the root or a folder | `GET /repos/{owner}/{repo}/readme?ref={ref}`, `GET /repos/{owner}/{repo}/readme/{dir}` |
320+| Source archive | `GET /repos/{owner}/{repo}/zipball/{ref}`, `GET /repos/{owner}/{repo}/tarball/{ref}` |
321+| Bytes per language | `GET /repos/{owner}/{repo}/languages` |
322+| Trees and blobs by SHA | `GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1`, `GET /repos/{owner}/{repo}/git/blobs/{sha}` |
323+
324+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](/docs/api). Agents can read code with the MCP tools `get_file` and `list_tree` (see [MCP tools](/docs/reference/mcp-tools)).
addedapps/diffinery/docs/repositories/git-lfs.md+188−0View file
@@ -1,0 +1,188 @@
1+---
2+title: Git LFS
3+description: Store large files with Git LFS on Diffinery: setup, authentication, the batch API, using LFS files in workflows, and limits.
4+---
5+
6+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.
7+
8+## Set up LFS in a repository
9+
10+You need the `git-lfs` client installed (`git lfs version` to check) and a Diffinery token for pushing. Then use LFS as usual:
11+
12+```sh
13+git lfs install # once per machine
14+cd website
15+git lfs track "*.psd" "assets/video/**"
16+git add .gitattributes assets/hero.psd
17+git commit -m "Track design files with LFS"
18+git push origin main
19+```
20+
21+`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.
22+
23+Cloning and checking out download LFS files automatically when `git-lfs` is installed:
24+
25+```sh
26+git clone https://diffinery.com/acme/website.git
27+git lfs pull # fetch LFS files for the current checkout, if you skipped them
28+```
29+
30+## Endpoint and authentication
31+
32+Git LFS finds the server from the remote URL, so no configuration is needed. The endpoint is:
33+
34+```text
35+https://diffinery.com/acme/website.git/info/lfs
36+```
37+
38+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](/docs/repositories/git#authentication)).
39+
40+| Operation | Needs |
41+| --- | --- |
42+| Download from a public repository | Nothing |
43+| Download from a private repository | A token with at least the `read` scope, for an account with read access |
44+| Upload | A token with the `write` or `admin` scope, for an account with write access. The repository must not be archived. |
45+
46+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 can download LFS files. A batch request for `upload` and the `PUT` that sends the file are writes:
47+
48+| Caller | Upload refused with |
49+| --- | --- |
50+| No credentials | `401 authentication required` (the `git-lfs` client then asks for credentials) |
51+| A `read`-scoped token | `403 this token needs the write scope` |
52+| A token whose account has less than write access, or a job token | `403 write access required` |
53+| Anyone, on an archived repository | `403 repository is archived (read-only)` |
54+
55+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.
56+
57+### Disable the locking check
58+
59+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:
60+
61+```sh
62+git config lfs.https://diffinery.com/acme/website.git/info/lfs.locksverify false
63+```
64+
65+`git lfs lock` and `git lfs unlock` don't work on Diffinery.
66+
67+## How the batch API works
68+
69+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.
70+
71+```http
72+POST /acme/website.git/info/lfs/objects/batch HTTP/1.1
73+Host: diffinery.com
74+Authorization: Basic eC10b2tlbjpkZnlfLi4u
75+Accept: application/vnd.git-lfs+json
76+Content-Type: application/vnd.git-lfs+json
77+
78+{"operation": "download", "objects": [{"oid": "4d7a2146...a6b0", "size": 52428800}]}
79+```
80+
81+The response lists an action for each object:
82+
83+```json
84+{
85+ "transfer": "basic",
86+ "hash_algo": "sha256",
87+ "objects": [
88+ {
89+ "oid": "4d7a2146...a6b0",
90+ "size": 52428800,
91+ "authenticated": true,
92+ "actions": {
93+ "download": {
94+ "href": "https://diffinery.com/acme/website.git/info/lfs/objects/4d7a2146...a6b0",
95+ "header": { "Authorization": "Basic eC10b2tlbjpkZnlfLi4u" },
96+ "expires_in": 3600
97+ }
98+ }
99+ }
100+ ]
101+}
102+```
103+
104+| Operation | Object state | Response for the object |
105+| --- | --- | --- |
106+| `download` | Stored | A `download` action pointing at `GET .../info/lfs/objects/<oid>`. |
107+| `download` | Not stored | `"error": {"code": 404, "message": "object not found"}` |
108+| `upload` | Not stored yet | An `upload` action pointing at `PUT .../info/lfs/objects/<oid>`. |
109+| `upload` | Already stored | No action, so the client skips it. |
110+| either | `oid` isn't 64 lowercase hex characters | `"error": {"code": 422, "message": "invalid oid"}` |
111+
112+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.
113+
114+At most 1,000 objects are handled per batch request; the `git-lfs` client sends 100 at a time by default.
115+
116+## Storage
117+
118+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.
119+
120+## LFS files in the web UI, the API and archives
121+
122+Outside the LFS endpoints, Diffinery sees only the small pointer files that Git stores in place of LFS content:
123+
124+- 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.
125+- `GET /api/v3/repos/acme/website/contents/{path}` and the MCP `get_file` tool return the pointer.
126+- [Source code archives](/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.
127+
128+To hand out a large file to people without Git, attach it to a release instead (up to 100 MB per asset). See [Releases](/docs/collaboration/releases).
129+
130+## Use LFS files in workflows
131+
132+The `actions/checkout` step in Diffinery workflows doesn't download LFS files: with `lfs: true` it prints the warning `Git LFS is not supported by the Diffinery checkout built-in; LFS pointers will not be resolved.` and leaves pointer files in the workspace.
133+
134+Download them in a step after the checkout. Hosted runners have `git-lfs` installed, and the checkout leaves the job's token configured for the repository's URL (unless you set `persist-credentials: false`), so this works without extra credentials:
135+
136+```yaml
137+name: Build
138+on: push
139+jobs:
140+ build:
141+ runs-on: ubuntu-latest
142+ steps:
143+ - uses: actions/checkout@v4
144+ - name: Download LFS files
145+ run: git lfs pull
146+ - run: ls -lh assets/
147+```
148+
149+To download only some files, use `git lfs pull --include="assets/video/**"`. The job token can read the repository but not upload LFS objects; use a secret holding a personal or agent token to push from a workflow. On self-hosted runners, install `git-lfs` on the machine first.
150+
151+Other differences in the checkout step: `sparse-checkout` is also ignored with a warning (a full checkout is done). See [Compatibility with GitHub Actions](/docs/actions/compatibility).
152+
153+## Moving LFS files from GitHub
154+
155+`git clone --mirror` and `diffinery migrate github` copy LFS pointers, not the LFS files. Copy the files yourself from a clone that has them:
156+
157+```sh
158+git clone https://github.com/acme/website.git
159+cd website
160+git lfs fetch --all origin
161+git remote add diffinery https://diffinery.com/acme/website.git
162+git lfs push --all diffinery
163+```
164+
165+See [Migrate from GitHub](/docs/migrate-from-github).
166+
167+## Limits
168+
169+| Limit | Value |
170+| --- | --- |
171+| Objects per batch request | 1,000 |
172+| Transfer adapter | `basic` only (no multipart or resumable uploads) |
173+| 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. |
174+| Hash algorithm | SHA-256 |
175+| File locking | Not supported |
176+| Push protection | LFS file contents are not scanned for secrets, only the pointer files in the Git push. |
177+
178+## Troubleshooting
179+
180+| What you see | Cause and fix |
181+| --- | --- |
182+| `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). |
183+| `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`. |
184+| 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. |
185+| 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). |
186+| Upload fails for a very large file | Files over 100 MB can't be uploaded. Split the file, or keep it outside the repository. |
187+| Workflow sees small text files instead of the real files | The checkout step doesn't fetch LFS files. Add a `git lfs pull` step. |
188+| `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. |
addedapps/diffinery/docs/repositories/git.md+320−0View file
@@ -1,0 +1,320 @@
1+---
2+title: Git over HTTPS
3+description: Clone, fetch and push Diffinery repositories with Git over smart HTTP, authenticate with tokens, and what Diffinery checks and triggers on every push.
4+---
5+
6+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.
7+
8+There is no SSH access. Use HTTPS with a token.
9+
10+## Clone URLs
11+
12+| What | URL |
13+| --- | --- |
14+| Clone, fetch and push | `https://diffinery.com/acme/website.git` |
15+| The same without `.git` | `https://diffinery.com/acme/website` |
16+| Web page | `https://diffinery.com/acme/website` |
17+| Download a ref without Git | `https://diffinery.com/acme/website/archive/main.zip` (or `.tar.gz`) |
18+
19+Both forms of the Git URL work. Archives are covered in [Source code archives](/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`.
20+
21+```sh
22+git clone https://diffinery.com/acme/website.git
23+cd website
24+git push origin main
25+```
26+
27+You need Git 2.26 or newer (see [Protocol versions](#protocol-versions)).
28+
29+## Authentication
30+
31+Git authenticates with a personal access token (`dfy_...`) used as the HTTP password. Passkeys are only for the browser.
32+
33+| Operation | Public repository | Private repository |
34+| --- | --- | --- |
35+| Clone and fetch | No credentials needed | A token with at least the `read` scope, for an account with read access |
36+| Push | A token with the `write` (or `admin`) scope, for an account with write access | Same |
37+
38+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](/docs/repositories/git-lfs) files) on any repository its account can read, and is refused for every push.
39+
40+Create a token under **Settings → Access tokens** (`https://diffinery.com/settings/tokens`) or with `diffinery token create`. See [Personal access tokens](/docs/accounts/tokens).
41+
42+When Git asks for credentials:
43+
44+- **Username**: anything. Diffinery ignores it. Your username, `x-token` or `git` all work.
45+- **Password**: the token.
46+
47+Diffinery also accepts the token as the username with an empty password, and as an `Authorization: Bearer dfy_...` header.
48+
49+### Let the CLI handle credentials
50+
51+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 (needs Node.js 22 or later):
52+
53+```sh
54+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
55+```
56+
57+Then:
58+
59+```sh
60+diffinery login # paste a dfy_ token; saved to ~/.config/diffinery/config.json (mode 0600)
61+diffinery auth setup-git # configure git to ask diffinery for credentials
62+git clone https://diffinery.com/acme/website.git
63+```
64+
65+`diffinery auth setup-git` runs the equivalent of:
66+
67+```sh
68+git config --global --unset-all credential.https://diffinery.com.helper
69+git config --global --add credential.https://diffinery.com.helper ""
70+git config --global --add credential.https://diffinery.com.helper "!diffinery auth git-credential"
71+```
72+
73+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.
74+
75+> **Note:** Git only asks a credential helper (or you) for a token when the server answers `401`. Private repositories do that. When you push to a **public** repository without credentials, the server answers `403` (`write access required`) instead, so Git never asks and the push fails with `The requested URL returned error: 403`. Pass the token with the push itself, for example `git -c http.extraHeader="Authorization: Bearer $DIFFINERY_TOKEN" push`, as shown in [Other ways to pass the token](#other-ways-to-pass-the-token).
76+
77+`diffinery repo clone acme/website` clones with the token passed as a one-off HTTP header that is never written to `.git/config`.
78+
79+### Other ways to pass the token
80+
81+| Method | Example | Notes |
82+| --- | --- | --- |
83+| 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. |
84+| 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. |
85+| 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. |
86+
87+Inside Diffinery Actions, `actions/checkout` authenticates with the job's own token automatically. That token can read the repository but not push to it (see [Job tokens](#job-tokens)).
88+
89+### Token scopes and roles
90+
91+What a push or fetch may do is the lower of your role on the repository and your token's scope:
92+
93+| Token scope | Acts as at most |
94+| --- | --- |
95+| `read` | read: clone and fetch only. Every push is refused. |
96+| `write` | maintain: push branches, move and delete tags |
97+| `admin` | admin: also bypass branch protection rules that let admins bypass, and bypass push protection |
98+
99+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)).
100+
101+### Job tokens
102+
103+Each Actions job gets a token (`GITHUB_TOKEN`, starting with `djt_`). It works for Git and the REST API on the job's own repository only, with **read** access, and stops working when the job completes. A workflow can clone and fetch its repository but can't push to it with this token. To push from a workflow, store a personal or agent token as a secret and use that. This differs from GitHub, where `GITHUB_TOKEN` can push when the workflow has `contents: write`.
104+
105+## Protocol versions
106+
107+| Operation | Protocol | Notes |
108+| --- | --- | --- |
109+| Clone and fetch (`git-upload-pack`) | Git protocol v2 only | Git 2.26 and newer use v2 by default. |
110+| Push (`git-receive-pack`) | Smart HTTP v0 | Works with every Git version that supports smart HTTP. |
111+| Dumb HTTP | Not supported | |
112+
113+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`):
114+
115+```text
116+Diffinery requires Git protocol v2 (git 2.26 or newer, or run: git config --global protocol.version 2)
117+```
118+
119+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`.
120+
121+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.
122+
123+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.
124+
125+## Shallow clones
126+
127+Shallow clones and fetches work:
128+
129+```sh
130+git clone --depth 1 https://diffinery.com/acme/website.git
131+git clone --shallow-since=2026-01-01 https://diffinery.com/acme/website.git
132+git fetch --depth 50
133+git fetch --unshallow # get the full history later
134+```
135+
136+Differences and limits:
137+
138+| Option | Behaviour |
139+| --- | --- |
140+| `--depth <n>` | Supported. |
141+| `--shallow-since <date>` | Supported. |
142+| `--unshallow` | Supported. |
143+| `--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. |
144+| `--shallow-exclude <ref>` | Not supported. The fetch fails with `--shallow-exclude is not supported; use --depth or --shallow-since`. |
145+| `--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`. |
146+| `--sparse` / sparse checkout | Works on the client side, but every object is still downloaded. |
147+
148+`actions/checkout` in Diffinery workflows uses `fetch-depth: 1` by default, like GitHub.
149+
150+## Pull request refs
151+
152+Like GitHub, Diffinery keeps two refs for each open pull request:
153+
154+| Ref | Points at |
155+| --- | --- |
156+| `refs/pull/<n>/head` | The pull request's head commit. |
157+| `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. |
158+
159+Fetch them by name:
160+
161+```sh
162+git fetch origin pull/12/head:pr-12
163+git checkout pr-12
164+
165+# The test merge that pull_request workflows run on
166+git fetch origin refs/pull/12/merge
167+git log -1 FETCH_HEAD
168+```
169+
170+To fetch every pull request head, add a refspec:
171+
172+```sh
173+git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pr/*'
174+git fetch origin
175+```
176+
177+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](/docs/collaboration/pull-requests).
178+
179+## Push options
180+
181+Diffinery reads one push option:
182+
183+| Option | Effect |
184+| --- | --- |
185+| `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. |
186+
187+```sh
188+git push -o allow-secrets="test fixture, key is revoked" origin main
189+```
190+
191+Other push options are accepted and ignored. See [Push protection](/docs/security/push-protection).
192+
193+## What happens when you push
194+
195+Every push goes through these steps, in this order. If an earlier step fails, nothing after it happens.
196+
197+1. **Authorization.** You must send a token: pushes without credentials are refused. Your account needs write access, your token the `write` or `admin` scope, and the repository must not be archived. Otherwise the server answers HTTP 401 (`authentication required`, when a private repository gets no credentials, which makes Git ask for them) or HTTP 403 (`write access required` when your role or a `read`-scoped token only allows reading, or `repository is archived (read-only)`). Git shows these as `Authentication failed` or `The requested URL returned error: 403`.
198+2. **Size check.** A push larger than 100 MB 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.
199+3. **Unpacking.** The pack is parsed and every object checked.
200+4. **Push protection.** New text files under 1 MB 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:
201+
202+ ```text
203+ remote: Push blocked: possible secrets detected.
204+ remote: AWS access key in blob 3f2a9c81d0
205+ remote: Remove the secret from your commits, or rotate it and contact an admin.
206+ ```
207+
208+ 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](/docs/security/push-protection).
209+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`).
210+6. **Ref update.** Each ref is moved with a compare-and-swap. If someone else pushed in between, Git shows `fetch first (stale info)`.
211+7. **After the update**, for every ref that moved:
212+ - A `push` event goes to webhooks, notifications and the audit log (`git.push`, `git.force_push` or `git.delete_ref`).
213+ - 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`.
214+ - For a new branch with no pull request, the output suggests one:
215+
216+ ```text
217+ remote: Create a pull request for 'feature/login':
218+ remote: https://diffinery.com/acme/website/compare/feature%2Flogin
219+ ```
220+
221+ - 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](/docs/actions/events).
222+ - A push to the default branch updates the repository's `on: schedule` workflows.
223+ - A merge queue whose base branch moved is rechecked. See [Merge queue](/docs/collaboration/merge-queue).
224+
225+Deleting a branch or tag sends the `push` event but starts no workflows.
226+
227+## Branches, force pushes and deletion
228+
229+On a branch without a protection rule, anyone with write access can create, update, force push and delete it:
230+
231+```sh
232+git push origin feature/login # create or update
233+git push --force-with-lease origin feature/login
234+git push origin --delete feature/login # delete
235+```
236+
237+The default branch can never be deleted (`cannot delete the default branch`). Change the default branch first under **Settings → General**.
238+
239+On a branch that matches a [branch protection](/docs/repositories/branch-protection) rule:
240+
241+| Rule | Push refused with |
242+| --- | --- |
243+| **Require a pull request before merging** | `protected branch: changes must be made through a pull request` |
244+| Force push without **Allow force pushes** | `protected branch: force push not allowed` |
245+| Deletion without **Allow deletion** | `protected branch: deletion not allowed` |
246+| **Require linear history** and the push adds a merge commit | `protected branch: merge commits not allowed (linear history)` |
247+
248+Admins (with an `admin`-scoped token) bypass these rules on push unless the rule has **Do not allow administrators to bypass these rules**.
249+
250+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](/docs/repositories/browsing-code#branches)).
251+
252+## Tags
253+
254+```sh
255+git tag -a v1.2.0 -m "Release 1.2.0"
256+git push origin v1.2.0 # or: git push --tags
257+git push origin --delete v1.2.0
258+```
259+
260+| Action | Needs |
261+| --- | --- |
262+| Create a new tag | write |
263+| Move an existing tag (force push it) | maintain |
264+| Delete a tag | maintain |
265+
266+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.
267+
268+Tags and the other Diffinery features:
269+
270+- **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](/docs/collaboration/releases).
271+- **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`.
272+- **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](/docs/actions/environments).
273+- **Workflows.** Pushing a tag starts `on: push` workflows whose `tags` filters match. Publishing a release starts `on: release` workflows. See [Events that trigger workflows](/docs/actions/events).
274+
275+## Agents and protected branches
276+
277+Agent accounts push like people, with these guardrails:
278+
279+- 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`.
280+- They can't bypass push protection, whatever their role.
281+- 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.
282+
283+So an agent's normal flow is: push a branch, then open a pull request. See [Agents](/docs/agents/overview).
284+
285+## Limits and differences from GitHub
286+
287+| Topic | Diffinery |
288+| --- | --- |
289+| Transport | HTTPS only. No SSH and no `git://`. |
290+| Fetch protocol | Protocol v2 only (Git 2.26+, or `protocol.version=2`). |
291+| Push size | 100 MB per push. Split bigger histories (see [Troubleshooting](#troubleshooting)). |
292+| Large files | Use [Git LFS](/docs/repositories/git-lfs). Files over 1 MB in a push are not scanned by push protection. |
293+| Partial clone, `--shallow-exclude` | Not supported yet. |
294+| `--deepen` | Behaves like `--depth`. |
295+| Packs sent to clients | Sent without deltas, so clones and fetches download more bytes than from GitHub. |
296+| Signed pushes | Not supported. Signed commits push normally. |
297+| `GITHUB_TOKEN` in workflows | Read-only; can't push. |
298+| Repository rename | The old URL doesn't redirect. Update remotes. |
299+| `refs/pull/*` | Same as GitHub: fetchable, not pushable, hidden from `push --mirror`. |
300+| Ref names | Must start with `refs/` and follow Git's ref name rules, otherwise `invalid ref name`. |
301+
302+## Troubleshooting
303+
304+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.
305+
306+| What you see | Cause | Fix |
307+| --- | --- | --- |
308+| `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`. |
309+| `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). |
310+| `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`. |
311+| `The requested URL returned error: 403` on push | Your role or your token's scope only allows reading (`write access required`), or the repository is archived (`repository is archived (read-only)`). | Ask an admin for write access, use a `write` token, or unarchive the repository. |
312+| `The requested URL returned error: 403` when pushing to a public repository, and Git never asked for a token | Git sent no credentials. For a public repository the server answers an anonymous push with `403` (`write access required`), not `401`, so neither Git nor your credential helper offers a token. | Send the token with the push: `git -c http.extraHeader="Authorization: Bearer $DIFFINERY_TOKEN" push origin main`. |
313+| `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. |
314+| `[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](/docs/security/push-protection). |
315+| `[remote rejected] ... (fetch first (stale info))` | The branch moved since you fetched. | `git pull --rebase` (or fetch and merge), then push. |
316+| `[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. |
317+| `[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. |
318+| `remote error: --shallow-exclude is not supported; use --depth or --shallow-since` | `--shallow-exclude` isn't supported. | Use `--depth` or `--shallow-since`. |
319+| `the receiving end does not support --signed push` | Signed pushes aren't supported. | Push without `--signed`. |
320+| `warning: filtering not recognized by server, ignoring` | Partial clones aren't supported. | Nothing to do; Git made a full clone. |
addedapps/diffinery/docs/repositories/overview.md+423−0View file
@@ -1,0 +1,423 @@
1+---
2+title: Repositories
3+description: Create, configure, share, archive, rename and delete Diffinery repositories, and use topics, READMEs, languages and source archives.
4+---
5+
6+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, 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](/docs/repositories/git).
7+
8+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.
9+
10+## User and organization repositories
11+
12+| Owner | Who can create repositories there | Who gets access automatically |
13+| --- | --- | --- |
14+| Your user account (`octocat/notes`) | Only you (and site administrators) | You are **admin**. Nobody else, unless you add collaborators or make it public. |
15+| An organization (`acme/website`) | Any member of the organization | Organization **members** get **write**. Organization **owners** and **admins** get **admin**. |
16+
17+You can add collaborators to either kind for extra or narrower 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. See [Organizations](/docs/accounts/organizations).
18+
19+An agent that creates a repository with `POST /api/v3/user/repos` creates it for the account that owns the agent. See [Agents](/docs/agents/overview).
20+
21+## Create a repository
22+
23+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.
24+
25+### In the web UI
26+
27+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`.
28+2. On **Create a new repository**, fill in:
29+
30+ | Field | Notes |
31+ | --- | --- |
32+ | **Owner** | You or one of your organizations. |
33+ | **Repository name** | See [Repository names](#repository-names). |
34+ | **Description** (optional) | Up to 350 characters. |
35+ | **Visibility** | **Private** (default) or **Public**. See [Visibility](#visibility). |
36+ | **Default branch** | Defaults to `main`. |
37+
38+3. Click **Create repository**.
39+
40+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.
41+
42+### With the CLI
43+
44+The CLI needs a token (`diffinery login`). Install it with:
45+
46+```sh
47+curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery
48+```
49+
50+It needs Node.js 22 or later. Then:
51+
52+```sh
53+# In your own account, private by default
54+diffinery repo create notes
55+
56+# In an organization, public, with a description and a different default branch
57+diffinery repo create website --org acme --public -d "Marketing site" --default-branch trunk
58+```
59+
60+| Option | Meaning |
61+| --- | --- |
62+| `--org ORG` | Create it in this organization instead of your account. |
63+| `--private` | Only people with access can see it (the default). |
64+| `--public` | Anyone can see and clone it. |
65+| `-d, --description TEXT` | Description. |
66+| `--default-branch BRANCH` | Default branch name (default `main`). |
67+| `--json` | Print the created repository as JSON. |
68+
69+The command prints the repository URL and the `git remote add origin ...` line to use. See [CLI reference](/docs/reference/cli) for every command.
70+
71+### With the REST API
72+
73+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`.
74+
75+```sh
76+# In your own account
77+curl -X POST https://diffinery.com/api/v3/user/repos \
78+ -H "Authorization: Bearer dfy_..." \
79+ -H "content-type: application/json" \
80+ -d '{"name": "notes", "description": "Personal notes"}'
81+
82+# In an organization, public
83+curl -X POST https://diffinery.com/api/v3/orgs/acme/repos \
84+ -H "Authorization: Bearer dfy_..." \
85+ -H "content-type: application/json" \
86+ -d '{"name": "website", "description": "Marketing site", "visibility": "public", "default_branch": "main"}'
87+```
88+
89+| Field | Type | Default | Notes |
90+| --- | --- | --- | --- |
91+| `name` | string | (required) | See [Repository names](#repository-names). |
92+| `description` | string | `""` | |
93+| `visibility` | `"public"` or `"private"` | `"private"` | A misspelled value fails with `visibility must be public or private` (HTTP 422). |
94+| `private` | boolean | | As on GitHub: `false` creates a public repository, `true` a private one. When you send both, `visibility` wins. |
95+| `default_branch` | string | `"main"` | |
96+
97+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`.
98+
99+Organization owners and admins get an email when someone else creates a repository in their organization (see [Notifications](/docs/accounts/notifications)).
100+
101+## Repository names
102+
103+- Start with a letter or digit.
104+- Then letters, digits, `.`, `-` and `_`, up to 100 characters in total.
105+- Must not end in `.git`, and must not be `.` or `..`.
106+- 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.
107+
108+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).
109+
110+## Visibility
111+
112+| Visibility | Who can see and clone it |
113+| --- | --- |
114+| **Private** (default) | The owner, the organization's members (for organization repositories), collaborators, and site administrators. |
115+| **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. |
116+
117+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`. 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.
118+
119+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 get `401` (`authentication required`) instead, so Git knows to ask for credentials.
120+
121+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.
122+
123+## Repository settings
124+
125+The **Settings** tab appears only for people with the **admin** role on the repository. It has five pages:
126+
127+| Page | Path | What it controls |
128+| --- | --- | --- |
129+| **General** | `/acme/website/settings` | Name, description, default branch, visibility, archive, delete. |
130+| **Collaborators** | `/acme/website/settings/access` | Direct access for people and agents, and email invitations, from one **Add people** form. |
131+| **Branch protection** | `/acme/website/settings/branches` | Rules for matching branches. See [Branch protection](/docs/repositories/branch-protection). |
132+| **Secrets and variables** | `/acme/website/settings/secrets` | Actions secrets and variables. See [Secrets and variables](/docs/actions/secrets-and-variables). |
133+| **Webhooks** | `/acme/website/settings/webhooks` | HTTPS endpoints that receive events. See [Webhooks](/docs/repositories/webhooks). |
134+
135+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`).
136+
137+Some repository features have no settings page:
138+
139+- **Releases** live at `/acme/website/releases`, and anyone with write access manages them there. See [Releases](/docs/collaboration/releases).
140+- **Deployment environments** (with their own secrets, variables and branch policies) are managed with the REST API. See [Environments](/docs/actions/environments).
141+- **Topics** are set with the REST API. See [Topics](#topics).
142+
143+### General
144+
145+The **General** page has these fields, saved together with **Save changes**:
146+
147+- **Repository name**: renames the repository. See [Rename a repository](#rename-a-repository).
148+- **Description**: up to 350 characters, shown under the repository name and in the **About** box.
149+- **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.
150+- **Visibility**: **Private** or **Public**.
151+
152+Below it, the **Danger zone** has **Archive this repository** and **Delete this repository**.
153+
154+### Secrets and variables
155+
156+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 }}`.
157+
158+- Names use letters, digits and `_` and don't start with a digit. They are stored in upper case.
159+- Secret names can't start with `GITHUB_`.
160+- Saving an existing name replaces its value.
161+- 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.
162+
163+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](/docs/actions/secrets-and-variables).
164+
165+### Webhooks
166+
167+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](/docs/repositories/webhooks).
168+
169+## Collaborators and roles
170+
171+A role decides what someone can do in a repository. Roles are the same as GitHub's:
172+
173+| Role | Can |
174+| --- | --- |
175+| **read** | View and clone. |
176+| **triage** | Also manage issues and labels. |
177+| **write** | Also push, open and merge pull requests, run workflows, create branches and tags. |
178+| **maintain** | Also move and delete tags. |
179+| **admin** | Also change settings, secrets, branch protection, webhooks and access, archive and delete the repository. |
180+
181+A person's role is the highest of: owning the repository (admin), their organization role (members write, 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](/docs/accounts/permissions) for the full matrix.
182+
183+### Add a collaborator in the web UI
184+
185+Open **Settings → Collaborators** and use the **Add people** form:
186+
187+1. In **Username or email**, enter a person's or agent's username (for example `octocat` or `review-bot`, with or without a leading `@`) or an email address.
188+2. Pick a **Role** (**Read**, **Triage**, **Write**, **Maintain** or **Admin**; the default is **Write**).
189+3. Click **Add**.
190+
191+What happens depends on what you entered:
192+
193+| You entered | Result |
194+| --- | --- |
195+| The username of a person or agent | They get the role right away, a notification on Diffinery and an email. The page confirms with "octocat now has write access. We let them know." |
196+| The email address of someone who has an account | Same as their username. |
197+| An email address nobody uses yet | Diffinery emails an invitation 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 creates the person's account and passkey and gives them the role on this repository. See [Invitations](/docs/accounts/invitations). |
198+
199+Errors you may see:
200+
201+| Message | Meaning |
202+| --- | --- |
203+| `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. |
204+| `<name> is an organization; add its people instead` | Organizations can't be collaborators. Add the people one by one. |
205+| `<name>'s account is disabled` | A site administrator disabled that account. |
206+| `enter a valid email address` | The text contains `@` but isn't a valid address. |
207+
208+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.
209+
210+Organization members don't appear in this table: they get their access from the organization.
211+
212+### Manage collaborators with the API
213+
214+These need an admin role on the repository.
215+
216+```sh
217+# List collaborators (any reader can list)
218+curl https://diffinery.com/api/v3/repos/acme/website/collaborators -H "Authorization: Bearer dfy_..."
219+
220+# Add or change a collaborator
221+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/octocat \
222+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
223+ -d '{"permission": "maintain"}'
224+
225+# Remove a collaborator
226+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/collaborators/octocat -H "Authorization: Bearer dfy_..."
227+```
228+
229+`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`.
230+
231+## Archive a repository
232+
233+Archiving makes a repository read-only: nobody can push, open or change issues and pull requests, create or edit releases, or start runs, and scheduled workflows stop. Everything stays readable, cloneable and downloadable as an archive. Admins can still change settings and unarchive it.
234+
235+- **Web UI**: **Settings → General → Danger zone → Archive**, then confirm. **Unarchive** reverses it.
236+- **API**: `PATCH /api/v3/repos/acme/website` with `{"archived": true}` (or `false`).
237+
238+Archived repositories show an **archived** badge. Requests that would write to one fail with `repository is archived (read-only)` (HTTP 403), and a `git push` fails with that message. Most write endpoints of the REST API (for example topics, releases, statuses, deployments and environments) refuse every write to an archived repository, even from admins; unarchive it first. Exceptions: the admin-only endpoints for collaborators, branch protection, webhooks, secrets and variables keep working, and the REST endpoints that open and edit issues, edit pull requests, add and edit comments, submit reviews and request reviewers don't check the archived state yet (see [Issues](/docs/collaboration/issues#permissions)).
239+
240+## Rename a repository
241+
242+Admins can rename a repository on **Settings → General** (change **Repository name** and click **Save changes**) or with:
243+
244+```sh
245+curl -X PATCH https://diffinery.com/api/v3/repos/acme/website \
246+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
247+ -d '{"name": "site"}'
248+```
249+
250+> **Important:** Renaming changes the repository's URL and clone URL, and the old URL does not redirect (unlike GitHub). Update your remotes afterwards: `git remote set-url origin https://diffinery.com/acme/site.git`. Also update anything else that uses the old name, such as CI scripts, `uses: acme/website/...` references to reusable workflows and webhook consumers.
251+
252+You can't move a repository to a different owner. To move one, create a repository under the new owner and push everything to it (see [Moving a repository with plain Git](/docs/migrate-from-github#move-with-plain-git)).
253+
254+## Delete a repository
255+
256+Deleting permanently removes the repository with its history, issues, pull requests, releases, runs, secrets, environments and webhooks. It can't be undone.
257+
258+- **Web UI**: **Settings → General → Danger zone → Delete this repository**. Type the full name (for example `acme/website`) and click **Delete this repository**.
259+- **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).
260+
261+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.
262+
263+## Change the default branch
264+
265+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.
266+
267+## Watch a repository
268+
269+Each repository page has a **Watch** menu in the header that decides which activity notifies you:
270+
271+| Option | You are notified about |
272+| --- | --- |
273+| **Watch: participating** (default) | Only issues and pull requests you take part in or are @mentioned in. |
274+| **Watch: all activity** | Everything in the repository. |
275+| **Watch: ignore** | Nothing from this repository. |
276+
277+With the API (GitHub-compatible):
278+
279+```sh
280+# Watch all activity
281+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \
282+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" -d '{"subscribed": true}'
283+
284+# Ignore the repository
285+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/subscription \
286+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" -d '{"ignored": true}'
287+
288+# Back to participating only
289+curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/subscription -H "Authorization: Bearer dfy_..."
290+```
291+
292+`GET` on the same path returns `404` when you are on the default (participating). **Settings → Notifications** lists the repositories you watch. See [Notifications](/docs/accounts/notifications).
293+
294+## Look up repositories
295+
296+| Task | Web | CLI | API |
297+| --- | --- | --- | --- |
298+| Repositories you can see | Your dashboard | `diffinery repo list` | `GET /api/v3/user/repos` |
299+| One owner's repositories | `https://diffinery.com/acme` | `diffinery repo list --owner acme` | `GET /api/v3/orgs/acme/repos` or `GET /api/v3/users/octocat/repos` |
300+| One repository | `https://diffinery.com/acme/website` | `diffinery repo view acme/website` | `GET /api/v3/repos/acme/website` |
301+
302+`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.
303+
304+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](/docs/reference/rest-api).
305+
306+## Topics
307+
308+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`).
309+
310+There is no field for topics in the web UI. Set them with the REST API:
311+
312+```sh
313+# Read them (anyone who can read the repository)
314+curl https://diffinery.com/api/v3/repos/acme/website/topics -H "Authorization: Bearer dfy_..."
315+
316+# Replace the whole list (admin role; an admin-scoped token)
317+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/topics \
318+ -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
319+ -d '{"names": ["docs", "static-site"]}'
320+```
321+
322+Both return `{"names": [...]}`. `PUT` replaces every topic; send `{"names": []}` to remove them all. The change is recorded in the audit log as `repo.topics`.
323+
324+Rules, the same as GitHub's:
325+
326+- Names are trimmed and lowercased, and duplicates and empty names are dropped.
327+- 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).
328+- A repository has at most 20 topics (`a repository can have at most 20 topics`, HTTP 422).
329+- `names` must be an array (`names must be an array`, HTTP 422).
330+- An archived repository refuses changes.
331+
332+## README
333+
334+The repository home page and every folder page render the folder's README (see [Browsing code](/docs/repositories/browsing-code#readmes-and-markdown)). Scripts can fetch it with GitHub's endpoints:
335+
336+```sh
337+# The README at the root of the default branch, as a contents object (base64 content)
338+curl https://diffinery.com/api/v3/repos/acme/website/readme -H "Authorization: Bearer dfy_..."
339+
340+# The README of a folder, at another ref, as raw text
341+curl "https://diffinery.com/api/v3/repos/acme/website/readme/docs?ref=v1.2.0" \
342+ -H "Authorization: Bearer dfy_..." -H "Accept: application/vnd.github.raw"
343+```
344+
345+| Detail | Behaviour |
346+| --- | --- |
347+| Which file | A file named `README`, or `README` with any one extension, in any capitalisation. `README.md` and `README.markdown` win over the others. |
348+| `ref` | A branch, tag or commit. Defaults to the default branch. |
349+| 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. |
350+| No README | `404 Not Found`. |
351+
352+## Languages
353+
354+`GET /api/v3/repos/acme/website/languages` returns how many bytes of each language the default branch contains, largest first, like GitHub:
355+
356+```json
357+{ "TypeScript": 182344, "CSS": 20311, "Shell": 1022 }
358+```
359+
360+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 `{}`.
361+
362+## Source code archives
363+
364+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).
365+
366+| Format | Web URL | REST API |
367+| --- | --- | --- |
368+| zip | `https://diffinery.com/acme/website/archive/<ref>.zip` | `GET /api/v3/repos/acme/website/zipball/<ref>` |
369+| tar.gz | `https://diffinery.com/acme/website/archive/<ref>.tar.gz` | `GET /api/v3/repos/acme/website/tarball/<ref>` |
370+
371+`<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.
372+
373+```sh
374+# Download the v1.2.0 tag with a token
375+curl -fL -o website.tar.gz -H "Authorization: Bearer dfy_..." \
376+ https://diffinery.com/api/v3/repos/acme/website/tarball/v1.2.0
377+
378+# Download main of a public repository
379+curl -fLO https://diffinery.com/acme/website/archive/main.zip
380+```
381+
382+What the archive contains:
383+
384+- 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.
385+- Files keep their executable bit, symbolic links stay links, and every entry has the commit's date. Submodules are empty folders.
386+- The full commit SHA is stored as the archive comment (for tar, in a pax header, as `git archive` does).
387+- Git LFS files appear as their small pointer files, not their content.
388+- Entries with names Git refuses to check out (such as `.git` in any spelling) are left out.
389+
390+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; it can be built again later.
391+
392+Limits:
393+
394+| Limit | Error |
395+| --- | --- |
396+| 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) |
397+| At most 100,000 tree entries | `the tree has more than 100,000 entries; clone it with git instead` (HTTP 413) |
398+| Folders nested at most 128 deep | `the tree is nested too deeply` (HTTP 413) |
399+| 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) |
400+| 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) |
401+| The ref must exist | `Not Found` (HTTP 404) |
402+
403+For anything bigger, clone with Git (`git clone --depth 1` is fastest; see [Git over HTTPS](/docs/repositories/git#shallow-clones)).
404+
405+## Troubleshooting
406+
407+| Message | Cause and fix |
408+| --- | --- |
409+| `invalid repository name: use letters, numbers, '.', '-' and '_'` | The name breaks a [naming rule](#repository-names). |
410+| `a repository with this name already exists` | The owner already has a repository with that name (names are case-insensitive). |
411+| `you are not a member of this organization` | Ask an organization owner to add you before creating repositories there. |
412+| `you can only create repositories for yourself or your organizations` | You tried to create a repository for another person's account. |
413+| `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. |
414+| `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. |
415+| `this token needs the write scope` | Your token is `read`-scoped and can only read. Create a token with the `write` or `admin` scope. |
416+| `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. |
417+| `repository is archived (read-only)` | Unarchive the repository first. |
418+| `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. |
419+| `visibility must be public or private` | Send `"public"` or `"private"` (or use the `private` boolean). |
420+| `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. |
421+| `<name> is not a valid topic...` or `a repository can have at most 20 topics` | Follow the [topic rules](#topics). |
422+| `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. |
423+| `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. |
addedapps/diffinery/docs/repositories/webhooks.md+349−0View file
@@ -1,0 +1,349 @@
1+---
2+title: Webhooks
3+description: Send repository events to your own HTTPS endpoint, verify their signatures, and check delivery status, with GitHub-compatible headers and payloads.
4+---
5+
6+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.
7+
8+## How webhooks work
9+
10+- Webhooks belong to one repository. You need the **admin** role on it to create, list or delete them.
11+- Each webhook has a payload URL (HTTPS only), a list of events, and a signing secret.
12+- 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.
13+- 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.
14+- 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).
15+- Diffinery records the HTTP status and time of the most recent delivery attempt of each webhook.
16+
17+## Events
18+
19+| Event | Sent when | Actions |
20+|---|---|---|
21+| `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 |
22+| `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, or enters or leaves the merge queue | `opened`, `synchronize`, `edited`, `closed`, `reopened`, `assigned`, `review_requested`, `ready_for_review`, `converted_to_draft`, `enqueued`, `dequeued` |
23+| `pull_request_review` | A review is submitted | `submitted` |
24+| `issues` | An issue is opened, edited (title or body), closed, reopened or assigned | `opened`, `edited`, `closed`, `reopened`, `assigned` |
25+| `issue_comment` | A comment is added to an issue or a pull request | `created` |
26+| `workflow_run` | A workflow run is created or finishes | `requested`, `completed` |
27+| `release` | A release is created, published, edited, unpublished or deleted, including drafts | `created`, `published`, `released`, `prereleased`, `edited`, `unpublished`, `deleted` |
28+| `status` | A commit status is posted | none |
29+| `deployment` | A deployment is created with the REST API | `created` |
30+| `deployment_status` | A deployment gets a new status, from the REST API or from a workflow job with `environment:` | `created` |
31+| `repository_dispatch` | Someone calls `POST /repos/{owner}/{repo}/dispatches` | the `event_type` that was sent |
32+| `workflow_dispatch` | Someone starts a workflow by hand (web UI, CLI, REST API or MCP) | none |
33+| `*` | Every event above | |
34+
35+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](/docs/reference/webhook-events).
36+
37+## Creating a webhook
38+
39+### In the web UI
40+
41+1. Open the repository, for example `https://diffinery.com/acme/website`.
42+2. Select **Settings**, then **Webhooks** in the sidebar (`https://diffinery.com/acme/website/settings/webhooks`).
43+3. Under **Add a webhook**, enter the **Payload URL**. It must start with `https://`.
44+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` and `repository_dispatch`. If you uncheck every box, the webhook also receives every event.
45+5. Optionally enter a **Secret**. Leave it empty to have Diffinery generate one.
46+6. Select **Add webhook**.
47+
48+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.
49+
50+> **Note:** The web form only offers the events listed above as separate checkboxes. To subscribe to `release`, `status`, `deployment`, `deployment_status` or `repository_dispatch` without receiving everything, create the webhook with the API and list the events you want.
51+
52+### With the API
53+
54+`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.
55+
56+```sh
57+curl -sS -X POST \
58+ -H "Authorization: Bearer dfy_..." \
59+ -H "Content-Type: application/json" \
60+ https://diffinery.com/api/v3/repos/acme/website/hooks \
61+ -d '{
62+ "config": { "url": "https://example.com/hooks/diffinery", "secret": "my-long-random-secret" },
63+ "events": ["push", "release", "status"]
64+ }'
65+```
66+
67+| Field | Required | Description |
68+|---|---|---|
69+| `config.url` | yes | The payload URL. Must start with `https://`, otherwise the request fails with `422` and `webhook URL must use https`. |
70+| `config.secret` | no | The signing secret. When omitted, Diffinery generates a random one. |
71+| `events` | no | Event names to subscribe to. Omitted, empty or not a list means `["*"]` (every event). |
72+
73+A request without `config.url` fails with `422` and `config.url is required`.
74+
75+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:
76+
77+```json
78+{
79+ "type": "Repository",
80+ "id": "0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b",
81+ "name": "web",
82+ "active": true,
83+ "events": ["push", "release", "status"],
84+ "config": { "url": "https://example.com/hooks/diffinery", "content_type": "json", "insecure_ssl": "0" },
85+ "updated_at": "2026-10-13T09:00:00Z",
86+ "created_at": "2026-10-13T09:00:00Z",
87+ "url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b",
88+ "ping_url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b/pings",
89+ "deliveries_url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b/deliveries",
90+ "last_response": { "code": null, "status": "unused", "message": null }
91+}
92+```
93+
94+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.
95+
96+The same request with the CLI (install it with `curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery`, which needs Node.js 22 or later):
97+
98+```sh
99+cat > hook.json <<'EOF'
100+{ "config": { "url": "https://example.com/hooks/diffinery" }, "events": ["push"] }
101+EOF
102+diffinery api POST /repos/acme/website/hooks --input hook.json
103+```
104+
105+### Listing and deleting webhooks
106+
107+```sh
108+# List (the secret is never included)
109+curl -sS -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/hooks
110+
111+# Delete
112+curl -sS -X DELETE -H "Authorization: Bearer dfy_..." \
113+ https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b
114+```
115+
116+The list (paginated with `per_page` and `page`) returns the same objects, never with the secret:
117+
118+```json
119+[
120+ {
121+ "type": "Repository",
122+ "id": "0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b",
123+ "name": "web",
124+ "active": true,
125+ "events": ["push", "release", "status"],
126+ "config": { "url": "https://example.com/hooks/diffinery", "content_type": "json", "insecure_ssl": "0" },
127+ "updated_at": "2026-10-13T09:30:02Z",
128+ "created_at": "2026-10-13T09:00:00Z",
129+ "url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b",
130+ "ping_url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b/pings",
131+ "deliveries_url": "https://diffinery.com/api/v3/repos/acme/website/hooks/0b9a6f3e2c1d4f5a8e7b6c5d4e3f2a1b/deliveries",
132+ "last_response": { "code": 200, "status": "active", "message": null }
133+ }
134+]
135+```
136+
137+| Field | Description |
138+|---|---|
139+| `last_response.code` | HTTP status of the most recent delivery, `0` when it got no response, `null` before the first delivery |
140+| `last_response.status` | `unused` (no delivery yet), `active` (last delivery got a `2xx`) or `failed` |
141+| `updated_at` | Time of the most recent delivery, or the creation time before the first one |
142+
143+`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`.
144+
145+`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.
146+
147+## Delivery
148+
149+### Request
150+
151+```http
152+POST /hooks/diffinery HTTP/1.1
153+Host: example.com
154+Content-Type: application/json
155+User-Agent: Diffinery-Hookshot/1
156+X-GitHub-Event: pull_request
157+X-GitHub-Delivery: 7d3f0c2a9b8e4d1fa6c5b4e3d2c1b0a9
158+X-Diffinery-Event: pull_request
159+X-Hub-Signature-256: sha256=4b1c0e2f...
160+
161+{"action":"opened","number":42,"headRef":"fix-typo", ...}
162+```
163+
164+| Header | Value |
165+|---|---|
166+| `Content-Type` | `application/json` |
167+| `User-Agent` | `Diffinery-Hookshot/1` |
168+| `X-GitHub-Event` | The event name, for example `push` or `pull_request` |
169+| `X-Diffinery-Event` | The same event name |
170+| `X-GitHub-Delivery` | A unique id for this delivery: 32 hexadecimal characters |
171+| `X-Hub-Signature-256` | `sha256=` followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook's secret |
172+
173+The body is compact JSON (no extra whitespace). Every payload has `repository` and `sender` objects; `repository.id` is a number, like GitHub's. See [Webhook events](/docs/reference/webhook-events).
174+
175+### Timeouts, redirects and retries
176+
177+| Behavior | Detail |
178+|---|---|
179+| Timeout | Your endpoint has 10 seconds to respond. After that the delivery counts as failed with status `0`. |
180+| Redirects | Not followed. A `3xx` response is recorded as that status and counts as failed. Use the final URL. |
181+| Success | Any `2xx` status. The response body is ignored. |
182+| 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. |
183+| 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. Separately, if Diffinery's own processing of an event fails, the event is processed again (up to 8 times, with a growing delay of up to 5 minutes), which delivers it again with a new `X-GitHub-Delivery` id. Make your handler idempotent, for example by remembering delivery ids. |
184+| 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. |
185+
186+Respond quickly with a `2xx` and do slow work afterwards, so that deliveries don't time out.
187+
188+### Retries
189+
190+When an attempt fails in a way that may be temporary, Diffinery tries the same delivery again later:
191+
192+| Result of an attempt | Retried |
193+|---|---|
194+| `2xx` | No: delivered |
195+| No response: network error, DNS failure, refused connection, or no answer within 10 seconds (recorded as `0`) | Yes |
196+| `408 Request Timeout`, `429 Too Many Requests` | Yes |
197+| `500` and above | Yes |
198+| `3xx` (redirects are not followed) | No |
199+| Any other `4xx`, for example `400`, `401`, `403`, `404` or `410` | No |
200+
201+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.
202+
203+- 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.
204+- 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.
205+- 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.
206+- Every attempt updates the webhook's last delivery status and time, so a delivery that succeeds on a retry shows as successful.
207+- Deliveries for other events are not held back while one is being retried, so a retried delivery can arrive after newer ones.
208+
209+### Delivery status
210+
211+Diffinery keeps the status and time of the **most recent** delivery attempt of each webhook (retries included), not a full history:
212+
213+- 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`.
214+- `GET /api/v3/repos/{owner}/{repo}/hooks` returns `last_response.code` and `last_response.status`, with the delivery time in `updated_at`.
215+
216+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.
217+
218+## Verifying signatures
219+
220+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.
221+
222+### Node.js
223+
224+```js
225+import crypto from "node:crypto";
226+import http from "node:http";
227+
228+const secret = process.env.WEBHOOK_SECRET;
229+
230+function verify(rawBody, header) {
231+ if (typeof header !== "string" || !header.startsWith("sha256=")) return false;
232+ const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
233+ const a = Buffer.from(header);
234+ const b = Buffer.from(expected);
235+ return a.length === b.length && crypto.timingSafeEqual(a, b);
236+}
237+
238+http
239+ .createServer((req, res) => {
240+ const chunks = [];
241+ req.on("data", (chunk) => chunks.push(chunk));
242+ req.on("end", () => {
243+ const body = Buffer.concat(chunks);
244+ if (!verify(body, req.headers["x-hub-signature-256"])) {
245+ res.writeHead(401).end("bad signature");
246+ return;
247+ }
248+ const event = req.headers["x-github-event"];
249+ const payload = JSON.parse(body.toString("utf8"));
250+ console.log(event, payload.action ?? "", payload.repository.full_name, payload.sender.login);
251+ res.writeHead(204).end();
252+ });
253+ })
254+ .listen(3000);
255+```
256+
257+### Python
258+
259+Standard library only:
260+
261+```text
262+import hashlib
263+import hmac
264+import json
265+import os
266+from http.server import BaseHTTPRequestHandler, HTTPServer
267+
268+SECRET = os.environ["WEBHOOK_SECRET"].encode("utf-8")
269+
270+def verify(body: bytes, header: str | None) -> bool:
271+ if not header or not header.startswith("sha256="):
272+ return False
273+ expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
274+ return hmac.compare_digest(expected, header)
275+
276+class Hook(BaseHTTPRequestHandler):
277+ def do_POST(self):
278+ body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
279+ if not verify(body, self.headers.get("X-Hub-Signature-256")):
280+ self.send_response(401)
281+ self.end_headers()
282+ return
283+ payload = json.loads(body)
284+ print(self.headers.get("X-GitHub-Event"), payload.get("action"), payload["repository"]["full_name"])
285+ self.send_response(204)
286+ self.end_headers()
287+
288+HTTPServer(("", 3000), Hook).serve_forever()
289+```
290+
291+### Check a signature by hand
292+
293+```sh
294+printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= /sha256=/'
295+```
296+
297+## GitHub compatibility
298+
299+Diffinery webhooks use GitHub's delivery format, so many GitHub webhook receivers and signature-verification libraries work unchanged:
300+
301+- The same headers: `X-GitHub-Event`, `X-GitHub-Delivery` and `X-Hub-Signature-256`, plus `X-Diffinery-Event`.
302+- The same signature scheme (HMAC-SHA256, `sha256=` prefix, hex).
303+- The same event names and the same `action` values for the actions Diffinery sends.
304+- Payloads have GitHub-style `repository` (with a numeric `id` and a `node_id`), `sender`, `pull_request` and `issue` objects, and `pull_request.merged` on `closed`.
305+- `release`, `status`, `deployment`, `deployment_status` 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.
306+- The hooks API returns GitHub's hook object (`type`, `name`, `config`, `last_response`, ...).
307+
308+Differences to plan for:
309+
310+| GitHub | Diffinery |
311+|---|---|
312+| 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](/docs/api) when you need them. For example, `issue_comment` does not include the comment body, and `push` has no `commits` array. |
313+| Numeric ids everywhere | `repository.id` is a number, but `sender.id`, `commentId`, `runId` and webhook ids are 32-character hexadecimal strings. |
314+| `X-Hub-Signature` (SHA-1) is also sent | Only `X-Hub-Signature-256`. |
315+| `ping` event when a webhook is created | No ping event. |
316+| 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`. |
317+| Webhooks can be edited (`PATCH`) | Delete and recreate. |
318+| Payload can be form-encoded | Always JSON. |
319+| Organization webhooks | Repository webhooks only. |
320+| `check_run`, `check_suite` and many other events | Only the events in the table above. |
321+| `events` defaults to `["push"]` in the API | Defaults to `["*"]` (every event). |
322+| Secrets are never returned | A generated secret is returned once by the create request. |
323+
324+Diffinery-specific fields in the small payloads use camelCase, for example `headRef`, `headSha`, `baseRef`, `commentId`, `runId` and `at`.
325+
326+## Security
327+
328+- Payload URLs must use HTTPS.
329+- 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.
330+- Verify the signature on every request, and reject requests without one.
331+- Creating a webhook from the web UI is recorded in the [audit log](/docs/security/audit-log) as `repo.webhook.create`, and deleting one as `repo.webhook.delete`.
332+- Payloads never contain Actions secrets, tokens or file contents.
333+
334+## Troubleshooting
335+
336+| Symptom | Cause and fix |
337+|---|---|
338+| `webhook URL must use https` | Use an `https://` URL. Plain HTTP endpoints are not supported. |
339+| 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. |
340+| 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`). |
341+| 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. |
342+| 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. |
343+| 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. |
344+| No `edited` event after changing labels | `edited` is sent only when the title or body changes. Label changes send no webhook event. |
345+| 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. |
346+| 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`. |
347+| No `deployment` event for a workflow job with `environment:` | Jobs record their deployment without a `deployment` event; they send `deployment_status` events (`in_progress` when the job starts, then `success`, `failure` or `inactive`). |
348+| `config.url is required` | Send the payload URL as `config.url`, not as a top-level `url`. |
349+| 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. |
addedapps/diffinery/docs/security/audit-log.md+164−0View file
@@ -1,0 +1,164 @@
1+---
2+title: Audit log
3+description: What Diffinery records in its audit log, the fields of each entry, who can view it, and which entries also send security emails.
4+---
5+
6+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, pushes, merges, admin merges that bypass branch protection, push protection decisions and package changes. Site administrators use it to answer "who did what, when, and on whose behalf".
7+
8+## Viewing the audit log
9+
10+The audit log is available to **site administrators** only.
11+
12+1. Sign in with your passkey.
13+2. Open **Settings** at `https://diffinery.com/settings`.
14+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`.
15+
16+The page shows the 200 most recent entries, newest first, in a table with these columns:
17+
18+| Column | Content |
19+|---|---|
20+| **When** | The time of the action |
21+| **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, and `api` for `api.blocked_secret`. |
22+| **Action** | The action name, such as `git.push` |
23+| **Target** | What the action applied to, such as `acme/website` or `acme/website:refs/heads/main` |
24+| **Detail** | Extra data as JSON (hover to see it all) |
25+
26+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.
27+
28+## Entry fields
29+
30+Each entry is stored with these fields:
31+
32+| Field | Description |
33+|---|---|
34+| `id` | A unique id for the entry |
35+| `at` | ISO 8601 timestamp of the action |
36+| `actor_id` | Id of the account that acted (empty for some system actions) |
37+| `actor_login` | Login of that account at the time |
38+| `on_behalf_of` | For agents: the login of the person or organization that owns the agent |
39+| `action` | The action name (see below) |
40+| `target` | What the action applied to; format depends on the action |
41+| `detail` | JSON with action-specific data, or empty |
42+
43+Entries are only ever added. Diffinery has no feature to edit or delete them.
44+
45+## Recorded actions
46+
47+### Sign-in and account security
48+
49+| Action | Recorded when | Target | Detail |
50+|---|---|---|---|
51+| `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` |
52+| `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 |
53+| `passkey.remove` | A passkey is removed | none | `name` |
54+| `invite.accept` | Someone creates an account and passkey from an invite link, or an existing user accepts an invite | for an existing user, the path the invite led to (such as `/acme/website`) | for a new account: `method` (`passkey`), `passkeyId`, `device`, `ip`, `location` |
55+| `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 |
56+| `token.revoke` | A token is revoked on **Access tokens** | the token id | none |
57+| `signin_link.create` | A site admin creates a one-time sign-in link for someone | that user's login | none |
58+| `user.active`, `user.pending`, `user.disabled` | A site admin changes a user's status | that user's login | none |
59+| `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`) |
60+
61+### Agents
62+
63+| Action | Recorded when | Target | Detail |
64+|---|---|---|---|
65+| `agent.create` | An agent account is created (web or `POST /api/v3/diffinery/agents`) | the agent's login | `maxRole` (web) |
66+| `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) |
67+| `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 |
68+
69+### Organizations and invitations
70+
71+| Action | Recorded when | Target | Detail |
72+|---|---|---|---|
73+| `org.create` | An organization is created from the web UI | the organization's login | none |
74+| `org.update` | An organization's settings are changed | the organization's login | `login` |
75+| `org.invite` | Someone without an account is invited to an organization: an email address entered in **Add people** on the organization's **People** tab, or `POST /api/v3/orgs/{org}/invitations` | the organization's login | `email`, `role` |
76+| `org.invite.revoke` | A pending organization invitation is revoked with **Revoke** | the organization's login | `id` (the invitation id) |
77+| `org.member.set`, `org.member.remove` | Someone with an account is added with **Add people**, a member's role is changed, or a member is removed | the organization's login | `user`, `role` |
78+| `invite.create` | A site admin creates an invite from **Site administration** | the email address | none |
79+| `site.invite` | A site admin invites someone with `POST /api/v3/invitations` | none | `email` |
80+
81+### Repositories
82+
83+All actions on the repository settings pages use the repository (`acme/website`) as the target.
84+
85+| Action | Recorded when | Detail |
86+|---|---|---|
87+| `repo.create` | A repository is created from the web UI | none (target `acme/website`) |
88+| `repo.update` | **General** settings are saved | `name`, `visibility`, `defaultBranch` |
89+| `repo.archive`, `repo.unarchive` | The repository is archived or unarchived | none |
90+| `repo.delete` | The repository is deleted (web or `DELETE /api/v3/repos/{owner}/{repo}`) | none |
91+| `repo.collaborator.set`, `repo.collaborator.remove` | On **Collaborators**: a person or agent with an account is added with **Add people**, or a collaborator's role is changed, or a collaborator is removed | `login`, `role` |
92+| `repo.invite` | An email address without an account is entered in **Add people**, which sends an invitation | `email`, `role` |
93+| `repo.topics` | The topics are replaced with `PUT /api/v3/repos/{owner}/{repo}/topics` | `names` |
94+| `repo.protection.set`, `repo.protection.delete` | A branch protection rule is saved or deleted on **Branch protection** | `pattern`, and `rules` when saved |
95+| `repo.secret.set`, `repo.secret.delete` | An Actions secret is saved or deleted on **Secrets and variables** | `name` (never the value) |
96+| `repo.variable.set`, `repo.variable.delete` | An Actions variable is saved or deleted | `name` |
97+| `repo.webhook.create`, `repo.webhook.delete` | A webhook is added or deleted on **Webhooks** | `url` and `events`, or `id` |
98+
99+> **Note:** The collaborator, branch protection, secret, variable and webhook entries above are written by the repository settings pages in the web UI. The same changes made through the REST API (for example `PUT /api/v3/repos/{owner}/{repo}/branches/{branch}/protection` or `PUT /api/v3/repos/{owner}/{repo}/actions/secrets/{name}`) are not recorded on this version. `repo.delete`, `repo.topics` and the environment entries below are recorded for API calls.
100+
101+### Environments
102+
103+Changes to [environments](/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`.
104+
105+| Action | Recorded when | Detail |
106+|---|---|---|
107+| `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`) |
108+| `environment.delete` | An environment is deleted | `environment` |
109+| `environment.branch_policy.create` | A deployment branch or tag pattern is added | `environment`, `pattern`, `type` (`branch` or `tag`) |
110+| `environment.branch_policy.delete` | A deployment branch or tag pattern is removed | `environment`, `id` |
111+| `environment.secret.set`, `environment.secret.delete` | An environment secret is saved or deleted | `environment`, `name` (never the value) |
112+
113+Environment variables are not recorded.
114+
115+### Git and pull requests
116+
117+| Action | Recorded when | Target | Detail |
118+|---|---|---|---|
119+| `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) |
120+| `git.force_push` | A ref moves to a commit that does not contain the old one | as above | `before`, `after` |
121+| `git.delete_ref` | A branch or tag is deleted | as above | `before`, `after` |
122+| `pull.merge` | A pull request is merged, directly or by the merge queue | `acme/website#42` | none |
123+| `pull_request.merge_bypassed` | A repository admin merges a pull request past unmet branch rules, by asking for a bypass (`"bypass_rules": true` or `"bypass_merge_queue": true` in the API, `diffinery pr merge --admin`, or **Merge without waiting for requirements (bypass)** or **Merge now (bypass the queue)** in the web UI). Recorded when the pull request had at least one merge blocker; a `pull.merge` entry follows. | `acme/website#42` | `blockers` (the unmet blockers, such as `needs 1 more approving review(s)`), `base` (the base branch) |
124+| `push.blocked_secret` | [Push protection](/docs/security/push-protection) blocks a push | `acme/website` | `kinds` (credential types found), `refs` |
125+| `push.secret_bypassed` | A repository admin pushes past push protection with `git push -o allow-secrets=<reason>` | `acme/website` | `kinds`, `reason`, `refs` |
126+| `api.blocked_secret` | [Push protection](/docs/security/push-protection#writes-through-the-rest-api) refuses a file written with the contents API or the git data API. The actor is recorded as `api`, not as the caller. | `acme/website` | `kinds` |
127+
128+### Container packages
129+
130+| Action | Recorded when | Target | Detail |
131+|---|---|---|---|
132+| `package.create` | The first push creates a package | `acme/website` (owner and package name) | `repository` it is linked to |
133+| `package.push` | An image manifest is pushed | `acme/website:latest` (with the tag or digest) | `digest` |
134+| `package.update` | A package's visibility or linked repository changes | `acme/website` | the new settings |
135+| `package.delete` | A package is deleted | `acme/website` | none |
136+| `package.delete_version` | One version of a package is deleted | `acme/website@sha256:...` | none |
137+
138+## Agents and on-behalf-of
139+
140+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 octocat`. This applies to pushes, merges, push protection decisions and settings changes made with the agent's token. (Agents can never bypass branch protection on a merge, so `pull_request.merge_bypassed` always names a person.) See [Agents](/docs/agents/overview).
141+
142+## Security emails
143+
144+Some audit entries also send an email to the people affected. These emails are in the security group, which cannot be turned off:
145+
146+| Action | Email |
147+|---|---|
148+| `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 |
149+| `passkey.add` | "A passkey was added to your account" |
150+| `passkey.remove` | "A passkey was removed from your account" |
151+| `agent.create` | "A new agent was added" |
152+| `push.blocked_secret` | "Your push was blocked: possible secrets", to the pusher (or the agent's owner) |
153+| `push.secret_bypassed` | "Push protection was bypassed", to the pusher and the repository's admins |
154+
155+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](/docs/accounts/notifications).
156+
157+## Troubleshooting
158+
159+| Symptom | Cause |
160+|---|---|
161+| You don't see **Audit log** in the settings sidebar | Only site administrators can view it. Ask a site admin; see [Site administration](/docs/admin/site-administration). |
162+| A push or merge appears a few seconds late | `git.push`, `git.force_push`, `git.delete_ref`, `pull.merge`, `pull_request.merge_bypassed` and push protection entries (including `api.blocked_secret`) are written by the event queue shortly after the action, not in the request itself. |
163+| A settings change made through the API is missing | Collaborator, branch protection, secret, variable and webhook changes are recorded only when made in the web UI. See the note under [Repositories](#repositories). |
164+| An older entry is missing from the page | The page shows only the 200 most recent entries. |
addedapps/diffinery/docs/security/overview.md+268−0View file
@@ -1,0 +1,268 @@
1+---
2+title: Security overview
3+description: Diffinery's security model end to end: sign-in, sessions, tokens, agent guardrails, branch and push protection, secrets, Actions, webhooks and auditing.
4+---
5+
6+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.
7+
8+## At a glance
9+
10+| Area | What Diffinery does |
11+|---|---|
12+| Sign-in | Passkeys only (WebAuthn). No passwords. |
13+| Browser sessions | Random session cookie, stored hashed, `HttpOnly`, `Secure`, `SameSite=Lax`, 30 days sliding |
14+| Cross-site requests | Double-submit CSRF token on every form, plus refusal of cross-site state-changing requests |
15+| Headers | HSTS, nonce-based Content Security Policy, `X-Frame-Options: DENY`, `nosniff` |
16+| Anonymous access | Read-only: anonymous callers can read public repositories but every change gets `401` |
17+| Machines and agents | Tokens (`dfy_...`), stored hashed, with scopes, repository restrictions and expiry; `read` tokens never write |
18+| Agents | Accounts owned by a person or organization, with server-enforced limits |
19+| Branches | Branch protection rules, enforced for git, the API, MCP and the web UI |
20+| Required checks | Checks reported by agents never satisfy a required check; a check can be bound to one reporter |
21+| Pushes | Credential scanning on every push and on file writes through the REST API; blocked pushes leave nothing behind |
22+| File names | Writes through the API, MCP and the web editor refuse `.git`, `..` and other unsafe tree entry names |
23+| Archived repositories | Read-only |
24+| Actions secrets | Envelope encryption with a per-repository, per-environment or per-organization key derived from `MASTER_KEY` |
25+| Workflow logs | Secret values are replaced with `***` |
26+| Workflow credentials | A job token valid for one job of one repository, and short-lived OIDC id tokens |
27+| Webhooks | HTTPS only, signed with HMAC-SHA256, failed deliveries retried |
28+| Accountability | Append-only audit log and security emails |
29+| Isolation | One Durable Object per repository |
30+
31+## Signing in with passkeys
32+
33+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.
34+
35+- Passkeys are bound to the site's hostname (`diffinery.com`), so a look-alike site cannot use them.
36+- Diffinery requires user verification (a fingerprint, face or device PIN) and discoverable credentials, and does not ask for attestation.
37+- 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`).
38+- New people create their first passkey from an invite link, which expires after 14 days and works once.
39+- 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. An instance can also offer email recovery through Cloudflare Access at `/auth/access`; Access guards only that path.
40+- Agents cannot sign in to the web UI. Only people get browser sessions.
41+
42+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](/docs/accounts/passkeys-and-sign-in).
43+
44+## Sessions
45+
46+After a passkey sign-in, Diffinery sets a session cookie:
47+
48+| Property | Value |
49+|---|---|
50+| Name | `__Host-dfy_session` (the `__Host-` prefix pins it to this origin, `Path=/`, no `Domain`) |
51+| Flags | `HttpOnly`, `Secure`, `SameSite=Lax` |
52+| Lifetime | 30 days, extended by another 30 days on the first use of each day |
53+| Storage | Only a SHA-256 hash of the session token is stored |
54+
55+Signing in again replaces the browser's previous session. **Settings**, **Passkeys and sessions** lists your **Signed-in browsers**; **Sign out everywhere else** ends every session except the current one. A disabled account cannot use its sessions or tokens.
56+
57+## Protection against cross-site requests
58+
59+Browser sessions ride on cookies, so Diffinery applies two independent defenses against cross-site request forgery.
60+
61+**Cross-site refusal.** Every request that changes state (any method other than `GET`, `HEAD` and `OPTIONS`) and carries no `Authorization` credential is checked first:
62+
63+- If the browser sends `Sec-Fetch-Site` and its value is not `same-origin` or `none`, the request is refused.
64+- 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.
65+
66+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.
67+
68+**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:
69+
70+- its `Origin`, if present, is the site's own origin (otherwise `403` with `cross-origin form submission rejected`),
71+- it is a form submission (`application/x-www-form-urlencoded` or `multipart/form-data`; otherwise `415` with `expected a form submission`), and
72+- 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`).
73+
74+The passkey endpoints take JSON, which forces a CORS preflight that other sites cannot pass.
75+
76+## Security headers and content security policy
77+
78+Every response carries:
79+
80+| Header | Value |
81+|---|---|
82+| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` |
83+| `X-Content-Type-Options` | `nosniff` |
84+| `Referrer-Policy` | `strict-origin-when-cross-origin` |
85+| `X-Frame-Options` | `DENY` (unless a response sets its own) |
86+| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` |
87+
88+Web pages also get `Cache-Control: private, no-store` and a Content Security Policy with a new random nonce for each response:
89+
90+```text
91+default-src 'self'; script-src 'nonce-...'; style-src 'self' 'unsafe-inline';
92+img-src 'self' https: data: blob:; connect-src 'self' wss://diffinery.com ws://diffinery.com;
93+font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'
94+```
95+
96+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.
97+
98+## Tokens and scopes
99+
100+Everything that is not a browser (git, the [CLI](/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).
101+
102+| Property | Detail |
103+|---|---|
104+| Format | `dfy_` followed by 40 random characters |
105+| Storage | Only a SHA-256 hash is stored; the token is shown once, at creation |
106+| Expiry | 1 to 366 days; 90 by default (30 for agent tokens created in the web UI) |
107+| Scope | `read`, `write` or `admin` |
108+| Repositories | Optionally restricted to a list of repositories |
109+| Revocation | Immediate; disabling an account revokes all of its tokens |
110+
111+A token never grants more than its account has. The scope caps the account's role on each repository:
112+
113+| Scope | Highest role it allows |
114+|---|---|
115+| `read` | read: clone and view |
116+| `write` | maintain: push, issues, pull requests, runs, and moving tags |
117+| `admin` | admin: everything the account can do, including settings |
118+
119+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.
120+
121+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, managing agents and their tokens, and revoking tokens need `admin`. Creating a token through the API needs an `admin`-scoped token or a browser session. 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](/docs/accounts/tokens) and [Permissions](/docs/accounts/permissions).
122+
123+## Repository access
124+
125+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](/docs/accounts/permissions).
126+
127+### Anonymous callers only read
128+
129+Without a token or a session, a caller gets the `read` role on public repositories and nothing on private ones. 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. Unknown paths under `/api/` answer with a JSON `404` (`{"message": "Not Found", "documentation_url": "..."}`), not the web UI's error page.
130+
131+### Archived repositories
132+
133+An archived repository is read-only for its content:
134+
135+- `git push` is refused, and scheduled workflows (`on: schedule`) stop running.
136+- 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.`
137+- Most REST API and MCP writes, such as file commits, new pull requests, merges, labels, assignees, locks, reactions, releases, commit statuses and check runs, deployments and git data, are refused with `403` and `repository is archived (read-only)`, even for admins. Reading still works.
138+- Exceptions: the admin endpoints for collaborators, branch protection, webhooks, secrets and variables keep working. The REST endpoints that open and edit issues, edit pull requests, add and edit comments, submit reviews and request reviewers, and the MCP `create_issue` and `review_pull_request` tools, don't check the archived state yet and still accept changes. See [Archive a repository](/docs/repositories/overview#archive-a-repository).
139+
140+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](/docs/security/audit-log) as `repo.archive` and `repo.unarchive`.
141+
142+## Agent guardrails
143+
144+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:
145+
146+- An agent's role is capped by its **Maximum role** (default write) and never exceeds its owner's role.
147+- Agents cannot push to the default branch or to protected branches, unless a branch protection rule allows it (`agentsMayPush`).
148+- Agents cannot merge pull requests into the default branch or protected branches, unless a rule allows it (`agentsMayMerge`).
149+- Pull requests opened by agents need at least one approval from a person, and agents' approvals never count toward required approvals.
150+- Agents cannot change branch protection, delete repositories, create agents, or bypass push protection.
151+- 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`.
152+- Agents never bypass branch protection when merging, even with the admin role.
153+- Agents cannot create, change or delete [environments](/docs/actions/environments) or their deployment branch policies, and cannot set or delete environment secrets (`only repository admins (people, not agents) can manage environments`).
154+- Agents cannot dismiss pull request reviews (`agents can't dismiss reviews`).
155+- 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](/docs/collaboration/checks-and-statuses).
156+- 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.
157+- 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.
158+- The audit log records who an agent acted for, and security emails about an agent (blocked pushes, new tokens) go to its owner.
159+
160+See [Agents](/docs/agents/overview).
161+
162+## Branch protection
163+
164+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.
165+
166+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. See [Branch protection](/docs/repositories/branch-protection).
167+
168+Required checks are guarded against spoofing. A required check matches check runs and commit statuses by name, and Diffinery decides which reports count:
169+
170+- 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.
171+- A rule can bind a check to one reporter with `requiredCheckSources` (for example `{ "ci/external": "ci-bot" }`); then only that account's reports count.
172+- Reports from agents never count.
173+- 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.
174+
175+## Push protection
176+
177+Every `git push` is scanned for credentials (AWS, GitHub, Diffinery, Slack, Stripe live, Google, Anthropic and OpenAI keys, and private keys) before anything is stored. 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, 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 files written with the contents API (`PUT /repos/{owner}/{repo}/contents/{path}`) and the git data API (blobs, and trees with inline `content`); there a match is refused with `422` and no bypass exists. See [Push protection](/docs/security/push-protection).
178+
179+## Tree entry names
180+
181+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:
182+
183+- is `.` or `..`,
184+- 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),
185+- contains `\` or a NUL character,
186+- is longer than 255 characters,
187+
188+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 not checked for these names.
189+
190+## Secrets encryption
191+
192+Actions secrets (repository, environment and organization) are encrypted at rest with envelope encryption:
193+
194+1. The instance has one master key, `MASTER_KEY`, a base64-encoded secret configured on the Worker.
195+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.
196+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.
197+4. Only the ciphertext is stored. Values are decrypted inside the repository's Durable Object only when a workflow run needs them.
198+
199+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](/docs/actions/secrets-and-variables) and, for operators, [Configuration](/docs/self-hosting/configuration).
200+
201+Environment secrets have extra limits:
202+
203+- They are never given to runs of pull request refs (`refs/pull/*`).
204+- An environment that has no deployment branch policy gets a protected-branches-only policy (`protected_branches: true`) when its first secret is added.
205+- Runs started by agents get them only when the environment allows protected branches only.
206+
207+See [Environments](/docs/actions/environments).
208+
209+## Secret masking in logs
210+
211+The runner replaces every secret value with `***` in job logs, step names, job summaries and failure messages:
212+
213+- All secrets the job receives are masked, including the job's `GITHUB_TOKEN`.
214+- A multi-line secret is also masked line by line.
215+- Values shorter than 3 characters are not masked, to keep logs readable.
216+- `::add-mask::<value>` masks additional values, as on GitHub.
217+- A job output whose value contains a secret is dropped with the warning `Skip output '<name>' since it may contain secret.`
218+
219+Masking is a safety net: a workflow that transforms a secret (for example, base64-encodes it) can still print it. See [Secrets and variables](/docs/actions/secrets-and-variables).
220+
221+## Job tokens
222+
223+Each workflow job gets its own token, available as `secrets.GITHUB_TOKEN` and `DIFFINERY_TOKEN`:
224+
225+- It is valid only for the repository and the job it was issued for. Diffinery stores only its hash.
226+- It stops working as soon as the job completes.
227+- It acts as `diffinery-actions[bot]` with read access to its own repository.
228+- For the [container registry](/docs/packages/container-registry), it can pull and push packages of its own repository according to the workflow's `packages` permission.
229+
230+[Sandboxes](/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, and stops working when the sandbox is closed.
231+
232+## OIDC id tokens
233+
234+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 the subject `repo:<owner>/<repo>:ref:<ref>`. 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](/docs/actions/oidc).
235+
236+## Webhook signatures
237+
238+Webhook payload URLs must use HTTPS. 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](/docs/repositories/webhooks).
239+
240+## Audit log
241+
242+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, webhooks, topics), environment and environment secret changes, pushes, force pushes and ref deletions, merges, admin merges that bypass branch protection, push protection decisions, 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](/docs/security/audit-log).
243+
244+## Security emails
245+
246+Diffinery emails you about security-relevant events on your account. These emails cannot be turned off in your notification settings:
247+
248+| Email | When |
249+|---|---|
250+| New sign-in to your account | A passkey sign-in from a device not seen before, or any recovery sign-in |
251+| A passkey was added / removed | A passkey is added to or removed from your account |
252+| 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) |
253+| Your token expires on ... | About 7 days before a token expires |
254+| A new agent was added | You create an agent |
255+| Your push was blocked: possible secrets | Push protection blocked your push (or your agent's) |
256+| Push protection was bypassed | An admin pushed past push protection in a repository you administer |
257+| Your account was disabled / enabled | A site admin changes your account's status |
258+
259+See [Notifications](/docs/accounts/notifications).
260+
261+## Data isolation
262+
263+- **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.
264+- **Authorization before routing.** The Worker resolves the caller and checks their role before a request reaches a repository's Durable Object.
265+- **Scoped credentials.** Job and sandbox tokens name their repository and are verified by that repository's Durable Object, so they cannot be used on another repository. Secret encryption keys are derived per repository and per organization.
266+- **Global data** (accounts, organizations, organization secrets, tokens, invites, notifications and the audit log) lives in a separate D1 database.
267+
268+See [Architecture](/docs/self-hosting/architecture).
addedapps/diffinery/docs/security/push-protection.md+208−0View file
@@ -1,0 +1,208 @@
1+---
2+title: Push protection
3+description: How Diffinery scans git pushes and API file writes for leaked credentials, what a blocked push looks like, and how to opt out or bypass the check.
4+---
5+
6+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. It also scans files written through the REST API's contents and git data endpoints. It is always on for every repository: there is nothing to enable, and nothing from a blocked push or write is stored.
7+
8+## How it works
9+
10+When you run `git push`, Diffinery receives your packfile and checks it before it writes anything:
11+
12+1. It unpacks the packfile in memory. No object is written to the repository yet.
13+2. It scans the new file contents (blobs) in the pack against a fixed list of credential patterns.
14+3. If nothing matches, it stores the objects and then applies the usual checks (permissions and [branch protection](/docs/repositories/branch-protection)) to each ref you pushed.
15+4. If anything matches, it rejects every ref in the push, prints what it found, records the attempt in the [audit log](/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.
16+
17+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.
18+
19+## What is detected
20+
21+Diffinery looks for these credential types. Each name below is exactly what appears in the push output, the audit log and the email.
22+
23+| Name | What matches |
24+|---|---|
25+| `AWS access key` | `AKIA` or `ASIA` followed by 16 uppercase letters or digits |
26+| `GitHub token` | `ghp_`, `gho_`, `ghu_`, `ghs_` or `ghr_` followed by 36 letters or digits, or `github_pat_` followed by 82 letters, digits or underscores |
27+| `Diffinery token` | `dfy_` followed by at least 32 letters, digits, `_` or `-` (a Diffinery personal or agent token) |
28+| `Slack token` | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-` or `xoxs-` followed by at least 10 letters, digits or `-` |
29+| `Stripe live key` | `sk_live_` or `rk_live_` followed by at least 20 letters or digits |
30+| `Google API key` | `AIza` followed by 35 letters, digits, `_` or `-` |
31+| `Anthropic API key` | `sk-ant-` followed by at least 20 letters, digits, `_` or `-` |
32+| `OpenAI API key` | `sk-` or `sk-proj-` followed by at least 40 letters, digits, `_` or `-` |
33+| `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-----` |
34+
35+Each pattern must stand on word boundaries, so a key embedded in a longer word does not match. Some other details:
36+
37+- A file is reported at most once per credential type, even if it holds several keys of that type.
38+- 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.
39+- Only live Stripe keys are detected. Test keys (`sk_test_...`) are not.
40+- An encrypted PEM key (`-----BEGIN ENCRYPTED PRIVATE KEY-----`) is not matched by the `Private key` pattern.
41+- The list is fixed. You cannot add your own patterns.
42+
43+## What is scanned
44+
45+| Scanned | Not scanned |
46+|---|---|
47+| Blobs (file contents) contained in the packfile of a `git push` | Commits, trees and tags themselves (commit messages are not scanned) |
48+| In a push, text files smaller than 1 MiB (1,048,576 bytes) | In a push, blobs of 1 MiB or more |
49+| Every ref in the push: branches and tags | Binary files: any blob that contains a NUL byte anywhere |
50+| Files written with `PUT /repos/{owner}/{repo}/contents/{path}` | Files that contain the text `diffinery:allow-secret` (see [Opting a file out](#opting-a-file-out)) |
51+| Blobs created with `POST /repos/{owner}/{repo}/git/blobs`, and inline `content` in `POST /repos/{owner}/{repo}/git/trees` | Objects the server already has, which git does not send again |
52+| | Git LFS objects, which are uploaded separately from the push |
53+| | Release assets |
54+| | The web editor, `POST /repos/{owner}/{repo}/diffinery/commits` and the MCP `commit_files` tool |
55+
56+Pushes larger than 100 MB are refused before scanning with `push exceeds 100 MB; split it into smaller pushes`.
57+
58+> **Important:** Push protection is a safety net, not a guarantee. It finds only the formats listed above, and changes made through the web editor, `POST /repos/{owner}/{repo}/diffinery/commits` or MCP are not scanned. Never rely on it as your only defense: keep secrets in [Actions secrets](/docs/actions/secrets-and-variables) and out of your repository.
59+
60+## When a push is blocked
61+
62+### What you see
63+
64+git prints Diffinery's messages with a `remote:` prefix, followed by a rejection for each ref:
65+
66+```text
67+$ git push origin main
68+Enumerating objects: 5, done.
69+...
70+remote: Push blocked: possible secrets detected.
71+remote: AWS access key in blob 3f2a9c1d0e
72+remote: Private key in blob 8b41e07a5c
73+remote: Remove the secret from your commits, or rotate it and contact an admin.
74+To https://diffinery.com/acme/website.git
75+ ! [remote rejected] main -> main (secret detected (push protection))
76+error: failed to push some refs to 'https://diffinery.com/acme/website.git'
77+```
78+
79+The format is:
80+
81+- The first line is always `Push blocked: possible secrets detected.`
82+- Then one line per finding, ` <type> in blob <first 10 characters of the blob id>`, for at most 10 findings.
83+- Then `Remove the secret from your commits, or rotate it and contact an admin.`
84+- 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.`
85+- Every ref in the push is rejected with the reason `secret detected (push protection)`.
86+
87+### What else happens
88+
89+- The attempt is recorded in the [audit log](/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.
90+- 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.
91+- Nothing from the push is stored in the repository.
92+
93+### How to fix it
94+
95+1. Find the file. The blob id prefix in the output identifies the file version. In your local clone, run:
96+
97+ ```sh
98+ git rev-list --objects --all | grep '^3f2a9c1d0e'
99+ ```
100+
101+ The line it prints ends with the path of the file.
102+
103+2. Remove the secret from every commit you are pushing, not only the latest one. For the most recent commit:
104+
105+ ```sh
106+ # edit the file to remove the secret, then
107+ git add path/to/file
108+ git commit --amend --no-edit
109+ ```
110+
111+ 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.
112+
113+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.
114+
115+4. Push again.
116+
117+## Writes through the REST API
118+
119+The GitHub-compatible file endpoints run the same scan on each file they write:
120+
121+| Endpoint | What is scanned |
122+|---|---|
123+| `PUT /repos/{owner}/{repo}/contents/{path}` | The new file content |
124+| `POST /repos/{owner}/{repo}/git/blobs` | The blob |
125+| `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 |
126+
127+The patterns, the binary-file rule and the `diffinery:allow-secret` opt-out are the same as for pushes. The 1 MiB limit does not apply: every text file is scanned, whatever its size.
128+
129+When a file matches, the request fails with `422` and nothing is stored:
130+
131+```json
132+{
133+ "message": "push protection: possible secret detected (AWS access key)",
134+ "documentation_url": "https://docs.github.com/rest"
135+}
136+```
137+
138+There is no bypass for API writes, not even for repository admins. If the file really must contain the value, add the `diffinery:allow-secret` marker to it, or push it with git and use [the bypass](#bypassing-push-protection). Each refused write is recorded in the [audit log](/docs/security/audit-log) as `api.blocked_secret` with the repository as the target and the detected types as detail. No email is sent for it.
139+
140+## Opting a file out
141+
142+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:
143+
144+```ts
145+// diffinery:allow-secret (fake keys for the parser tests)
146+export const FIXTURE_KEY = "AKIAIOSFODNN7EXAMPL3";
147+```
148+
149+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.
150+
151+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.
152+
153+## Bypassing push protection
154+
155+A repository admin who is a person can push a flagged change anyway by giving a reason with a git push option:
156+
157+```sh
158+git push -o allow-secrets="revoked key kept in a migration test" origin main
159+```
160+
161+`--push-option=allow-secrets=...` is the long form of the same option.
162+
163+### Who can bypass
164+
165+All of these must be true:
166+
167+| Requirement | Detail |
168+|---|---|
169+| 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](/docs/accounts/permissions). |
170+| You are a person | Agents can never bypass push protection, whatever their role. |
171+| 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](/docs/accounts/tokens). |
172+| You give a reason | The value after `allow-secrets=` must not be empty or only spaces. It is trimmed and cut to 200 characters. |
173+
174+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.`
175+
176+### What a bypass does
177+
178+- The push continues and the output shows `Push protection bypassed by octocat: <reason>`, followed by the findings (up to 10).
179+- The bypass skips only the secret scan. Branch protection still applies to every ref.
180+- It is recorded in the [audit log](/docs/security/audit-log) as `push.secret_bypassed`, with the detected types, your reason and the refs.
181+- 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.
182+
183+Bypassing does not make a real credential safe: anyone who can read the repository can read it. Rotate real credentials even after a bypass.
184+
185+## Differences from GitHub push protection
186+
187+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:
188+
189+| | GitHub | Diffinery |
190+|---|---|---|
191+| Patterns | A large catalog of provider patterns, plus custom patterns | The nine types listed above; no custom patterns |
192+| Enabling | A repository or organization setting | Always on for every repository; cannot be turned off |
193+| 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; repository admins who are people only |
194+| Excluding files | Paths in `.github/secret_scanning.yml` | A `diffinery:allow-secret` marker inside each file |
195+| Notification | Secret scanning alerts in the repository | Audit log entries and email to the pusher (and to admins on a bypass) |
196+| Existing history | Secret scanning can also scan existing content | Only new blobs in each push (and each file written through the contents and git data APIs) are scanned |
197+
198+## Troubleshooting
199+
200+| Symptom | Cause and fix |
201+|---|---|
202+| `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). |
203+| 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. |
204+| `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. |
205+| `-o allow-secrets=` seems to be ignored | The reason is empty. Give a non-empty reason, for example `-o allow-secrets="test fixture"`. |
206+| A real key was not caught | It is not one of the formats above, it is in a binary file or in a pushed file of 1 MiB or more, the file contains `diffinery:allow-secret`, or the change was made with the web editor, `diffinery/commits` or MCP. Rotate the key and remove it. |
207+| `push protection: possible secret detected (...)` (422) from the API | A file you wrote with the contents or git data API matched. Remove the value, or mark a test fixture with `diffinery:allow-secret`. API writes have no bypass. |
208+| `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](/docs/repositories/git-lfs). |
addedapps/diffinery/docs/self-hosting/architecture.md+246−0View file
@@ -1,0 +1,246 @@
1+---
2+title: Architecture
3+description: 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.
4+---
5+
6+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.
7+
8+## Overview
9+
10+| Piece of Diffinery | Cloudflare product | Binding |
11+| --- | --- | --- |
12+| Web UI and public docs, REST API, git smart HTTP, Git LFS, MCP server, container registry, runner API, release asset uploads and downloads, source archives, profile pictures | Workers, with the Hono router | The Worker itself (`src/index.ts`) |
13+| 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` |
14+| 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 | D1, read through the Sessions API | `DB` |
15+| 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 | R2 | `BUCKET` |
16+| Webhooks (and retries of failed deliveries), notifications, audit fan-out | Queues (`diffinery-events`, dead-letter queue `diffinery-events-dlq`) | `EVENTS` |
17+| Email jobs | Queues (`diffinery-mail`, dead-letter queue `diffinery-mail-dlq`) | `MAIL` |
18+| Workflow run orchestration (durable across restarts) | Workflows (`diffinery-run`, class `RunWorkflow`) | `RUN_WORKFLOW` |
19+| Hosted job runners | Containers (`RunnerContainer`, one container per job) | `RUNNER` |
20+| Agent sandboxes | Containers (`SandboxContainer`, one container per sandbox) | `SANDBOX` |
21+| Scheduled workflows, token expiry reminders, email log pruning, registry cleanup | Cron Triggers, every 5 minutes | `scheduled` handler |
22+| Live run logs and pull request updates in the browser | Durable Object WebSockets with hibernation | Through `REPO` |
23+| Outgoing email | Email Sending | `EMAIL` |
24+| Email-based account recovery and first admin sign-in | Cloudflare Access, on `/auth/access` only | `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` |
25+
26+```text
27+ browser, git, CLI, MCP client, docker, runner agent
28+ |
29+ v
30+ +---------------------------+ +--------------------+
31+ | Worker (Hono) |-------->| D1 (Sessions API) |
32+ | auth, routing, D1 reads | +--------------------+
33+ +---------------------------+
34+ | | | \
35+ v v v \----------------> R2
36+ +------------+ +--------+ +------------------+
37+ | RepoDO | | Queues | | Containers |
38+ | (one per | | events | | RunnerContainer |
39+ | repo, | | mail | | SandboxContainer |
40+ | SQLite) | +--------+ +------------------+
41+ +------------+ | ^
42+ | ^ v |
43+ | | webhooks, email, |
44+ v | audit log |
45+ Workflows (RunWorkflow) ------------+
46+```
47+
48+## The Worker
49+
50+`apps/diffinery/src/index.ts` exports the Worker's `fetch`, `queue` and `scheduled` handlers, and the classes Cloudflare instantiates: `RepoDO`, `RunnerContainer`, `SandboxContainer` and `RunWorkflow`.
51+
52+Every request goes through one middleware before it reaches a route:
53+
54+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.
55+2. **D1 session.** All D1 queries of the request run in one D1 session (see [D1 and read replication](#d1-and-read-replication)).
56+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. For everything else, a `dfy_` token in the `Authorization` header (Bearer, `token` or Basic) becomes an account principal with the token's scopes. Job tokens (`djt_`) and sandbox tokens (`dst_`) become a principal for one repository. Otherwise the session cookie from a passkey sign-in is checked. Otherwise the request is anonymous.
57+4. After the route runs, the response gets security headers and the `x-d1-bookmark` header.
58+
59+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 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.
60+
61+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.
62+
63+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.
64+
65+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:
66+
67+- An anonymous caller (no token, no session) gets `401 authentication required` for any write, even on a public repository.
68+- A personal or agent token whose scope is only `read` gets `403 this token needs the write scope`.
69+- Browser sessions act with full scope, and job and sandbox tokens are limited by their own role instead.
70+
71+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 revoking tokens (`403 this token is limited to repositories and can't change account settings`).
72+
73+## RepoDO: one Durable Object per repository
74+
75+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.
76+
77+The object keeps its data in its own SQLite database (tables in [Data model](#data-model)). Inside it:
78+
79+- **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.
80+- **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.
81+- **Collaboration.** Issues, pull requests, comments, reviews, reactions, issue events, labels, check runs and commit statuses, webhooks configuration and mergeability.
82+- **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 tag, so tags pushed by agents never match an environment's tag rules.
83+- **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.
84+- **Actions.** Parsing workflows from the pushed commit, creating runs and jobs, the runner API's server side, logs while a job runs, caches and artifacts metadata, secrets (encrypted with a key derived from `MASTER_KEY`) and variables, and sandboxes.
85+- **Merge queue.** One queue per protected branch. The object's single alarm wakes it when a merge group's check timeout runs out.
86+- **Live updates.** WebSocket connections, see [Live updates](#live-updates).
87+
88+Deleting a repository wipes the object's storage.
89+
90+## D1 and read replication
91+
92+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/`.
93+
94+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:
95+
96+- 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`).
97+- 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.
98+- Durable Objects, queue consumers and the cron handler use the D1 binding directly, which always goes to the primary.
99+
100+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](/docs/reference/rest-api) page explains how API clients use the bookmark header.
101+
102+## R2
103+
104+One bucket (`diffinery-data`) stores everything too large for SQLite or D1:
105+
106+- git objects over 512 KiB compressed,
107+- Git LFS objects (verified against their SHA-256 on upload),
108+- job logs, archived from the Durable Object when the job completes,
109+- Actions caches and artifacts,
110+- container image blobs and manifests, with uploads written as multipart uploads,
111+- an append-only archive of every event that goes through the events queue,
112+- release assets (up to 100 MB each),
113+- source archives (zipball and tarball downloads), cached after they are first built,
114+- profile pictures of people and organizations, stored as small WebP files in a few sizes.
115+
116+## Queues
117+
118+**`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. A failing event is retried with backoff up to 8 times, then moved to `diffinery-events-dlq`.
119+
120+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. Retry messages are not added to the R2 event archive.
121+
122+**`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.
123+
124+## Workflows
125+
126+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.
127+
128+1. **Plan.** Ask the repository's Durable Object for the job graph, in levels by `needs`. If the run's concurrency group is busy, sleep a minute and plan again, for up to 6 hours.
129+2. **Start each level.** Start every job of the level (each start retried up to 3 times). Matrix jobs are expanded here, and up to `max-parallel` start at once.
130+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.
131+4. **Finish.** Compute the run's conclusion, which also triggers `workflow_run` followers.
132+
133+## Containers
134+
135+Both 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).
136+
137+- **`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, artifact and OIDC endpoints, reports completion and exits. If the container stops before the job reports completion, the job fails with "The runner stopped unexpectedly". Instance type `standard-2`, at most 100 at once, internet access on. Docker is started inside the container the first time a job uses it.
138+- **`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.
139+
140+Self-hosted runners (`runs-on: [self-hosted, ...]`) run the same agent outside Cloudflare. They poll `POST /api/v3/repos/{owner}/{repo}/actions/runners/claim` and then talk to the same runner API. See [Runners](/docs/actions/runners).
141+
142+## Cron trigger
143+
144+The `scheduled` handler runs every 5 minutes (`*/5 * * * *`, GitHub's minimum schedule interval) and:
145+
146+1. Cleans up the container registry: abandoned uploads and blobs no image uses.
147+2. 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 of the last 5.
148+3. Sends token expiry reminders and, once a day, prunes the email log.
149+
150+## Live updates
151+
152+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.
153+
154+## Email Sending and Access
155+
156+- **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.
157+- **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.
158+
159+## Request flow: a git push
160+
161+1. `git push` sends `POST /<owner>/<repo>.git/git-receive-pack` with a token in Basic authentication.
162+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.
163+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.
164+4. **Push protection** scans every new blob under 1 MiB for credentials. 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.
165+5. The objects are written (SQLite or R2 by size).
166+6. Each ref update is checked: valid ref name, write role, `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.
167+7. Each accepted ref moves with a compare-and-swap.
168+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.
169+9. The report goes back to git, including messages like "Create a pull request for 'feature':" with a link.
170+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.
171+
172+## Request flow: a workflow run
173+
174+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.
175+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.
176+3. The Workflow plans the job graph and starts the first level. For each job the Durable Object expands reusable workflows and the matrix, inserts job rows and a queued check run on the commit, creates a job 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.
177+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 runner API; OIDC tokens are signed with `OIDC_PRIVATE_KEY`.
178+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.
179+6. The Workflow moves to the next level. When all levels are 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.
180+
181+## Packages in the repository
182+
183+| Path | Package | What it contains |
184+| --- | --- | --- |
185+| `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. |
186+| `apps/cli` | `diffinery` | The command-line client. |
187+| `containers/runner` | `@diffinery/runner` | The runner agent and sandbox server (bundled to `dist/agent.mjs`) and the container image. |
188+| `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. |
189+| `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. |
190+| `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. |
191+
192+## Data model
193+
194+### D1 tables
195+
196+| Table | Holds |
197+| --- | --- |
198+| `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). |
199+| `org_members` | Organization membership with role `owner`, `admin` or `member`. |
200+| `repos` | Every repository: owner, name, description, visibility (`public` or `private`), topics, default branch, archived flag and timestamps. |
201+| `repo_access` | Direct repository access for a person or agent, with role `read`, `triage`, `write`, `maintain` or `admin`. |
202+| `tokens` | Personal and agent tokens: SHA-256 hash, prefix, scopes, repository restriction, expiry, last use, revocation, expiry reminder. |
203+| `invites` | Invitations to the site, an organization or a repository, by token hash, with expiry and acceptance. |
204+| `access_requests` | Requests from people waiting for an invite. |
205+| `passkeys` | WebAuthn credentials: public key, counter, transports, device type, backup state, name. |
206+| `sessions` | Browser sessions by hash of the cookie value, with the sign-in method and passkey, user agent and expiry. |
207+| `webauthn_challenges` | In-flight passkey ceremonies. |
208+| `signin_links` | Admin-issued single-use sign-in links. |
209+| `notifications` | In-app notifications. |
210+| `audit_log` | The append-only audit log: actor, on-behalf-of, action, target, detail. |
211+| `schedules` | Cron expressions of default-branch workflows, read by the cron trigger. |
212+| `org_secrets` | Organization Actions secrets, encrypted. |
213+| `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. |
214+| `notification_prefs`, `repo_watches`, `thread_subscriptions` | Notification settings, repository watching and per-thread subscriptions. |
215+| `known_devices` | Browsers and passkeys a person has signed in with, so only new ones trigger a security email. |
216+| `email_log` | Every email Diffinery tried to send, with status. |
217+
218+### RepoDO tables
219+
220+| Table | Holds |
221+| --- | --- |
222+| `meta` | The repository's identity: ID, owner, name, default branch, owner kind. |
223+| `refs` | Branches, tags, `refs/pull/*` and merge queue refs. |
224+| `objects` | Git objects: type, size, deflated data, or a flag that the data is in R2. |
225+| `counters` | Number sequences: issue and pull request numbers, run numbers and merge queue order. |
226+| `issues` | Issues and pull requests (they share one number sequence), with title, body, state, author, assignees, labels and lock state. |
227+| `pulls` | Pull request fields: head and base branches, head SHA, draft, merge state, merge commit, requested reviewers, intent. |
228+| `comments` | Issue and pull request comments, and review line comments with path, line, side, commit and resolution. |
229+| `reviews` | Pull request reviews with state and commit. |
230+| `labels` | The repository's labels. |
231+| `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). |
232+| `secrets`, `variables` | Repository Actions secrets (encrypted) and variables. |
233+| `webhooks` | Webhook URL, secret, events, and the last delivery status and time. |
234+| `checks` | Check runs by commit, from workflows and from external tools, with who reported each one (person or agent). |
235+| `statuses` | Commit statuses by commit and context. |
236+| `releases`, `release_assets` | Releases and their asset metadata (name, label, content type, size, download count). |
237+| `deployments`, `deployment_statuses` | Deployments and their status history. |
238+| `environments`, `environment_secrets`, `environment_variables` | Deployment environments with their branch policy, encrypted secrets and variables. |
239+| `reactions` | Reactions on issues, pull requests and comments. |
240+| `issue_events` | The event history of issues and pull requests (for example labeled, assigned, locked). |
241+| `ref_actors` | Who last created or moved each tag, and whether that was a person or an agent. |
242+| `runs`, `jobs` | Workflow runs and their jobs: event, refs, SHAs, status, conclusion, matrix, outputs, steps, timeout, labels. |
243+| `logs` | Log lines of running jobs (moved to R2 when the job completes). |
244+| `caches`, `artifacts` | Cache and artifact metadata; the archives are in R2. |
245+| `merge_queue` | Queued pull requests and their merge groups. |
246+| `sandboxes` | Agent sandboxes, who created them and their status. |
addedapps/diffinery/docs/self-hosting/configuration.md+180−0View file
@@ -1,0 +1,180 @@
1+---
2+title: Configuration
3+description: Every setting of a self-hosted Diffinery: Worker variables and secrets, bindings, the dev environment, and the secrets and variables the deploy workflows use.
4+---
5+
6+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](/docs/self-hosting/deploy).
7+
8+## Where configuration lives
9+
10+| File or place | What it holds | Edit it? |
11+| --- | --- | --- |
12+| `apps/diffinery/wrangler.jsonc` | Worker name, route, variables, bindings, container settings, cron, and the `dev` environment. | Yes. This is the source of truth. |
13+| `apps/diffinery/wrangler.deploy.jsonc` | `wrangler.jsonc` without the `dev` environment, plus the real D1 database ID, `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD`, 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. |
14+| Worker secrets | `MASTER_KEY` and `OIDC_PRIVATE_KEY`. Created by `scripts/ensure-secrets.sh`. | Only to restore them. Never change `MASTER_KEY`. |
15+| Repository secrets and variables | Cloudflare credentials and options for the Deploy workflow, and a token for the live test and sync workflows. | Yes, in your forge's repository settings. |
16+| `apps/diffinery/.dev.vars` | Local secrets for `wrangler dev`, such as `MASTER_KEY`. Ignored by git. | Yes, for local development only. |
17+
18+## Worker variables
19+
20+Set under `vars` in `wrangler.jsonc`. All values are strings.
21+
22+| Variable | Production value in the repository | Required | What it does |
23+| --- | --- | --- | --- |
24+| `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. |
25+| `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. |
26+| `BOOTSTRAP_ADMINS` | A comma-separated list of emails and logins | For the first sign-in | Emails or logins (case-insensitive) that become site administrators. See [BOOTSTRAP_ADMINS](#bootstrap_admins). |
27+| `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. |
28+| `ACCESS_AUD` | Empty (filled in on deploy) | For email sign-in | The audience tag of the Access application that guards `/auth/access`. |
29+| `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). |
30+
31+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.
32+
33+### PUBLIC_URL
34+
35+`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:
36+
37+- **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.
38+- **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.
39+- **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.
40+- **OIDC:** the issuer (`iss`) of workflow ID tokens, and their default audience.
41+- **The container registry host name**, the hostname of `PUBLIC_URL` (images are `<hostname>/<owner>/<name>`).
42+- **The cross-site check:** a cookie-authenticated write with an `Origin` other than this origin is refused.
43+
44+> **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.
45+
46+### BOOTSTRAP_ADMINS
47+
48+A comma-separated list; spaces around entries are ignored and matching is case-insensitive. An entry can be an email address or a login.
49+
50+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 `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](/docs/admin/site-administration).
51+
52+### DEV_AUTH
53+
54+When `DEV_AUTH` is set and `ACCESS_AUD` is empty, 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.
55+
56+> **Important:** Never set `DEV_AUTH` on a deployed instance. Anyone on the internet would act as that account.
57+
58+## Worker secrets
59+
60+Set with `wrangler secret put` (the deploy does this for you with `scripts/ensure-secrets.sh`), never in `wrangler.jsonc`.
61+
62+| Secret | Format | Created by | What it does | If it is missing |
63+| --- | --- | --- | --- | --- |
64+| `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. It also signs the unsubscribe links in emails. | 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. |
65+| `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`. |
66+
67+Neither secret is rotated automatically. Changing `MASTER_KEY` makes every stored secret undecryptable and invalidates unsubscribe links already sent; keep a copy somewhere safe. 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`.
68+
69+To set a secret by hand (for example to restore a backup):
70+
71+```sh
72+cd apps/diffinery
73+printf '%s' "$MASTER_KEY" | pnpm exec wrangler secret put MASTER_KEY -c wrangler.deploy.jsonc
74+```
75+
76+## Bindings
77+
78+Defined in `wrangler.jsonc`. The deploy creates the underlying resources if they don't exist.
79+
80+| Binding | Kind | Resource (production) | What Diffinery uses it for |
81+| --- | --- | --- | --- |
82+| `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, the registry index, email settings and log. |
83+| `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. |
84+| `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. |
85+| `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. |
86+| `EMAIL` | `send_email` (Email Sending) | Your onboarded domain | Sending email. Optional: without it every email is logged as `skipped`. |
87+| `REPO` | Durable Object namespace | Class `RepoDO` (SQLite) | One object per repository. |
88+| `RUNNER` | Durable Object namespace with a container | Class `RunnerContainer` (SQLite) | One container per hosted workflow job. |
89+| `SANDBOX` | Durable Object namespace with a container | Class `SandboxContainer` (SQLite) | One container per agent sandbox. |
90+| `RUN_WORKFLOW` | Workflow | `diffinery-run`, class `RunWorkflow` | One durable instance per workflow run. |
91+
92+The Durable Object classes are declared in one migration with tag `v1` (`new_sqlite_classes`).
93+
94+## Other Worker settings
95+
96+| Setting | Value | Notes |
97+| --- | --- | --- |
98+| `name` | `diffinery` | The Worker's name in your account. |
99+| `main` | `src/index.ts` | |
100+| `compatibility_date` | `2026-10-01` | |
101+| `compatibility_flags` | `nodejs_compat` | |
102+| `workers_dev` | `false` | The instance is only served on your route. |
103+| `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. |
104+| `observability` | Enabled, `head_sampling_rate: 1` | Every request is logged to Workers Logs. |
105+| `upload_source_maps` | `true` | Readable stack traces in the logs. |
106+| `limits.cpu_ms` | `300000` | Up to 5 minutes of CPU time per request (large pushes, diffs and merges). |
107+| `containers` | `RunnerContainer`: `standard-2`, `max_instances: 100`; `SandboxContainer`: `standard-1`, `max_instances: 50`; both `rollout_active_grace_period: 3600` | Both build `containers/runner/Dockerfile` with `containers/runner` as context. Raise `max_instances` for more concurrent jobs or sandboxes. |
108+| `triggers.crons` | `["*/5 * * * *"]` | Scheduled workflows, token expiry reminders, email log pruning, registry cleanup. |
109+
110+## The dev environment
111+
112+`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.
113+
114+| Setting | `dev` value |
115+| --- | --- |
116+| `routes`, `workers_dev` | No routes; `workers_dev: true` |
117+| `PUBLIC_URL` | `http://localhost:8787` |
118+| `BOOTSTRAP_ADMINS`, `DEV_AUTH` | `dev@diffinery.local` |
119+| `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` | Empty |
120+| `DB` | `diffinery-dev` |
121+| `BUCKET` | `diffinery-data-dev` |
122+| Queues | `diffinery-events-dev` and `diffinery-mail-dev`, batches waiting up to 1 second, no dead-letter queues |
123+| `EMAIL` | Captured locally by Miniflare, not delivered |
124+| `RUN_WORKFLOW` | `diffinery-run-dev` |
125+| `containers` | None: hosted runners and sandboxes are unavailable locally; use a self-hosted runner |
126+| `triggers.crons` | None |
127+
128+`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`.
129+
130+## Deploy script settings
131+
132+Environment variables the scripts in `scripts/` read:
133+
134+| Variable | Used by | Default | Meaning |
135+| --- | --- | --- | --- |
136+| `CLOUDFLARE_API_TOKEN` | `bootstrap.sh`, `email-sending.sh`, `ensure-secrets.sh` (through wrangler), `wrangler deploy` | Required | The API token. |
137+| `CLOUDFLARE_ACCOUNT_ID` | `bootstrap.sh`, wrangler | Required | The account to deploy to. |
138+| `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. |
139+| `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. |
140+| `ACCESS_TEAM_DOMAIN` | `bootstrap.sh` | Discovered | Your Zero Trust team domain, when the token can't read it. |
141+| `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. |
142+| `GITHUB_OUTPUT` | `bootstrap.sh` | Set by Actions | Where bootstrap writes `d1_id`, `access_team` and `access_aud_set`. |
143+
144+`scripts/render-config.mjs` takes flags instead:
145+
146+| Flag | Effect |
147+| --- | --- |
148+| `--d1 <database id>` | Required. The D1 `database_id`. |
149+| `--team <team domain>` | Sets `ACCESS_TEAM_DOMAIN`. |
150+| `--aud <audience tag>` | Sets `ACCESS_AUD`. |
151+| `--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. |
152+| `--zone <zone>` | The route's zone, with `--public-url`. Default: the hostname's last two labels. |
153+| `--mail-from <address>` | Sets `MAIL_FROM`, overriding the one `--public-url` sets. |
154+
155+Bootstrap calls it with `--public-url https://$DIFFINERY_DOMAIN`, `--zone $DIFFINERY_ZONE` when that is set, and `--mail-from notifications@${MAIL_DOMAIN:-$DIFFINERY_DOMAIN}`. See [Deploy your own Diffinery](/docs/self-hosting/deploy#scriptsrender-configmjs).
156+
157+## Repository secrets and variables for the workflows
158+
159+The workflows in `.github/workflows/` read these from the repository (or its `production` environment):
160+
161+| Name | Kind | Used by | Value |
162+| --- | --- | --- | --- |
163+| `CLOUDFLARE_API_TOKEN` | Secret | Deploy | Cloudflare API token with the permissions listed in [Deploy your own Diffinery](/docs/self-hosting/deploy). |
164+| `CLOUDFLARE_ACCOUNT_ID` | Secret | Deploy | Your Cloudflare account ID. |
165+| `ACCESS_TEAM_DOMAIN` | Variable | Deploy | Optional. Your Zero Trust team domain, for example `example.cloudflareaccess.com`, when the API token can't read it. |
166+| `DIFFINERY_LIVE_TOKEN` | Secret | Live test, Sync to Diffinery | 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. The sync workflow uses it to create the organization and repository, copy secrets and variables, and push. |
167+
168+The Deploy workflow in the repository doesn't pass `DIFFINERY_DOMAIN`, `DIFFINERY_ZONE` or `MAIL_DOMAIN` to bootstrap. For your own instance, add them to the job's `env` in `.github/workflows/deploy.yml` (see [Deploy your own Diffinery](/docs/self-hosting/deploy#step-1-point-the-code-at-your-domain)).
169+
170+The Deploy job and the Sync job run in an environment named `production`. The Live test and Sync workflows target `https://diffinery.com` and its `codician-team/diffinery` repository; adapt or delete them for your own instance.
171+
172+`scripts/diffinery-wait-runs.sh`, which the sync workflow uses to wait for Actions runs on a commit, reads `DIFFINERY` (the instance origin) and `TOKEN` (a Diffinery token) from the environment:
173+
174+```sh
175+DIFFINERY=https://diffinery.com TOKEN=dfy_... scripts/diffinery-wait-runs.sh acme/website <sha> 60
176+```
177+
178+## Settings that are not configuration
179+
180+Some values are fixed in code and can't be changed without editing it, for example the job timeout ceiling, matrix size, cache quota and session lifetime. They are listed in [Limits](/docs/reference/limits).
addedapps/diffinery/docs/self-hosting/deploy.md+366−0View file
@@ -1,0 +1,366 @@
1+---
2+title: Deploy your own Diffinery
3+description: Deploy Diffinery to your own Cloudflare account and domain with the Deploy workflow and its scripts, sign in as the first admin, and develop locally.
4+---
5+
6+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 with the repository's Deploy workflow and scripts, 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.
7+
8+For what each Cloudflare product does in Diffinery, see [Architecture](/docs/self-hosting/architecture). For every setting, see [Configuration](/docs/self-hosting/configuration).
9+
10+## What you need
11+
12+| Requirement | Why |
13+| --- | --- |
14+| A Cloudflare account on the Workers Paid plan | Diffinery uses Containers (hosted runners and sandboxes), 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. |
15+| 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. |
16+| A domain added to the account as a zone | Diffinery serves `https://<hostname>/*` through a Workers route on that zone. |
17+| 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. |
18+| A copy of the Diffinery repository on a forge with GitHub-compatible Actions | The Deploy workflow (`.github/workflows/deploy.yml`) runs on `ubuntu-latest` runners: GitHub, or a Diffinery instance. You can also run the same steps from your own machine (see [Deploy from your own machine](#deploy-from-your-own-machine)). |
19+
20+Tools the workflow installs or expects on its runner, and that you need for a manual deploy: 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`).
21+
22+Nobody who uses your instance needs a Cloudflare account. Only the deployment uses one.
23+
24+## How a deployment works
25+
26+One workflow run does everything, and every step is safe to repeat:
27+
28+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.
29+2. `scripts/bootstrap.sh` creates whatever is missing in your account (D1 database, R2 bucket, queues, the Cloudflare Access application for `/auth/access`), checks Email Sending, writes `apps/diffinery/wrangler.deploy.jsonc` with the real IDs and your hostname's route, `PUBLIC_URL` and email sender, and applies D1 migrations.
30+3. `wrangler deploy -c wrangler.deploy.jsonc` uploads the Worker, the Durable Object classes, the Workflow and the cron trigger, and builds and pushes the runner image for Containers.
31+4. `scripts/ensure-secrets.sh` creates the Worker's two secrets, `MASTER_KEY` and `OIDC_PRIVATE_KEY`, the first time only.
32+5. On the very first deploy, bootstrap runs again to finish the Access setup, and the Worker is redeployed.
33+6. Smoke tests check `/healthz`, the sign-in pages, static assets and D1 sessions.
34+
35+## Step 1: Point the code at your domain
36+
37+The repository is set up for `diffinery.com`. Your instance's hostname comes from the `DIFFINERY_DOMAIN` environment variable of the deploy: on every deploy, `scripts/bootstrap.sh` passes it to `scripts/render-config.mjs`, which writes the Worker's route, `PUBLIC_URL` and `MAIL_FROM` into the deployed config (see [scripts/render-config.mjs](#scriptsrender-configmjs)). You don't need to change those three values in `wrangler.jsonc`; the deploy replaces them. Before your first deploy, change these places in your copy.
38+
39+**`apps/diffinery/wrangler.jsonc`:** replace the `BOOTSTRAP_ADMINS` list with your own.
40+
41+```jsonc
42+{
43+ "vars": {
44+ // PUBLIC_URL and MAIL_FROM are replaced on deploy; leave them as they are.
45+ "PUBLIC_URL": "https://diffinery.com",
46+ "MAIL_FROM": "notifications@diffinery.com",
47+ "BOOTSTRAP_ADMINS": "you@example.com",
48+ "ACCESS_TEAM_DOMAIN": "",
49+ "ACCESS_AUD": ""
50+ }
51+}
52+```
53+
54+- `BOOTSTRAP_ADMINS`: comma-separated emails (or logins) of the people who become site administrators when they first sign in. Replace the existing list with your own.
55+- Leave `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` empty; bootstrap fills them in.
56+- `routes`, `PUBLIC_URL` and `MAIL_FROM` can stay as they are. The deployed config gets a route for `<DIFFINERY_DOMAIN>/*`, `PUBLIC_URL` set to `https://<DIFFINERY_DOMAIN>` and `MAIL_FROM` set to `notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>`. You can still edit them to match, so that the file reads correctly, but the deploy doesn't use their values.
57+
58+**`.github/workflows/deploy.yml`:**
59+
60+- Add `DIFFINERY_DOMAIN` to the job's `env`. `scripts/bootstrap.sh` uses it for the Worker's route, `PUBLIC_URL`, the email sender, the Access application and the Email Sending check. Without it, the scripts assume `diffinery.com`, and the deployed Worker gets the `diffinery.com/*` route and `PUBLIC_URL` `https://diffinery.com` whatever `wrangler.jsonc` says.
61+- Add `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: example.co.uk`.
62+- Add `MAIL_DOMAIN` if email should come from another domain than your hostname, for example `MAIL_DOMAIN: example.com` to send from `notifications@example.com`.
63+- Replace `https://diffinery.com` in the smoke test steps and in `environment.url` with your origin.
64+- Change the job's `if:` condition. It deploys only when the workflow runs on `https://diffinery.com` or is started by hand.
65+
66+```yaml
67+jobs:
68+ deploy:
69+ name: Deploy to Cloudflare
70+ # Deploy on every push to main of your copy.
71+ if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' }}
72+ runs-on: ubuntu-latest
73+ timeout-minutes: 45
74+ environment:
75+ name: production
76+ url: https://git.example.com
77+ env:
78+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
79+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
80+ ACCESS_TEAM_DOMAIN: ${{ vars.ACCESS_TEAM_DOMAIN }}
81+ DIFFINERY_DOMAIN: git.example.com
82+ # DIFFINERY_ZONE: example.com # only if the zone isn't the last two labels
83+ # MAIL_DOMAIN: example.com # only to send email from another domain
84+```
85+
86+`DIFFINERY_DOMAIN` is a bare hostname, without `https://` or a path. Diffinery is always served over HTTPS at the root of that hostname.
87+
88+**Other workflows.** `.github/workflows/live-test.yml` and `.github/workflows/sync-to-diffinery.yml` test and mirror the public `diffinery.com` instance. Delete them, or change their `DIFFINERY` host and organization names before you enable them. `ci.yml` (typecheck, tests and the end-to-end test) works as is. `dind-probe.yml` is a maintenance probe started by hand; you can delete it. `d1-replication.yml` is optional; see [Architecture](/docs/self-hosting/architecture#d1-and-read-replication).
89+
90+> **Note:** The scripts use fixed resource names: a D1 database `diffinery`, an R2 bucket `diffinery-data`, queues `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail` and `diffinery-mail-dlq`, and a Worker `diffinery`. One Cloudflare account holds one instance unless you rename them in `scripts/bootstrap.sh` and `wrangler.jsonc`.
91+
92+## Step 2: Create a Cloudflare API token
93+
94+In the Cloudflare dashboard, create an API token (**My Profile → API Tokens → Create Token → Create Custom Token**) with these permissions:
95+
96+| Kind | Permission | Level | Used for |
97+| --- | --- | --- | --- |
98+| Account | Workers Scripts | Edit | Deploying the Worker, Durable Objects and secrets |
99+| Account | D1 | Edit | Creating the database and applying migrations |
100+| Account | R2 (Workers R2 Storage) | Edit | Creating the bucket |
101+| Account | Queues | Edit | Creating the queues and consumers |
102+| Account | Workflows | Edit | The `diffinery-run` Workflow |
103+| Account | Containers | Edit | Building and deploying the runner image |
104+| Account | Access: Apps and Policies | Edit | The Access application for `/auth/access` |
105+| Zone (the zone that holds your hostname, `DIFFINERY_ZONE`) | Workers Routes | Edit | The `git.example.com/*` route |
106+| Zone (your zone), optional | Zone Read, 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. |
107+
108+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).
109+
110+Copy your **Account ID** from the dashboard (**Workers & Pages** overview, or any zone's overview page).
111+
112+## Step 3: Add repository secrets and variables
113+
114+In your copy's repository settings, add:
115+
116+| Name | Kind | Value |
117+| --- | --- | --- |
118+| `CLOUDFLARE_API_TOKEN` | Secret | The API token from step 2. |
119+| `CLOUDFLARE_ACCOUNT_ID` | Secret | Your Cloudflare account ID. |
120+| `ACCESS_TEAM_DOMAIN` | Variable, optional | Your Zero Trust team domain, for example `example.cloudflareaccess.com`. Needed only when the token can't read it. |
121+
122+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.
123+
124+## Step 4: Run the Deploy workflow
125+
126+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.
127+
128+### scripts/bootstrap.sh
129+
130+Bootstrap needs `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, and reads these optional variables:
131+
132+| Variable | Default | Used for |
133+| --- | --- | --- |
134+| `DIFFINERY_DOMAIN` | `diffinery.com` | Your hostname: the Worker's route, `PUBLIC_URL` (`https://<DIFFINERY_DOMAIN>`), the Access application and the email sender. |
135+| `DIFFINERY_ZONE` | The hostname's last two labels | The Cloudflare zone the route is added to. Passed to render-config as `--zone`. |
136+| `MAIL_DOMAIN` | `DIFFINERY_DOMAIN` | The domain email is sent from (`notifications@<MAIL_DOMAIN>`) and that the Email Sending check looks at. |
137+| `ACCESS_TEAM_DOMAIN` | Discovered | Your Zero Trust team domain, when the token can't read it. |
138+
139+Each section prints a collapsible log group:
140+
141+1. **D1 database.** Looks for a database named `diffinery` and creates it if missing.
142+2. **R2 bucket.** Creates `diffinery-data` if missing.
143+3. **Queues.** Creates `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail` and `diffinery-mail-dlq` if missing.
144+4. **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.
145+5. **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.
146+6. **Render config and migrate D1.** Runs `scripts/render-config.mjs` with `--public-url https://<DIFFINERY_DOMAIN>`, `--zone <DIFFINERY_ZONE>` (only when set) and `--mail-from notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>`, and then `wrangler d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc`.
147+
148+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.
149+
150+### scripts/render-config.mjs
151+
152+```sh
153+node scripts/render-config.mjs --d1 <database id> [--team <x.cloudflareaccess.com>] [--aud <tag>] \
154+ [--public-url https://git.example.com] [--zone example.com] [--mail-from notifications@example.com]
155+```
156+
157+It reads `apps/diffinery/wrangler.jsonc`, drops the `env` section (the `dev` environment) and `$schema`, and changes it as follows:
158+
159+| Flag | Required | Effect on the deployed config |
160+| --- | --- | --- |
161+| `--d1 <database id>` | Yes | Sets the D1 `database_id`. Without it the script stops with `--d1 <database id> is required`. |
162+| `--team <team domain>` | No | Sets `ACCESS_TEAM_DOMAIN`. |
163+| `--aud <tag>` | No | Sets `ACCESS_AUD`. |
164+| `--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`. |
165+| `--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`. |
166+| `--mail-from <address>` | No | Sets `MAIL_FROM`, overriding the `notifications@<hostname>` that `--public-url` sets. |
167+
168+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`.
169+
170+For `DIFFINERY_DOMAIN=git.example.com`, the deployed config contains:
171+
172+```json
173+{
174+ "routes": [{ "pattern": "git.example.com/*", "zone_name": "example.com" }],
175+ "vars": {
176+ "PUBLIC_URL": "https://git.example.com",
177+ "MAIL_FROM": "notifications@git.example.com"
178+ }
179+}
180+```
181+
182+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.
183+
184+### wrangler deploy
185+
186+`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 both container classes.
187+
188+### scripts/ensure-secrets.sh
189+
190+Creates the Worker's own secrets once and never rotates them:
191+
192+- `MASTER_KEY`: 32 random bytes, base64 (`openssl rand -base64 32`). It is the root key for encrypting Actions secrets and signing unsubscribe links.
193+- `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.
194+
195+Values are generated on the runner 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.
196+
197+### Finishing Access on the first deploy
198+
199+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`.
200+
201+### Checks after the deploy
202+
203+The workflow then checks the live site:
204+
205+- `/healthz` must answer `200` within 5 minutes (30 tries, 10 seconds apart). A new hostname can take a few minutes to get its certificate.
206+- `/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.
207+- `/auth/access` should redirect (`302`) to `https://<team>.cloudflareaccess.com/...`. If not, the job only warns.
208+- 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.
209+- Responses must carry an `x-d1-bookmark` header, which shows that D1 queries run in sessions.
210+
211+## Step 5: Set up email sending
212+
213+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.
214+
215+`scripts/email-sending.sh` runs on every deploy (inside bootstrap) and changes nothing. It:
216+
217+1. Finds the zone that holds the domain (the domain itself or a parent).
218+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`.
219+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>`.
220+
221+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.
222+
223+Once deployed, a site administrator can check delivery in **Settings → Email** (`/settings/admin/email`) or with:
224+
225+```sh
226+curl -sS https://git.example.com/api/v3/diffinery/admin/email -H "Authorization: Bearer dfy_..."
227+```
228+
229+`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.
230+
231+## Step 6: DNS
232+
233+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.
234+
235+If the smoke test fails with `/healthz did not return 200`, this record is the first thing to check.
236+
237+## Step 7: Sign in as the first administrator
238+
239+Diffinery has no passwords and no open sign-up. The first administrator signs in through the email check at `/auth/access`:
240+
241+1. Open `https://git.example.com/auth/access`.
242+2. Cloudflare Access asks for your email and sends you a one-time code. Enter it.
243+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 `@`.
244+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.
245+
246+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**.
247+
248+`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.
249+
250+## Step 8: Cloudflare Access for /auth/access
251+
252+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.
253+
254+- **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.
255+- **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.
256+- **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.
257+- **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.
258+
259+## Deploy from your own machine
260+
261+You can run the same steps without a workflow. From a clone, with the tools listed in [What you need](#what-you-need):
262+
263+```sh
264+export CLOUDFLARE_API_TOKEN=...
265+export CLOUDFLARE_ACCOUNT_ID=...
266+export DIFFINERY_DOMAIN=git.example.com
267+# export DIFFINERY_ZONE=example.com # if the zone isn't the hostname's last two labels
268+# export MAIL_DOMAIN=example.com # to send email from another domain
269+# export ACCESS_TEAM_DOMAIN=example.cloudflareaccess.com # if the token can't read it
270+
271+pnpm install --frozen-lockfile
272+pnpm -r --if-present build
273+scripts/bootstrap.sh
274+(cd apps/diffinery && pnpm exec wrangler deploy -c wrangler.deploy.jsonc)
275+scripts/ensure-secrets.sh
276+```
277+
278+If bootstrap ended with `team=?`, run `scripts/bootstrap.sh` again now that the Worker is live, then deploy once more with `--containers-rollout none`.
279+
280+## Updating
281+
282+To update, bring your copy up to date with upstream and run Deploy again (or push to `main` if your workflow deploys on push). Each run:
283+
284+- Creates nothing that already exists and keeps your D1 data, R2 data, secrets and Access application.
285+- Applies new D1 migrations before the new Worker goes live.
286+- Rebuilds the documentation from the Markdown files in your copy, so `/docs` matches the code you deploy.
287+- 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. A hosted job that is running when its container is replaced gets a warning in its log ("This runner is being replaced by a new version. The job keeps running for up to 14 minutes before it is cancelled.").
288+
289+Keep `MASTER_KEY` safe and never change it: it decrypts every stored Actions secret (repository, organization and environment secrets), and unsubscribe links in emails already sent are signed with it. 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`.
290+
291+## Local development
292+
293+You can run Diffinery on your machine with `wrangler dev`, which simulates D1, R2, Queues, Durable Objects and Workflows locally.
294+
295+```sh
296+pnpm install
297+pnpm -r build
298+pnpm --filter @diffinery/app db:migrate:local
299+pnpm dev
300+```
301+
302+`pnpm dev` runs `wrangler dev -e dev` in `apps/diffinery` and serves `http://localhost:8787`. The `dev` environment differs from production:
303+
304+| | Production | `dev` environment |
305+| --- | --- | --- |
306+| `PUBLIC_URL` | `https://git.example.com` | `http://localhost:8787` |
307+| 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. |
308+| D1, R2, queues, Workflow | `diffinery`, `diffinery-data`, `diffinery-events`, `diffinery-mail`, `diffinery-run` | `diffinery-dev`, `diffinery-data-dev`, `diffinery-events-dev`, `diffinery-mail-dev`, `diffinery-run-dev`, all local |
309+| Containers | Hosted runners and sandboxes | None. `runs-on: ubuntu-latest` jobs and sandboxes can't start; use a self-hosted runner. |
310+| Cron | Every 5 minutes | None |
311+| Email | Sent | Captured by Miniflare, not delivered. `GET /api/v3/diffinery/emails` shows each full message. |
312+| `MASTER_KEY` | Set by `ensure-secrets.sh` | Not set: storing Actions secrets fails until you add one. |
313+
314+To store secrets locally, put a key in `apps/diffinery/.dev.vars` (ignored by git):
315+
316+```sh
317+echo "MASTER_KEY=$(openssl rand -base64 32)" > apps/diffinery/.dev.vars
318+```
319+
320+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`:
321+
322+```sh
323+export DIFFINERY_URL=http://localhost:8787
324+export DIFFINERY_TOKEN=$(curl -sS -X POST http://localhost:8787/api/v3/diffinery/tokens \
325+ -H "content-type: application/json" --data '{"name":"local","scopes":["admin"]}' | jq -r .token)
326+diffinery runner -R dev/website --labels linux --agent containers/runner/dist/agent.mjs
327+```
328+
329+### Tests
330+
331+```sh
332+pnpm -r build && pnpm -r typecheck && pnpm -r test
333+pnpm --filter @diffinery/app exec playwright install --with-deps chromium
334+pnpm --filter @diffinery/app e2e
335+```
336+
337+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.
338+
339+## Troubleshooting
340+
341+| Message | Where | What to do |
342+| --- | --- | --- |
343+| `set CLOUDFLARE_API_TOKEN` or `set CLOUDFLARE_ACCOUNT_ID` | bootstrap, email check | Add the secrets (step 3), or export them for a manual deploy. |
344+| `could not create D1 database (does the token have D1: Edit?)` | bootstrap | Add D1 Edit to the token. |
345+| `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. |
346+| `could not create queue diffinery-events (token has Queues: Edit?)` | bootstrap | Add Queues Edit to the token. |
347+| `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. |
348+| `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. |
349+| `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. |
350+| `Email Sending check failed; email may not be delivered.` | bootstrap | Read the email check's output above it. |
351+| `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. |
352+| `git.example.com is not onboarded to Email Sending, so no email will be delivered.` | email check | Onboard the domain (step 5). |
353+| `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. |
354+| `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. |
355+| `--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. |
356+| `--d1 <database id> is required` | render-config | You ran the script by hand without `--d1`. Pass the D1 database ID that bootstrap prints. |
357+| `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. |
358+| `/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`. |
359+| `/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`. |
360+| `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. |
361+| "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. |
362+| "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. |
363+| "Waiting for an invite" after the email check | Web UI | Your email isn't in `BOOTSTRAP_ADMINS` and nobody invited you. Fix the list in `wrangler.jsonc`, deploy, and sign in through `/auth/access` again. |
364+| `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. |
365+| `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. |
366+| `OIDC is not configured on this Diffinery instance` | Workflow jobs with `id-token: write` | `OIDC_PRIVATE_KEY` is missing. Run `scripts/ensure-secrets.sh`. |
modifiedapps/diffinery/package.json+3−2View file
@@ -11,8 +11,9 @@
1111 "cf-typegen": "wrangler types",
1212 "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev",
14- "build": "node islands/build.mjs",
15- "predev": "node islands/build.mjs"
14+ "build": "node islands/build.mjs && node scripts/build-docs.mjs",
15+ "predev": "node islands/build.mjs && node scripts/build-docs.mjs",
16+ "build:docs": "node scripts/build-docs.mjs"
1617 },
1718 "dependencies": {
1819 "@cloudflare/containers": "^0.3.7",
addedapps/diffinery/scripts/build-docs.mjs+225−0View file
@@ -1,0 +1,225 @@
1+// Builds the public documentation into src/docs/content.generated.ts:
2+// - every Markdown page under docs/ (with its frontmatter), in the order of docs/nav.json
3+// - the CLI reference, generated from the CLI's own command registry
4+// - the CLI itself as one file, served at /-/cli/diffinery.mjs
5+// The REST API and MCP tool references are generated at runtime from the live
6+// router and tool list (src/docs/reference.ts), so none of the references can drift.
7+//
8+// Run by `pnpm build` and `pnpm dev`. Fails on a page missing from the nav (or the
9+// other way round) and on missing or overlong frontmatter.
10+import { build } from "esbuild";
11+import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
12+import { tmpdir } from "node:os";
13+import path from "node:path";
14+import { fileURLToPath, pathToFileURL } from "node:url";
15+
16+const here = path.dirname(fileURLToPath(import.meta.url));
17+const app = path.resolve(here, "..");
18+const docsDir = path.join(app, "docs");
19+const cliSrc = path.resolve(app, "../cli/src");
20+const out = path.join(app, "src/docs/content.generated.ts");
21+
22+/** Pages produced in code rather than from a Markdown file. */
23+const RUNTIME_PAGES = new Set(["api", "reference/mcp-tools"]);
24+const BUILD_PAGES = new Set(["reference/cli"]);
25+
26+const problems = [];
27+
28+async function walk(dir) {
29+ const files = [];
30+ for (const e of await readdir(dir, { withFileTypes: true })) {
31+ const p = path.join(dir, e.name);
32+ if (e.isDirectory()) files.push(...(await walk(p)));
33+ else if (e.name.endsWith(".md")) files.push(p);
34+ }
35+ return files;
36+}
37+
38+function parsePage(slug, text) {
39+ const m = /^---\n([\s\S]*?)\n---\n?/.exec(text.replace(/\r\n?/g, "\n"));
40+ if (!m) {
41+ problems.push(`${slug}.md: missing frontmatter (--- title / description ---)`);
42+ return { title: slug, description: "", markdown: text };
43+ }
44+ const meta = {};
45+ for (const line of m[1].split("\n")) {
46+ const kv = /^(\w+):\s*(.*)$/.exec(line);
47+ if (kv) meta[kv[1]] = kv[2].trim().replace(/^(["'])(.*)\1$/, "$2");
48+ }
49+ if (!meta.title) problems.push(`${slug}.md: frontmatter has no title`);
50+ if (!meta.description) problems.push(`${slug}.md: frontmatter has no description`);
51+ else if (meta.description.length > 200) problems.push(`${slug}.md: description is ${meta.description.length} characters (keep it under 200)`);
52+ const markdown = text.replace(/\r\n?/g, "\n").slice(m[0].length).trim() + "\n";
53+ if (/^# /m.test(markdown.replace(/```[\s\S]*?```/g, ""))) problems.push(`${slug}.md: don't use an H1 (the title is the H1)`);
54+ return { title: meta.title ?? slug, description: meta.description ?? "", markdown };
55+}
56+
57+// ---------------------------------------------------------------- the CLI
58+
59+// Lets bundled CommonJS dependencies call require() (same banner as apps/cli's own build).
60+const REQUIRE_SHIM = "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);";
61+
62+async function bundleCli(tmp) {
63+ // The registry, for the reference page.
64+ const regFile = path.join(tmp, "registry.mjs");
65+ await build({
66+ stdin: {
67+ contents: `export { registry } from "./main.ts"; export { optionsFor } from "./cli.ts"; export { VERSION } from "./version.ts";`,
68+ resolveDir: cliSrc,
69+ loader: "ts",
70+ },
71+ bundle: true,
72+ platform: "node",
73+ target: "node22",
74+ format: "esm",
75+ outfile: regFile,
76+ banner: { js: REQUIRE_SHIM },
77+ logLevel: "error",
78+ });
79+ // The CLI itself, minified, for /-/cli/diffinery.mjs.
80+ const binFile = path.join(tmp, "diffinery.mjs");
81+ await build({
82+ entryPoints: [path.join(cliSrc, "bin.ts")],
83+ bundle: true,
84+ minify: true,
85+ platform: "node",
86+ target: "node22",
87+ format: "esm",
88+ outfile: binFile,
89+ banner: { js: `#!/usr/bin/env node\n${REQUIRE_SHIM}` },
90+ logLevel: "error",
91+ });
92+ const mod = await import(pathToFileURL(regFile).href);
93+ return { ...mod, bin: await readFile(binFile, "utf8") };
94+}
95+
96+function cliReference({ registry, optionsFor, VERSION }) {
97+ const all = [...registry.groups.flatMap((g) => g.commands), ...registry.commands];
98+ const groups = [...registry.groups].sort((a, b) => a.name.localeCompare(b.name));
99+ const singles = [...registry.commands].sort((a, b) => a.name.localeCompare(b.name));
100+ const lines = [
101+ `This page lists every command of the \`diffinery\` command-line client (version ${VERSION}) with all of its options. It is generated from the CLI's own command definitions, so it always matches \`diffinery <command> --help\`.`,
102+ "",
103+ "## Install",
104+ "",
105+ "The CLI is a single JavaScript file and needs Node.js 22 or later.",
106+ "",
107+ "```sh",
108+ "mkdir -p ~/.local/bin",
109+ "curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery",
110+ "chmod +x ~/.local/bin/diffinery",
111+ "diffinery --version",
112+ "```",
113+ "",
114+ "Make sure `~/.local/bin` is on your `PATH`. To update, download the file again.",
115+ "",
116+ "## Configuration",
117+ "",
118+ "| Setting | Where | Notes |",
119+ "| --- | --- | --- |",
120+ "| Server URL | `DIFFINERY_URL`, else `url` in the config file, else `https://diffinery.com` | Set this for your own Diffinery instance. |",
121+ "| Token | `DIFFINERY_TOKEN`, else `token` in the config file | `diffinery login` stores a token in the config file. |",
122+ "| Config file | `~/.config/diffinery/config.json` (`$XDG_CONFIG_HOME/diffinery/config.json` when set) | Written by `diffinery login`. |",
123+ "",
124+ "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.",
125+ "",
126+ "Most read commands take `--json` to print the raw API response, for scripts and agents.",
127+ "",
128+ "## Exit codes",
129+ "",
130+ "| Code | Meaning |",
131+ "| --- | --- |",
132+ "| `0` | Success. |",
133+ "| `1` | The command failed (an API error, a failed check, a failed run when waiting). |",
134+ "| `2` | Usage error: unknown command, bad option or missing argument. Help is printed to stderr. |",
135+ "",
136+ "## Commands",
137+ "",
138+ "| Command | Summary |",
139+ "| --- | --- |",
140+ ...[...groups.map((g) => [g.name, g.summary]), ...singles.map((c) => [c.name, c.summary])].sort((a, b) => a[0].localeCompare(b[0])).map(([n, s]) => `| [\`${n}\`](#${slug(`diffinery ${n}`)}) | ${s} |`),
141+ "",
142+ ];
143+ const cmdSection = (cmd, level) => {
144+ const opts = optionsFor(cmd);
145+ const usage = `diffinery ${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}${Object.keys(opts).length > 1 ? " [options]" : ""}`;
146+ lines.push(`${"#".repeat(level)} diffinery ${cmd.name}`, "", cmd.summary.replace(/\.?$/, "."), "", "```text", usage, "```", "");
147+ if (cmd.description) lines.push(cmd.description.trim(), "");
148+ const rows = Object.entries(opts).filter(([k]) => k !== "help");
149+ if (rows.length) {
150+ lines.push("| Option | Description |", "| --- | --- |");
151+ for (const [k, o] of rows) {
152+ const flag = `${o.short ? `-${o.short}, ` : ""}--${k}${o.type === "string" ? ` ${o.arg ?? "VALUE"}` : ""}`;
153+ lines.push(`| \`${flag}\` | ${o.desc.replace(/\|/g, "\\|")}${o.multiple ? " Repeatable." : ""} |`);
154+ }
155+ lines.push("");
156+ }
157+ };
158+ for (const g of groups) {
159+ lines.push(`### diffinery ${g.name}`, "", g.summary.replace(/\.?$/, "."), "");
160+ for (const c of [...g.commands].sort((a, b) => a.name.localeCompare(b.name))) cmdSection(c, 4);
161+ }
162+ for (const c of singles) cmdSection(c, 3);
163+ if (all.length < 10) problems.push("the CLI registry looks empty");
164+ return lines.join("\n");
165+}
166+
167+// Same rules as slugify() in src/web/markdown.ts.
168+function slug(text) {
169+ return text
170+ .toLowerCase()
171+ .replace(/<[^>]*>/g, "")
172+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
173+ .trim()
174+ .replace(/\s+/g, "-")
175+ .slice(0, 80);
176+}
177+
178+// ---------------------------------------------------------------- main
179+
180+const nav = JSON.parse(await readFile(path.join(docsDir, "nav.json"), "utf8"));
181+const navSlugs = nav.flatMap((s) => s.pages);
182+const dupes = navSlugs.filter((s, i) => navSlugs.indexOf(s) !== i);
183+if (dupes.length) problems.push(`docs/nav.json lists ${dupes.join(", ")} more than once`);
184+
185+const files = await walk(docsDir);
186+const pages = {};
187+for (const f of files) {
188+ const s = path.relative(docsDir, f).replace(/\\/g, "/").replace(/\.md$/, "");
189+ pages[s] = parsePage(s, await readFile(f, "utf8"));
190+}
191+// DOCS_DRAFT=1 tolerates pages that aren't written yet (local work in progress only).
192+for (const s of navSlugs) if (!pages[s] && !RUNTIME_PAGES.has(s) && !BUILD_PAGES.has(s) && !process.env.DOCS_DRAFT) problems.push(`docs/nav.json lists "${s}" but docs/${s}.md doesn't exist`);
193+for (const s of Object.keys(pages)) if (!navSlugs.includes(s)) problems.push(`docs/${s}.md isn't listed in docs/nav.json`);
194+
195+const tmp = await mkdtemp(path.join(tmpdir(), "diffinery-docs-"));
196+let cli;
197+try {
198+ cli = await bundleCli(tmp);
199+} finally {
200+ await rm(tmp, { recursive: true, force: true });
201+}
202+pages["reference/cli"] = {
203+ title: "CLI reference",
204+ description: "Every command and option of the diffinery command-line client, generated from its source.",
205+ markdown: cliReference(cli) + "\n",
206+};
207+
208+if (problems.length) {
209+ console.error(`docs: ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}`);
210+ process.exit(1);
211+}
212+
213+const ordered = Object.fromEntries(navSlugs.filter((s) => pages[s]).map((s) => [s, pages[s]]));
214+const src = `// Generated by scripts/build-docs.mjs from docs/ and apps/cli. Do not edit.
215+/* eslint-disable */
216+export interface DocPage { title: string; description: string; markdown: string }
217+export interface DocSection { title: string; pages: string[] }
218+export const DOCS_NAV: DocSection[] = ${JSON.stringify(nav)};
219+export const DOCS_PAGES: Record<string, DocPage> = ${JSON.stringify(ordered)};
220+export const CLI_VERSION = ${JSON.stringify(cli.VERSION)};
221+export const CLI_BUNDLE = ${JSON.stringify(cli.bin)};
222+`;
223+await mkdir(path.dirname(out), { recursive: true });
224+await writeFile(out, src);
225+console.log(`docs: ${Object.keys(ordered).length} pages (+${RUNTIME_PAGES.size} generated at runtime), CLI ${cli.VERSION} (${Math.round(cli.bin.length / 1024)} KiB)`);
modifiedapps/diffinery/src/auth/principal.ts+1−1View file
@@ -59,7 +59,7 @@
5959 if (!repoId) throw new HttpError(401, "malformed token");
6060 return { type: "repo-token", repoId, token: cred };
6161 }
62- throw new HttpError(401, "unrecognised token; create one under Settings → Tokens");
62+ throw new HttpError(401, "unrecognised token; create one under Settings → Access tokens");
6363 }
6464 // Browsers sign in with a passkey (or the Access recovery path) and carry a session cookie.
6565 const session = cookieFrom(req, SESSION_COOKIE);
addedapps/diffinery/src/docs/api-meta.ts+1158−0View file
@@ -1,0 +1,1158 @@
1+/**
2+ * Hand-written notes for the generated REST API reference (/docs/api and
3+ * /docs/api/openapi.json). The list of endpoints itself comes from the live
4+ * router, so an endpoint can't be missing from the docs: one without an entry
5+ * here still appears, with a summary derived from its method and path. A unit
6+ * test fails when an entry here no longer matches a route.
7+ *
8+ * Keys are "METHOD /path" with OpenAPI-style {params}, relative to /api/v3.
9+ */
10+
11+/** Who may call an endpoint: a repository role, or an account-level requirement. */
12+export type Access = "public" | "account" | "read" | "triage" | "write" | "maintain" | "admin" | "org-admin" | "site-admin";
13+
14+/** [type, description, required?] */
15+export type Field = [string, string, boolean?];
16+
17+export interface OpDoc {
18+ summary: string;
19+ description?: string;
20+ access?: Access;
21+ query?: Record<string, Field>;
22+ body?: Record<string, Field>;
23+ /** Success status, when it isn't 200. */
24+ status?: number;
25+}
26+
27+export type Scope = "read" | "write" | "admin";
28+
29+/**
30+ * Endpoints whose token requirement differs from the default (GET needs a
31+ * read token, anything else a write token). These are account-level changes,
32+ * so a token limited to repositories is refused too, except where noted.
33+ */
34+export const ACCOUNT_SCOPES: Record<string, { scope: Scope; anyToken?: boolean }> = {
35+ "POST /user/orgs": { scope: "admin" },
36+ "POST /orgs/{org}/invitations": { scope: "admin" },
37+ "POST /invitations": { scope: "admin" },
38+ "POST /user/repos": { scope: "write" },
39+ "POST /orgs/{org}/repos": { scope: "write" },
40+ "GET /orgs/{org}/actions/secrets": { scope: "admin" },
41+ "PUT /orgs/{org}/actions/secrets/{name}": { scope: "admin" },
42+ "DELETE /orgs/{org}/actions/secrets/{name}": { scope: "admin" },
43+ "POST /diffinery/tokens": { scope: "admin", anyToken: true },
44+ "DELETE /diffinery/tokens/{id}": { scope: "admin" },
45+ "POST /diffinery/agents": { scope: "admin" },
46+ "POST /diffinery/agents/{login}/tokens": { scope: "admin" },
47+ "DELETE /diffinery/agents/{login}": { scope: "admin" },
48+ "PUT /notifications": { scope: "write" },
49+ "PUT /diffinery/notification-settings": { scope: "write" },
50+ "POST /diffinery/emails/test": { scope: "write" },
51+ "POST /diffinery/admin/emails/samples": { scope: "admin" },
52+ "PUT /user/avatar": { scope: "write" },
53+ "DELETE /user/avatar": { scope: "write" },
54+ "PUT /orgs/{org}/avatar": { scope: "write" },
55+ "DELETE /orgs/{org}/avatar": { scope: "write" },
56+};
57+
58+const page: Record<string, Field> = {
59+ per_page: ["integer", "Results per page (default 30, at most 100)."],
60+ page: ["integer", "Page number, starting at 1."],
61+};
62+
63+export const API_DOCS: Record<string, OpDoc> = {
64+ // ------------------------------------------------------------ meta, users and organizations
65+ "GET /": { summary: "API root", description: "Names the API and links to these docs and the MCP endpoint.", access: "public" },
66+ "GET /meta": { summary: "Instance metadata", description: "Whether this instance issues OIDC tokens to workflows.", access: "public" },
67+ "GET /rate_limit": {
68+ summary: "Get rate limit status",
69+ description: "For GitHub clients that check their budget before calling. Diffinery doesn't meter API calls, so this always reports a full, constant budget (15000 core requests an hour).",
70+ access: "public",
71+ },
72+ "GET /user": { summary: "Get the authenticated account", description: "Returns your account, including `email` and `site_admin`. Agents get their own account (type `Bot`).", access: "account" },
73+ "PUT /user/avatar": {
74+ summary: "Set your profile picture",
75+ description:
76+ "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.",
77+ access: "account",
78+ },
79+ "DELETE /user/avatar": { summary: "Remove your profile picture", description: "Diffinery only. Goes back to generated initials and returns the `avatar_url`. Same token rules as setting one. Audited.", access: "account" },
80+ "GET /users/{login}": { summary: "Get an account", description: "A user, organization or agent by login.", access: "account" },
81+ "GET /user/orgs": { summary: "List your organizations", description: "Each organization carries your `role` in it (member, admin or owner).", access: "account" },
82+ "POST /user/orgs": {
83+ summary: "Create an organization",
84+ access: "account",
85+ status: 201,
86+ body: { login: ["string", "Organization login (letters, digits and dashes).", true], name: ["string", "Display name (default: the login)."] },
87+ },
88+ "GET /orgs/{org}/members": { summary: "List organization members", description: "Members only (or site admins). Each entry carries the member's `role`.", access: "account" },
89+ "POST /orgs/{org}/invitations": {
90+ summary: "Invite someone to an organization",
91+ description: "Emails an invite link. The response holds the link too, so you can share it yourself.",
92+ access: "org-admin",
93+ status: 201,
94+ body: { email: ["string", "Email address to invite.", true], role: ["string", "`member` (default), `admin` or `owner`."] },
95+ },
96+ "PUT /orgs/{org}/avatar": {
97+ summary: "Set an organization's picture",
98+ description:
99+ "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.",
100+ access: "org-admin",
101+ },
102+ "DELETE /orgs/{org}/avatar": { summary: "Remove an organization's picture", description: "Diffinery only. Goes back to generated initials and returns the `avatar_url`. Audited.", access: "org-admin" },
103+ "POST /invitations": {
104+ summary: "Invite someone to this instance",
105+ description: "A site-wide invite with no organization. Site admins only.",
106+ access: "site-admin",
107+ status: 201,
108+ body: { email: ["string", "Email address to invite.", true] },
109+ },
110+
111+ // ------------------------------------------------------------ search
112+ "GET /search/issues": {
113+ summary: "Search issues and pull requests",
114+ description:
115+ "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.",
116+ access: "public",
117+ query: {
118+ q: ["string", "Search query, at most 256 characters.", true],
119+ sort: ["string", "`created` (default), `updated` or `comments`."],
120+ order: ["string", "`desc` (default) or `asc`."],
121+ ...page,
122+ },
123+ },
124+ "GET /search/repositories": {
125+ summary: "Search repositories",
126+ description:
127+ "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.",
128+ access: "public",
129+ query: {
130+ q: ["string", "Search query, at most 256 characters.", true],
131+ sort: ["string", "`updated` sorts by last push; anything else keeps the default order."],
132+ order: ["string", "With `sort=updated`: `desc` (default) or `asc`."],
133+ ...page,
134+ },
135+ },
136+
137+ // ------------------------------------------------------------ repositories
138+ "GET /user/repos": { summary: "List repositories you can access", access: "account" },
139+ "POST /user/repos": {
140+ summary: "Create a repository for yourself",
141+ description: "An agent's repositories belong to its owner.",
142+ access: "account",
143+ status: 201,
144+ body: {
145+ name: ["string", "Repository name.", true],
146+ description: ["string", "Short description."],
147+ visibility: ["string", "`private` (default) or `public`."],
148+ default_branch: ["string", "Default branch name (default `main`)."],
149+ },
150+ },
151+ "GET /users/{login}/repos": { summary: "List an account's repositories", description: "Only the ones you can see.", access: "account" },
152+ "GET /orgs/{org}/repos": { summary: "List an organization's repositories", description: "Only the ones you can see.", access: "account" },
153+ "POST /orgs/{org}/repos": {
154+ summary: "Create an organization repository",
155+ access: "account",
156+ status: 201,
157+ body: {
158+ name: ["string", "Repository name.", true],
159+ description: ["string", "Short description."],
160+ visibility: ["string", "`private` (default) or `public`."],
161+ default_branch: ["string", "Default branch name (default `main`)."],
162+ },
163+ },
164+ "GET /repos/{owner}/{repo}": { summary: "Get a repository", description: "Includes your role (`permissions.role`) and repository statistics.", access: "read" },
165+ "PATCH /repos/{owner}/{repo}": {
166+ summary: "Update a repository",
167+ access: "admin",
168+ body: {
169+ name: ["string", "New name."],
170+ description: ["string", "New description."],
171+ visibility: ["string", "`private` or `public`."],
172+ default_branch: ["string", "New default branch (must exist)."],
173+ archived: ["boolean", "Archive (read-only) or unarchive."],
174+ },
175+ },
176+ "DELETE /repos/{owner}/{repo}": { summary: "Delete a repository", description: "People only; agents can't delete repositories. Audited.", access: "admin", status: 204 },
177+ "GET /repos/{owner}/{repo}/collaborators": { summary: "List collaborators", description: "Each entry carries `role_name`.", access: "read" },
178+ "PUT /repos/{owner}/{repo}/collaborators/{login}": {
179+ summary: "Add or change a collaborator",
180+ access: "admin",
181+ status: 204,
182+ body: { permission: ["string", "`read`, `triage`, `write` (default), `maintain` or `admin`; GitHub's `pull` and `push` work too."] },
183+ },
184+ "DELETE /repos/{owner}/{repo}/collaborators/{login}": { summary: "Remove a collaborator", access: "admin", status: 204 },
185+ "GET /repos/{owner}/{repo}/assignees": { summary: "List assignable people", description: "Collaborators with triage access or more, plus the owner when the owner is a user.", access: "read", query: { ...page } },
186+ "GET /repos/{owner}/{repo}/assignees/{login}": {
187+ summary: "Check if someone can be assigned",
188+ description: "204 when `login` is the repository's owner or a collaborator with triage access or more, 404 otherwise.",
189+ access: "read",
190+ status: 204,
191+ },
192+ "GET /repos/{owner}/{repo}/languages": {
193+ summary: "List languages",
194+ description: "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.",
195+ access: "read",
196+ },
197+ "GET /repos/{owner}/{repo}/topics": { summary: "Get topics", description: "Returns `{ names }`.", access: "read" },
198+ "PUT /repos/{owner}/{repo}/topics": {
199+ summary: "Replace topics",
200+ description: "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.",
201+ access: "admin",
202+ body: { names: ["string[]", "Every topic the repository should have (`[]` clears them).", true] },
203+ },
204+ "POST /repos/{owner}/{repo}/dispatches": {
205+ summary: "Trigger a repository_dispatch event",
206+ description: "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.",
207+ access: "write",
208+ status: 204,
209+ body: {
210+ event_type: ["string", "Event type, at most 100 characters.", true],
211+ client_payload: ["object", "Data for workflows as `github.event.client_payload`: at most 10 top-level keys and 64 KB."],
212+ },
213+ },
214+
215+ // ------------------------------------------------------------ branches, tags, refs and commits
216+ "GET /repos/{owner}/{repo}/branches": { summary: "List branches", description: "Each branch says whether it is `protected` and the `default`.", access: "read" },
217+ "GET /repos/{owner}/{repo}/branches/{branch}": { summary: "Get a branch", access: "read" },
218+ "GET /repos/{owner}/{repo}/tags": { summary: "List tags", access: "read" },
219+ "POST /repos/{owner}/{repo}/merges": {
220+ summary: "Merge a branch",
221+ description:
222+ "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.",
223+ access: "write",
224+ status: 201,
225+ body: {
226+ base: ["string", "Branch to merge into.", true],
227+ head: ["string", "Branch, tag or commit to merge.", true],
228+ commit_message: ["string", "Commit message (default: `Merge {head} into {base}`)."],
229+ },
230+ },
231+ "GET /repos/{owner}/{repo}/git/refs/{path}": { summary: "List references", description: "Every ref under `refs/{path}`, for example `heads` or `tags/v1`.", access: "read" },
232+ "POST /repos/{owner}/{repo}/git/refs": {
233+ summary: "Create a reference",
234+ description: "Branch protection and agent rules apply as they do to a push.",
235+ access: "write",
236+ status: 201,
237+ body: { ref: ["string", "Full ref name, like `refs/heads/feature`.", true], sha: ["string", "Commit to point at.", true] },
238+ },
239+ "PATCH /repos/{owner}/{repo}/git/refs/{path}": {
240+ summary: "Update a reference",
241+ access: "write",
242+ body: { sha: ["string", "New target commit.", true], force: ["boolean", "Allow a non-fast-forward update."] },
243+ },
244+ "DELETE /repos/{owner}/{repo}/git/refs/{path}": { summary: "Delete a reference", access: "write", status: 204 },
245+ "GET /repos/{owner}/{repo}/commits": {
246+ summary: "List commits",
247+ access: "read",
248+ query: { sha: ["string", "Branch, tag or commit to start from (default: the default branch)."], path: ["string", "Only commits that touch this path."], ...page },
249+ },
250+ "GET /repos/{owner}/{repo}/commits/{ref}": { summary: "Get a commit", description: "The commit with its diff.", access: "read" },
251+ "GET /repos/{owner}/{repo}/commits/{sha}/pulls": {
252+ summary: "List pull requests for a commit",
253+ description: "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.",
254+ access: "read",
255+ query: { ...page },
256+ },
257+ "GET /repos/{owner}/{repo}/compare/{spec}": { summary: "Compare two refs", description: "`spec` is `base...head`.", access: "read" },
258+ "GET /repos/{owner}/{repo}/raw/{ref}/{path}": { summary: "Download a raw file", description: "The file's bytes, served as text or `application/octet-stream`.", access: "read" },
259+
260+ // ------------------------------------------------------------ contents
261+ "GET /repos/{owner}/{repo}/contents": { summary: "List the root directory", access: "read", query: { ref: ["string", "Branch, tag or commit (default: the default branch)."] } },
262+ "GET /repos/{owner}/{repo}/contents/{path}": {
263+ summary: "Get a file or directory",
264+ description: "A directory returns an array of entries; a file returns its content base64-encoded.",
265+ access: "read",
266+ query: { ref: ["string", "Branch, tag or commit (default: the default branch)."] },
267+ },
268+ "PUT /repos/{owner}/{repo}/contents/{path}": {
269+ summary: "Create or update a file",
270+ description: "Commits one file.",
271+ access: "write",
272+ status: 201,
273+ body: { message: ["string", "Commit message.", true], content: ["string", "New content, base64-encoded.", true], branch: ["string", "Branch to commit to (default: the default branch)."] },
274+ },
275+ "DELETE /repos/{owner}/{repo}/contents/{path}": {
276+ summary: "Delete a file",
277+ access: "write",
278+ body: { message: ["string", "Commit message.", true], branch: ["string", "Branch to commit to (default: the default branch)."] },
279+ },
280+ "POST /repos/{owner}/{repo}/diffinery/commits": {
281+ summary: "Commit several files at once",
282+ description: "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.",
283+ access: "write",
284+ status: 201,
285+ body: {
286+ branch: ["string", "Branch to commit to.", true],
287+ message: ["string", "Commit message.", true],
288+ files: ["array", "Changes: `{ path, content, encoding: \"utf-8\" | \"base64\", mode: \"100644\" | \"100755\" }` or `{ path, delete: true }`.", true],
289+ base: ["string", "Create `branch` from this ref when it doesn't exist."],
290+ expected_head_sha: ["string", "Fail with 409 if the branch has moved."],
291+ },
292+ },
293+ "GET /repos/{owner}/{repo}/readme": {
294+ summary: "Get the README",
295+ description: "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.",
296+ access: "read",
297+ query: { ref: ["string", "Branch, tag or commit (default: the default branch)."] },
298+ },
299+ "GET /repos/{owner}/{repo}/readme/{dir}": {
300+ summary: "Get a directory's README",
301+ description: "Like the root README, for the directory `dir`.",
302+ access: "read",
303+ query: { ref: ["string", "Branch, tag or commit (default: the default branch)."] },
304+ },
305+ ...archiveDocs("tarball", "a tarball", "`.tar.gz`"),
306+ ...archiveDocs("zipball", "a zip archive", "`.zip` (at most 65535 files)"),
307+
308+ // ------------------------------------------------------------ releases
309+ "GET /repos/{owner}/{repo}/releases": { summary: "List releases", description: "Newest first. Drafts are included only for people with write access.", access: "read", query: { ...page } },
310+ "POST /repos/{owner}/{repo}/releases": {
311+ summary: "Create a release",
312+ description:
313+ "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](/docs/collaboration/releases).",
314+ access: "write",
315+ status: 201,
316+ body: {
317+ tag_name: ["string", "Tag to release.", true],
318+ target_commitish: ["string", "Branch, tag or commit for a new tag (default: the default branch)."],
319+ name: ["string", "Release title."],
320+ body: ["string", "Markdown notes, at most 125000 characters."],
321+ draft: ["boolean", "Save as a draft."],
322+ prerelease: ["boolean", "Mark as a prerelease."],
323+ make_latest: ["string", "`true` (default), `false`, or `legacy` (the newest by date)."],
324+ generate_release_notes: ["boolean", "Append generated notes to `body`."],
325+ },
326+ },
327+ "GET /repos/{owner}/{repo}/releases/{id}": { summary: "Get a release", description: "Drafts are 404 for people without write access.", access: "read" },
328+ "PATCH /repos/{owner}/{repo}/releases/{id}": {
329+ summary: "Update a release",
330+ description: "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.",
331+ access: "write",
332+ body: {
333+ tag_name: ["string", "New tag."],
334+ target_commitish: ["string", "Branch, tag or commit for a new tag."],
335+ name: ["string", "Release title."],
336+ body: ["string", "Markdown notes, at most 125000 characters."],
337+ draft: ["boolean", "Draft or published."],
338+ prerelease: ["boolean", "Mark as a prerelease."],
339+ make_latest: ["string", "`true`, `false` or `legacy`."],
340+ },
341+ },
342+ "DELETE /repos/{owner}/{repo}/releases/{id}": { summary: "Delete a release", description: "Deletes its assets too; the tag stays. Sends the `release` webhook.", access: "write", status: 204 },
343+ "GET /repos/{owner}/{repo}/releases/{id}/assets": { summary: "List release assets", access: "read", query: { ...page } },
344+ "GET /repos/{owner}/{repo}/releases/assets/{id}": {
345+ summary: "Get a release asset",
346+ description: "The asset's metadata, or the file itself with `Accept: application/octet-stream`.",
347+ access: "read",
348+ },
349+ "PATCH /repos/{owner}/{repo}/releases/assets/{id}": {
350+ summary: "Update a release asset",
351+ description: "`state` is accepted and ignored.",
352+ access: "write",
353+ body: { name: ["string", "New file name (unique within the release)."], label: ["string", "Display label."] },
354+ },
355+ "DELETE /repos/{owner}/{repo}/releases/assets/{id}": { summary: "Delete a release asset", access: "write", status: 204 },
356+ "POST /repos/{owner}/{repo}/releases/generate-notes": {
357+ summary: "Generate release notes",
358+ description: "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 }`.",
359+ access: "write",
360+ body: {
361+ tag_name: ["string", "Tag for the release.", true],
362+ target_commitish: ["string", "Used when the tag doesn't exist yet (default: the default branch)."],
363+ previous_tag_name: ["string", "Start from this tag (default: the newest earlier published release)."],
364+ configuration_file_path: ["string", "Config file to use instead of `.github/release.yml`."],
365+ },
366+ },
367+ "GET /repos/{owner}/{repo}/releases/latest": {
368+ summary: "Get the latest release",
369+ description: "The release marked latest, or else the newest published release that isn't a prerelease. 404 when there is none.",
370+ access: "read",
371+ },
372+ "GET /repos/{owner}/{repo}/releases/tags/{tag}": { summary: "Get a release by tag", description: "Published releases only; drafts aren't found by tag.", access: "read" },
373+ ...reactionDocs("/repos/{owner}/{repo}/releases/{id}", "a release", "Drafts are hidden from people without write access, and so are their reactions."),
374+
375+ // ------------------------------------------------------------ git database
376+ "POST /repos/{owner}/{repo}/git/blobs": {
377+ summary: "Create a blob",
378+ description: "Push protection applies: content that looks like a credential is refused with 422. Returns `{ url, sha }`.",
379+ access: "write",
380+ status: 201,
381+ body: { content: ["string", "The blob's content.", true], encoding: ["string", "`utf-8` (default) or `base64`."] },
382+ },
383+ "GET /repos/{owner}/{repo}/git/blobs/{sha}": { summary: "Get a blob", description: "Content is base64-encoded; send `Accept: application/vnd.github.raw` for the raw bytes.", access: "read" },
384+ "POST /repos/{owner}/{repo}/git/trees": {
385+ summary: "Create a tree",
386+ description:
387+ "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.",
388+ access: "write",
389+ status: 201,
390+ body: {
391+ tree: ["array", "Entries: `{ path, mode, sha }` or `{ path, mode, content }`. `mode` is `100644` (default), `100755`, `040000`, `160000` or `120000`.", true],
392+ base_tree: ["string", "Tree to start from (default: an empty tree)."],
393+ },
394+ },
395+ "GET /repos/{owner}/{repo}/git/trees/{sha}": {
396+ summary: "Get a tree",
397+ description: "`sha` may be a tree, a commit or a ref. `truncated` is true when a recursive listing stopped at 100000 entries.",
398+ access: "read",
399+ query: { recursive: ["string", "Any value but `0` or `false` lists every entry below, not just the top level."] },
400+ },
401+ "POST /repos/{owner}/{repo}/git/commits": {
402+ summary: "Create a Git commit",
403+ description: "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.",
404+ access: "write",
405+ status: 201,
406+ body: {
407+ message: ["string", "Commit message.", true],
408+ tree: ["string", "SHA of the commit's tree.", true],
409+ parents: ["string[]", "Parent commit SHAs (none for a root commit)."],
410+ author: ["object", "`{ name, email, date }`."],
411+ committer: ["object", "`{ name, email, date }` (default: the author)."],
412+ signature: ["string", "Signature to store in the commit as given."],
413+ },
414+ },
415+ "GET /repos/{owner}/{repo}/git/commits/{sha}": { summary: "Get a Git commit", access: "read" },
416+ "POST /repos/{owner}/{repo}/git/tags": {
417+ summary: "Create a tag object",
418+ description: "Writes an annotated tag object only; create its `refs/tags/...` reference with `POST /repos/{owner}/{repo}/git/refs` to publish it.",
419+ access: "write",
420+ status: 201,
421+ body: {
422+ tag: ["string", "Tag name.", true],
423+ object: ["string", "SHA of the object to tag.", true],
424+ message: ["string", "Tag message."],
425+ type: ["string", "`commit`, `tree`, `blob` or `tag`; must match the object (default: its type)."],
426+ tagger: ["object", "`{ name, email, date }` (default: you)."],
427+ },
428+ },
429+ "GET /repos/{owner}/{repo}/git/tags/{sha}": { summary: "Get a tag object", description: "`verification.verified` is always false.", access: "read" },
430+ "GET /repos/{owner}/{repo}/git/ref/{ref}": {
431+ summary: "Get a reference",
432+ description: "`ref` is the name after `refs/`, like `heads/main` or `tags/v1.0`, and must match exactly.",
433+ access: "read",
434+ },
435+ "GET /repos/{owner}/{repo}/git/matching-refs/{ref}": {
436+ summary: "List matching references",
437+ description: "Refs whose names start with `refs/{ref}`, like `heads/feature` or `tags/v1.`. Pull request refs appear only when `ref` starts with `pull/`.",
438+ access: "read",
439+ query: { per_page: ["integer", "Results per page (default 100, at most 100)."], page: page.page! },
440+ },
441+ "GET /repos/{owner}/{repo}/git/refs": {
442+ summary: "List all references",
443+ description: "Every branch, tag and other ref, without pull request refs.",
444+ access: "read",
445+ query: { per_page: ["integer", "Results per page (default 100, at most 100)."], page: page.page! },
446+ },
447+
448+ // ------------------------------------------------------------ branch protection
449+ "GET /repos/{owner}/{repo}/branches/{branch}/protection": { summary: "Get a protection rule", description: "`branch` is the rule's pattern.", access: "admin" },
450+ "PUT /repos/{owner}/{repo}/branches/{branch}/protection": {
451+ summary: "Create or replace a protection rule",
452+ description: "`branch` is a branch name or pattern. People only; agents can't change protection. See the branch protection guide for every field.",
453+ access: "admin",
454+ },
455+ "DELETE /repos/{owner}/{repo}/branches/{branch}/protection": { summary: "Delete a protection rule", access: "admin", status: 204 },
456+
457+ // ------------------------------------------------------------ issues
458+ "GET /repos/{owner}/{repo}/issues": {
459+ summary: "List issues and pull requests",
460+ description: "As on GitHub, this list includes pull requests. The total is in the `X-Total-Count` header.",
461+ access: "read",
462+ query: { state: ["string", "`open` (default), `closed` or `all`."], labels: ["string", "Only items with this label."], creator: ["string", "Only items opened by this login."], q: ["string", "Text search in titles."], ...page },
463+ },
464+ "POST /repos/{owner}/{repo}/issues": {
465+ summary: "Create an issue",
466+ access: "read",
467+ status: 201,
468+ body: { title: ["string", "Title.", true], body: ["string", "Markdown body."], labels: ["string[]", "Label names."], assignees: ["string[]", "Logins."] },
469+ },
470+ "GET /repos/{owner}/{repo}/issues/{number}": { summary: "Get an issue", access: "read" },
471+ "PATCH /repos/{owner}/{repo}/issues/{number}": {
472+ summary: "Update an issue",
473+ access: "read",
474+ body: {
475+ title: ["string", "New title."],
476+ body: ["string", "New body."],
477+ state: ["string", "`open` or `closed`."],
478+ state_reason: ["string", "Why it was closed, like `completed` or `not_planned`."],
479+ labels: ["string[]", "Replace the labels."],
480+ assignees: ["string[]", "Replace the assignees."],
481+ },
482+ },
483+ "GET /repos/{owner}/{repo}/issues/{number}/comments": {
484+ summary: "List comments",
485+ description: "Conversation comments on an issue or pull request (not review comments).",
486+ access: "read",
487+ query: { since: ["string", "Only comments updated at or after this ISO timestamp."], ...page },
488+ },
489+ "POST /repos/{owner}/{repo}/issues/{number}/comments": { summary: "Add a comment", access: "read", status: 201, body: { body: ["string", "Markdown body.", true] } },
490+ "GET /repos/{owner}/{repo}/issues/comments": {
491+ summary: "List comments in a repository",
492+ description: "Conversation comments on every issue and pull request.",
493+ access: "read",
494+ query: {
495+ since: ["string", "Only comments updated at or after this ISO timestamp."],
496+ sort: ["string", "`created` (default) or `updated`."],
497+ direction: ["string", "`asc` (default) or `desc`."],
498+ ...page,
499+ },
500+ },
501+ "GET /repos/{owner}/{repo}/issues/comments/{id}": { summary: "Get a comment", description: "`id` is the numeric id or Diffinery's own.", access: "read" },
502+ "PATCH /repos/{owner}/{repo}/issues/comments/{id}": { summary: "Edit a comment", access: "read", body: { body: ["string", "New Markdown body.", true] } },
503+ "DELETE /repos/{owner}/{repo}/issues/comments/{id}": { summary: "Delete a comment", description: "Your own comments, or anyone's with maintain access.", access: "read", status: 204 },
504+ ...reactionDocs("/repos/{owner}/{repo}/issues/{number}", "an issue", "Works for pull requests too."),
505+ ...reactionDocs("/repos/{owner}/{repo}/issues/comments/{id}", "a comment"),
506+ "POST /repos/{owner}/{repo}/issues/{number}/assignees": {
507+ summary: "Add assignees",
508+ description: "Adds to the current assignees, at most 10 in all. Logins aren't checked against who can be assigned. Returns the issue.",
509+ access: "triage",
510+ status: 201,
511+ body: { assignees: ["string[]", "Logins to add."] },
512+ },
513+ "DELETE /repos/{owner}/{repo}/issues/{number}/assignees": { summary: "Remove assignees", description: "Returns the issue.", access: "triage", body: { assignees: ["string[]", "Logins to remove."] } },
514+ "PUT /repos/{owner}/{repo}/issues/{number}/lock": {
515+ summary: "Lock a conversation",
516+ description: "While locked, only people with triage access can comment, review or react.",
517+ access: "triage",
518+ status: 204,
519+ body: { lock_reason: ["string", "`off-topic`, `too heated`, `resolved` or `spam`."] },
520+ },
521+ "DELETE /repos/{owner}/{repo}/issues/{number}/lock": { summary: "Unlock a conversation", access: "triage", status: 204 },
522+ "GET /repos/{owner}/{repo}/issues/{number}/events": { summary: "List an issue's events", description: "Labels, assignments, state changes, locks, review requests and the like, oldest first.", access: "read", query: { ...page } },
523+ "GET /repos/{owner}/{repo}/issues/{number}/timeline": {
524+ summary: "List an issue's timeline",
525+ description: "Events, comments (`commented`) and reviews (`reviewed`) together, oldest first.",
526+ access: "read",
527+ query: { ...page },
528+ },
529+ "GET /repos/{owner}/{repo}/issues/events": { summary: "List events in a repository", description: "Events on every issue and pull request, newest first, each with its `issue`.", access: "read", query: { ...page } },
530+ "GET /repos/{owner}/{repo}/issues/events/{id}": { summary: "Get an event", access: "read" },
531+ "GET /repos/{owner}/{repo}/labels": { summary: "List labels", access: "read" },
532+ "POST /repos/{owner}/{repo}/labels": {
533+ summary: "Create a label",
534+ description: "422 when a label with that name exists; change one with `PATCH /repos/{owner}/{repo}/labels/{name}`.",
535+ access: "triage",
536+ status: 201,
537+ body: { name: ["string", "Label name, at most 100 characters.", true], color: ["string", "6-digit hex color, with or without `#` (default `ededed`)."], description: ["string", "Description."] },
538+ },
539+ "GET /repos/{owner}/{repo}/labels/{name}": { summary: "Get a label", access: "read" },
540+ "PATCH /repos/{owner}/{repo}/labels/{name}": {
541+ summary: "Update a label",
542+ description: "Renaming it renames it on every issue too.",
543+ access: "triage",
544+ body: { new_name: ["string", "New name."], color: ["string", "6-digit hex color, with or without `#`."], description: ["string", "Description."] },
545+ },
546+ "DELETE /repos/{owner}/{repo}/labels/{name}": { summary: "Delete a label", description: "Also removes it from every issue.", access: "triage", status: 204 },
547+ "GET /repos/{owner}/{repo}/issues/{number}/labels": { summary: "List an issue's labels", access: "read", query: { ...page } },
548+ "POST /repos/{owner}/{repo}/issues/{number}/labels": {
549+ summary: "Add labels to an issue",
550+ description: "Labels that don't exist yet are created. At most 100 per issue. Returns the issue's labels.",
551+ access: "triage",
552+ body: { labels: ["string[]", "Label names (`{ name }` objects, or a bare array as the body, work too).", true] },
553+ },
554+ "PUT /repos/{owner}/{repo}/issues/{number}/labels": {
555+ summary: "Replace an issue's labels",
556+ description: "Labels that don't exist yet are created; `[]` removes them all. Returns the issue's labels.",
557+ access: "triage",
558+ body: { labels: ["string[]", "Label names (`{ name }` objects, or a bare array as the body, work too).", true] },
559+ },
560+ "DELETE /repos/{owner}/{repo}/issues/{number}/labels": { summary: "Remove all labels from an issue", access: "triage", status: 204 },
561+ "DELETE /repos/{owner}/{repo}/issues/{number}/labels/{name}": {
562+ summary: "Remove a label from an issue",
563+ description: "Returns the labels left. 404 when the issue doesn't have it.",
564+ access: "triage",
565+ },
566+
567+ // ------------------------------------------------------------ pull requests
568+ "GET /repos/{owner}/{repo}/pulls": {
569+ summary: "List pull requests",
570+ description: "The total is in the `X-Total-Count` header.",
571+ access: "read",
572+ query: { state: ["string", "`open` (default), `closed` or `all`."], labels: ["string", "Only pull requests with this label."], creator: ["string", "Only pull requests opened by this login."], q: ["string", "Text search in titles."], ...page },
573+ },
574+ "POST /repos/{owner}/{repo}/pulls": {
575+ summary: "Open a pull request",
576+ access: "write",
577+ status: 201,
578+ body: {
579+ title: ["string", "Title.", true],
580+ head: ["string", "Branch with the changes (`owner:branch` also works).", true],
581+ base: ["string", "Branch to merge into (default: the default branch)."],
582+ body: ["string", "Markdown description."],
583+ intent: ["string", "Diffinery: what the change is meant to do, shown to reviewers."],
584+ draft: ["boolean", "Open as a draft."],
585+ reviewers: ["string[]", "Logins to request reviews from."],
586+ },
587+ },
588+ "GET /repos/{owner}/{repo}/pulls/{number}": {
589+ summary: "Get a pull request",
590+ description: "Includes `mergeable`, `mergeable_state`, `merge_blockers` (why it can't merge yet), `approvals`, `required_approvals` and `checks`.",
591+ access: "read",
592+ },
593+ "PATCH /repos/{owner}/{repo}/pulls/{number}": {
594+ summary: "Update a pull request",
595+ description:
596+ "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, which sends `ready_for_review` or `converted_to_draft`.",
597+ access: "read",
598+ body: {
599+ title: ["string", "New title."],
600+ body: ["string", "New description."],
601+ state: ["string", "`open` or `closed`."],
602+ draft: ["boolean", "`false` marks it ready for review; `true` converts it back to a draft."],
603+ },
604+ },
605+ "PUT /repos/{owner}/{repo}/pulls/{number}/update-branch": {
606+ summary: "Update a pull request's branch",
607+ description:
608+ "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.",
609+ access: "write",
610+ status: 202,
611+ body: { expected_head_sha: ["string", "Fail unless the head branch is at this commit."] },
612+ },
613+ "GET /repos/{owner}/{repo}/pulls/{number}/files": { summary: "List changed files", description: "Each file with its status, additions, deletions and patch.", access: "read" },
614+ "GET /repos/{owner}/{repo}/pulls/{number}/commits": { summary: "List a pull request's commits", access: "read" },
615+ "GET /repos/{owner}/{repo}/pulls/{number}/merge": { summary: "Check mergeability", description: "Whether the pull request can merge now, and if not, the blockers.", access: "read" },
616+ "PUT /repos/{owner}/{repo}/pulls/{number}/merge": {
617+ summary: "Merge a pull request",
618+ description:
619+ "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. 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) can bypass unmet rules with `bypass_rules: true`, or the queue with `bypass_merge_queue: true`, when the rule doesn't include administrators, there are no conflicts and the pull request isn't a draft. A bypass that skipped an unmet rule is audited as `pull_request.merge_bypassed`.",
620+ access: "write",
621+ body: {
622+ merge_method: ["string", "`merge`, `squash` or `rebase`."],
623+ commit_title: ["string", "Title of the merge or squash commit."],
624+ commit_message: ["string", "Body of the merge or squash commit."],
625+ sha: ["string", "Fail if the head has moved from this commit."],
626+ delete_branch: ["boolean", "Delete the head branch after merging."],
627+ bypass_merge_queue: ["boolean", "Merge directly even when a queue is required (admins)."],
628+ bypass_rules: ["boolean", "Merge even though protection rules aren't met (admins). Without it, an admin's merge is blocked like anyone else's."],
629+ },
630+ },
631+ "GET /repos/{owner}/{repo}/merge-queue/{branch}": { summary: "Get a branch's merge queue", description: "Diffinery only (GitHub exposes this through GraphQL). Settings and every entry with its merge group and checks.", access: "read" },
632+ "PUT /repos/{owner}/{repo}/pulls/{number}/merge-queue": {
633+ summary: "Add a pull request to the merge queue",
634+ description: "Diffinery only. Returns 201 with the entry when queued.",
635+ access: "write",
636+ status: 201,
637+ body: { merge_method: ["string", "`merge`, `squash` or `rebase` (default: the queue's method)."], delete_branch: ["boolean", "Delete the head branch once merged."] },
638+ },
639+ "DELETE /repos/{owner}/{repo}/pulls/{number}/merge-queue": { summary: "Remove a pull request from the merge queue", access: "write", status: 204 },
640+ "GET /repos/{owner}/{repo}/pulls/{number}/reviews": { summary: "List reviews", access: "read" },
641+ "POST /repos/{owner}/{repo}/pulls/{number}/reviews": {
642+ summary: "Submit a review",
643+ description: "Approvals from agents don't count toward required approvals.",
644+ access: "read",
645+ status: 201,
646+ body: {
647+ event: ["string", "`APPROVE`, `REQUEST_CHANGES` or `COMMENT` (default)."],
648+ body: ["string", "Review summary."],
649+ comments: ["array", "Line comments: `{ path, line, side: \"LEFT\" | \"RIGHT\", body }`."],
650+ },
651+ },
652+ "GET /repos/{owner}/{repo}/pulls/{number}/reviews/{id}": { summary: "Get a review", description: "`id` is the numeric id or Diffinery's own.", access: "read" },
653+ "PUT /repos/{owner}/{repo}/pulls/{number}/reviews/{id}/dismissals": {
654+ summary: "Dismiss a review",
655+ description: "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.",
656+ access: "maintain",
657+ body: { message: ["string", "Why the review is dismissed.", true] },
658+ },
659+ "GET /repos/{owner}/{repo}/pulls/{number}/comments": { summary: "List review comments", description: "Line comments from reviews.", access: "read", query: { ...page } },
660+ "POST /repos/{owner}/{repo}/pulls/{number}/comments": {
661+ summary: "Create a review comment",
662+ description:
663+ "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.",
664+ access: "read",
665+ status: 201,
666+ body: {
667+ body: ["string", "Markdown body.", true],
668+ path: ["string", "File path (required unless replying)."],
669+ line: ["integer", "Line number (required unless replying)."],
670+ side: ["string", "`RIGHT` (default) or `LEFT`."],
671+ position: ["integer", "Used as `line` when `line` is missing."],
672+ commit_id: ["string", "Accepted and ignored."],
673+ in_reply_to: ["integer", "Review comment id to reply to; its path and line are used."],
674+ },
675+ },
676+ "POST /repos/{owner}/{repo}/pulls/{number}/comments/{id}/replies": {
677+ summary: "Reply to a review comment",
678+ description: "Adds a reply on the same line, as its own `COMMENTED` review.",
679+ access: "read",
680+ status: 201,
681+ body: { body: ["string", "Markdown body.", true] },
682+ },
683+ "GET /repos/{owner}/{repo}/pulls/comments": {
684+ summary: "List review comments in a repository",
685+ access: "read",
686+ query: {
687+ since: ["string", "Only comments updated at or after this ISO timestamp."],
688+ sort: ["string", "`created` (default) or `updated`."],
689+ direction: ["string", "`asc` (default) or `desc`."],
690+ ...page,
691+ },
692+ },
693+ "GET /repos/{owner}/{repo}/pulls/comments/{id}": { summary: "Get a review comment", access: "read" },
694+ "PATCH /repos/{owner}/{repo}/pulls/comments/{id}": {
695+ summary: "Edit a review comment",
696+ description: "Your own comments, or anyone's with maintain access.",
697+ access: "read",
698+ body: { body: ["string", "New Markdown body.", true] },
699+ },
700+ "DELETE /repos/{owner}/{repo}/pulls/comments/{id}": { summary: "Delete a review comment", description: "Your own comments, or anyone's with maintain access.", access: "read", status: 204 },
701+ ...reactionDocs("/repos/{owner}/{repo}/pulls/comments/{id}", "a review comment"),
702+ "GET /repos/{owner}/{repo}/pulls/{number}/requested_reviewers": { summary: "List requested reviewers", description: "Returns `{ users, teams }`; `teams` is always empty.", access: "read" },
703+ "POST /repos/{owner}/{repo}/pulls/{number}/requested_reviewers": {
704+ summary: "Request reviewers",
705+ description: "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.",
706+ access: "read",
707+ status: 201,
708+ body: { reviewers: ["string[]", "Logins."], team_reviewers: ["string[]", "Must be empty or absent."] },
709+ },
710+ "DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers": {
711+ summary: "Remove requested reviewers",
712+ description: "The author, or anyone with triage access. Teams aren't supported. Returns the pull request.",
713+ access: "read",
714+ body: { reviewers: ["string[]", "Logins to remove."] },
715+ },
716+
717+ // ------------------------------------------------------------ commit statuses
718+ "POST /repos/{owner}/{repo}/statuses/{sha}": {
719+ summary: "Create a commit status",
720+ description:
721+ "`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](/docs/collaboration/checks-and-statuses).",
722+ access: "write",
723+ status: 201,
724+ body: {
725+ state: ["string", "`error`, `failure`, `pending` or `success`.", true],
726+ target_url: ["string", "http(s) link to details."],
727+ description: ["string", "Short description (cut at 1024 characters)."],
728+ context: ["string", "Label that tells statuses apart (default `default`)."],
729+ },
730+ },
731+ "GET /repos/{owner}/{repo}/commits/{ref}/statuses": { summary: "List commit statuses", description: "Every status for the commit, newest first, including older ones for the same context.", access: "read", query: { ...page } },
732+ "GET /repos/{owner}/{repo}/statuses/{ref}": { summary: "List commit statuses for a ref", description: "Same as `GET /repos/{owner}/{repo}/commits/{ref}/statuses`.", access: "read", query: { ...page } },
733+ "GET /repos/{owner}/{repo}/commits/{ref}/status": {
734+ summary: "Get the combined status",
735+ description: "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.",
736+ access: "read",
737+ query: { per_page: ["integer", "Statuses per page (default 100, at most 100)."], page: page.page! },
738+ },
739+
740+ // ------------------------------------------------------------ checks
741+ "GET /repos/{owner}/{repo}/commits/{ref}/check-runs": { summary: "List check runs for a commit", access: "read" },
742+ "GET /repos/{owner}/{repo}/check-runs/{id}": { summary: "Get a check run", description: "`id` is the numeric id or Diffinery's own.", access: "read" },
743+ "PATCH /repos/{owner}/{repo}/check-runs/{id}": {
744+ summary: "Update a check run",
745+ description:
746+ "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).",
747+ access: "write",
748+ body: {
749+ name: ["string", "New name."],
750+ status: ["string", "`queued`, `in_progress`, `completed`, `waiting`, `requested` or `pending`."],
751+ conclusion: ["string", "`action_required`, `cancelled`, `failure`, `neutral`, `success`, `skipped`, `stale` or `timed_out`."],
752+ details_url: ["string", "http(s) link to more detail."],
753+ external_id: ["string", "Your own reference for the run."],
754+ started_at: ["string", "ISO timestamp."],
755+ completed_at: ["string", "ISO timestamp."],
756+ output: ["object", "`{ title, summary, text, annotations }`."],
757+ },
758+ },
759+ "GET /repos/{owner}/{repo}/check-runs/{id}/annotations": { summary: "List check run annotations", access: "read", query: { ...page } },
760+ "GET /repos/{owner}/{repo}/commits/{ref}/check-suites": {
761+ summary: "List check suites for a commit",
762+ description: "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.",
763+ access: "read",
764+ query: { ...page },
765+ },
766+ "GET /repos/{owner}/{repo}/check-suites/{id}": { summary: "Get a check suite", description: "`id` is a workflow run id or `app-{app}-{sha}`.", access: "read" },
767+ "GET /repos/{owner}/{repo}/check-suites/{id}/check-runs": { summary: "List a check suite's check runs", access: "read", query: { ...page } },
768+ "POST /repos/{owner}/{repo}/check-runs": {
769+ summary: "Create or update a check run",
770+ description: "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.",
771+ access: "write",
772+ status: 201,
773+ body: {
774+ name: ["string", "Check name.", true],
775+ head_sha: ["string", "Commit the check is for.", true],
776+ status: ["string", "`queued` (default), `in_progress` or `completed`."],
777+ conclusion: ["string", "With `completed`: `success`, `failure`, `neutral`, `cancelled`, `skipped` or `timed_out`."],
778+ details_url: ["string", "Link to more detail."],
779+ output: ["object", "`{ title, summary }`."],
780+ },
781+ },
782+
783+ // ------------------------------------------------------------ deployments
784+ "GET /repos/{owner}/{repo}/deployments": {
785+ summary: "List deployments",
786+ description: "Newest first. Each filter matches exactly.",
787+ access: "read",
788+ query: {
789+ sha: ["string", "Full commit SHA."],
790+ ref: ["string", "Ref, as given when the deployment was created."],
791+ task: ["string", "Task name."],
792+ environment: ["string", "Environment name."],
793+ ...page,
794+ },
795+ },
796+ "POST /repos/{owner}/{repo}/deployments": {
797+ summary: "Create a deployment",
798+ description:
799+ "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. `auto_merge` is ignored. See [Environments](/docs/actions/environments).",
800+ access: "write",
801+ status: 201,
802+ body: {
803+ ref: ["string", "Branch, tag or commit to deploy.", true],
804+ task: ["string", "Task name (default `deploy`)."],
805+ payload: ["object", "Extra data for the deployer (a string is kept as is)."],
806+ environment: ["string", "Environment name (default `production`)."],
807+ description: ["string", "Short description."],
808+ transient_environment: ["boolean", "Transient deployments aren't made inactive by later successful ones."],
809+ production_environment: ["boolean", "Default: true when the environment is `production`."],
810+ required_contexts: ["string[]", "Contexts that must have passed; `[]` skips the check."],
811+ },
812+ },
813+ "GET /repos/{owner}/{repo}/deployments/{id}": { summary: "Get a deployment", access: "read" },
814+ "DELETE /repos/{owner}/{repo}/deployments/{id}": {
815+ summary: "Delete a deployment",
816+ description: "Refused with 422 while its latest status is `success`, unless it is the only deployment in its environment. Deletes its statuses too.",
817+ access: "write",
818+ status: 204,
819+ },
820+ "GET /repos/{owner}/{repo}/deployments/{id}/statuses": { summary: "List deployment statuses", description: "Newest first.", access: "read", query: { ...page } },
821+ "POST /repos/{owner}/{repo}/deployments/{id}/statuses": {
822+ summary: "Create a deployment status",
823+ description:
824+ "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.",
825+ access: "write",
826+ status: 201,
827+ body: {
828+ state: ["string", "`error`, `failure`, `inactive`, `in_progress`, `queued`, `pending` or `success`.", true],
829+ log_url: ["string", "http(s) link to the deployment's output."],
830+ target_url: ["string", "Older name for `log_url`, used when `log_url` is missing."],
831+ environment_url: ["string", "http(s) link to the deployed environment."],
832+ description: ["string", "Short description (cut at 140 characters)."],
833+ environment: ["string", "Move the deployment to this environment."],
834+ auto_inactive: ["boolean", "`false` keeps older deployments active (default `true`)."],
835+ },
836+ },
837+ "GET /repos/{owner}/{repo}/deployments/{id}/statuses/{status}": { summary: "Get a deployment status", access: "read" },
838+
839+ // ------------------------------------------------------------ environments
840+ "GET /repos/{owner}/{repo}/environments": { summary: "List environments", access: "read", query: { ...page } },
841+ "GET /repos/{owner}/{repo}/environments/{env}": { summary: "Get an environment", access: "read" },
842+ "PUT /repos/{owner}/{repo}/environments/{env}": {
843+ summary: "Create or update an environment",
844+ description:
845+ "People only; agents can't change environments. Required reviewers and wait timers aren't supported: a non-zero `wait_timer` or any `reviewers` fails with 422, and `prevent_self_review` is ignored. Audited. See [Environments](/docs/actions/environments).",
846+ access: "admin",
847+ body: {
848+ deployment_branch_policy: ["object", "`{ protected_branches, custom_branch_policies }` with exactly one true, or `null` to allow every ref."],
849+ wait_timer: ["integer", "Must be 0 or absent."],
850+ reviewers: ["array", "Must be empty or absent."],
851+ },
852+ },
853+ "DELETE /repos/{owner}/{repo}/environments/{env}": { summary: "Delete an environment", description: "Deletes its secrets and variables too. People only. Audited.", access: "admin", status: 204 },
854+ "GET /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies": {
855+ summary: "List deployment branch policies",
856+ description: "The branch and tag patterns of an environment with `custom_branch_policies`.",
857+ access: "read",
858+ query: { ...page },
859+ },
860+ "POST /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies": {
861+ summary: "Create a deployment branch policy",
862+ description: "The environment must have `custom_branch_policies`. Tag patterns never match tags last pushed by an agent. People only. Audited.",
863+ access: "admin",
864+ body: { name: ["string", "Branch or tag name pattern, like `release/*`.", true], type: ["string", "`branch` (default) or `tag`."] },
865+ },
866+ "DELETE /repos/{owner}/{repo}/environments/{env}/deployment-branch-policies/{id}": {
867+ summary: "Delete a deployment branch policy",
868+ description: "People only. Audited.",
869+ access: "admin",
870+ status: 204,
871+ },
872+ "GET /repos/{owner}/{repo}/environments/{env}/secrets": {
873+ summary: "List environment secrets",
874+ description: "Names and dates only; values are never returned.",
875+ access: "admin",
876+ query: { ...page },
877+ },
878+ "GET /repos/{owner}/{repo}/environments/{env}/secrets/{name}": { summary: "Get an environment secret", description: "Name and dates only.", access: "admin" },
879+ "PUT /repos/{owner}/{repo}/environments/{env}/secrets/{name}": {
880+ summary: "Set an environment secret",
881+ description:
882+ "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.",
883+ access: "admin",
884+ status: 201,
885+ body: { value: ["string", "Secret value.", true] },
886+ },
887+ "DELETE /repos/{owner}/{repo}/environments/{env}/secrets/{name}": { summary: "Delete an environment secret", description: "People only. Audited.", access: "admin", status: 204 },
888+ "GET /repos/{owner}/{repo}/environments/{env}/variables": {
889+ summary: "List environment variables",
890+ access: "write",
891+ query: { per_page: ["integer", "Results per page (default 10, at most 100)."], page: page.page! },
892+ },
893+ "GET /repos/{owner}/{repo}/environments/{env}/variables/{name}": { summary: "Get an environment variable", access: "write" },
894+ "POST /repos/{owner}/{repo}/environments/{env}/variables": {
895+ summary: "Create an environment variable",
896+ description: "409 when it exists. Names are upper-cased, and `GITHUB_` names are refused. People only.",
897+ access: "admin",
898+ status: 201,
899+ body: { name: ["string", "Variable name.", true], value: ["string", "Variable value.", true] },
900+ },
901+ "PATCH /repos/{owner}/{repo}/environments/{env}/variables/{name}": {
902+ summary: "Update an environment variable",
903+ description: "People only.",
904+ access: "admin",
905+ status: 204,
906+ body: { name: ["string", "New name (renames the variable)."], value: ["string", "New value."] },
907+ },
908+ "DELETE /repos/{owner}/{repo}/environments/{env}/variables/{name}": { summary: "Delete an environment variable", description: "People only.", access: "admin", status: 204 },
909+
910+ // ------------------------------------------------------------ actions
911+ "GET /repos/{owner}/{repo}/actions/workflows": { summary: "List workflows", description: "Each workflow file with its triggers, and parse errors for invalid ones (`state: invalid`).", access: "read" },
912+ "GET /repos/{owner}/{repo}/actions/workflows/{workflow}": {
913+ summary: "Get a workflow",
914+ description: "`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.",
915+ access: "read",
916+ },
917+ "GET /repos/{owner}/{repo}/actions/workflows/{workflow}/runs": {
918+ summary: "List a workflow's runs",
919+ description: "No `Link` header; page with `total_count`.",
920+ access: "read",
921+ query: {
922+ branch: ["string", "Head branch."],
923+ status: ["string", "Run status or conclusion."],
924+ event: ["string", "Triggering event."],
925+ head_sha: ["string", "Head commit."],
926+ ...page,
927+ },
928+ },
929+ "POST /repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches": {
930+ summary: "Run a workflow (workflow_dispatch)",
931+ description: "`workflow` is the file path or name, URL-encoded, like `.github%2Fworkflows%2Fdeploy.yml` or `deploy.yml`. Returns the new run's id.",
932+ access: "write",
933+ status: 201,
934+ body: { ref: ["string", "Branch or tag to run on.", true], inputs: ["object", "Values for `on.workflow_dispatch.inputs`."] },
935+ },
936+ "POST /repos/{owner}/{repo}/actions/runners/claim": {
937+ summary: "Claim a job for a self-hosted runner",
938+ description: "Used by `diffinery runner`. Returns 204 when no queued job matches the labels.",
939+ access: "admin",
940+ body: { name: ["string", "Runner name."], labels: ["string[]", "Runner labels, matched against `runs-on`."] },
941+ },
942+ "GET /repos/{owner}/{repo}/actions/runs": {
943+ summary: "List workflow runs",
944+ access: "read",
945+ query: {
946+ workflow: ["string", "Workflow file path."],
947+ branch: ["string", "Head branch."],
948+ status: ["string", "Run status or conclusion."],
949+ event: ["string", "Triggering event."],
950+ head_sha: ["string", "Head commit."],
951+ per_page: ["integer", "Results per page (default 25, at most 100)."],
952+ page: ["integer", "Page number, starting at 1."],
953+ },
954+ },
955+ "GET /repos/{owner}/{repo}/actions/runs/{id}": { summary: "Get a workflow run", description: "The run with its jobs.", access: "read" },
956+ "GET /repos/{owner}/{repo}/actions/runs/{id}/attempts/{attempt}": {
957+ summary: "Get a run attempt",
958+ description: "Re-runs are stored as separate runs with the same run number; this returns attempt `attempt` of the run's number.",
959+ access: "read",
960+ },
961+ "GET /repos/{owner}/{repo}/actions/runs/{id}/logs": {
962+ summary: "Download a run's logs",
963+ description: "A zip with one text file per job. Jobs past 24 MB of logs in total are left out.",
964+ access: "read",
965+ },
966+ "GET /repos/{owner}/{repo}/actions/runs/{id}/jobs": { summary: "List a run's jobs", access: "read" },
967+ "POST /repos/{owner}/{repo}/actions/runs/{id}/cancel": { summary: "Cancel a run", access: "write", status: 202 },
968+ "POST /repos/{owner}/{repo}/actions/runs/{id}/rerun": { summary: "Re-run a workflow run", access: "write", status: 201 },
969+ "POST /repos/{owner}/{repo}/actions/runs/{id}/rerun-failed-jobs": { summary: "Re-run a run's failed jobs", access: "write", status: 201 },
970+ "GET /repos/{owner}/{repo}/actions/jobs/{id}": { summary: "Get a job", description: "The job with its steps.", access: "read" },
971+ "GET /repos/{owner}/{repo}/actions/jobs/{id}/logs": {
972+ summary: "Get a job's logs",
973+ description: "Plain text, one timestamped line each, or JSON with `format=json`.",
974+ access: "read",
975+ query: { format: ["string", "`json` for an array of `{ t, text }` lines."], after: ["integer", "Only lines after this sequence number (for following a live log)."] },
976+ },
977+ "GET /repos/{owner}/{repo}/actions/runs/{id}/artifacts": { summary: "List a run's artifacts", description: "Each with an `archive_download_url` (a `.tar.gz`).", access: "read" },
978+ "GET /repos/{owner}/{repo}/actions/secrets": { summary: "List repository secrets", description: "Names and update times only; values are never returned.", access: "admin" },
979+ "PUT /repos/{owner}/{repo}/actions/secrets/{name}": {
980+ summary: "Set a repository secret",
981+ description: "People only. The value is encrypted at rest. GitHub's sealed-box `encrypted_value` isn't supported; send `value` over HTTPS.",
982+ access: "admin",
983+ status: 204,
984+ body: { value: ["string", "Secret value.", true] },
985+ },
986+ "DELETE /repos/{owner}/{repo}/actions/secrets/{name}": { summary: "Delete a repository secret", description: "People only.", access: "admin", status: 204 },
987+ "GET /repos/{owner}/{repo}/actions/variables": { summary: "List repository variables", access: "write", query: { ...page } },
988+ "POST /repos/{owner}/{repo}/actions/variables": {
989+ summary: "Create a repository variable",
990+ description: "409 when it exists. Names are stored upper-cased. People only.",
991+ access: "admin",
992+ status: 201,
993+ body: { name: ["string", "Variable name.", true], value: ["string", "Variable value.", true] },
994+ },
995+ "GET /repos/{owner}/{repo}/actions/variables/{name}": { summary: "Get a repository variable", description: "`created_at` and `updated_at` are always null.", access: "write" },
996+ "PATCH /repos/{owner}/{repo}/actions/variables/{name}": {
997+ summary: "Update a repository variable",
998+ description: "People only.",
999+ access: "admin",
1000+ status: 204,
1001+ body: { name: ["string", "New name (renames the variable)."], value: ["string", "New value."] },
1002+ },
1003+ "PUT /repos/{owner}/{repo}/actions/variables/{name}": {
1004+ summary: "Set a repository variable",
1005+ description: "Diffinery's form: creates the variable or replaces its value. GitHub clients use `POST` and `PATCH`. Names are stored upper-cased. People only.",
1006+ access: "admin",
1007+ status: 204,
1008+ body: { value: ["string", "Variable value.", true] },
1009+ },
1010+ "DELETE /repos/{owner}/{repo}/actions/variables/{name}": {
1011+ summary: "Delete a repository variable",
1012+ description: "People only. `name` must match the stored name exactly, and names are stored upper-cased, so send it in upper case.",
1013+ access: "admin",
1014+ status: 204,
1015+ },
1016+ "GET /orgs/{org}/actions/secrets": { summary: "List organization secrets", description: "Names and update times only.", access: "org-admin" },
1017+ "PUT /orgs/{org}/actions/secrets/{name}": {
1018+ summary: "Set an organization secret",
1019+ description: "Available to every repository in the organization. Names are upper-cased and must match `[A-Z_][A-Z0-9_]*`. People only.",
1020+ access: "org-admin",
1021+ status: 204,
1022+ body: { value: ["string", "Secret value.", true] },
1023+ },
1024+ "DELETE /orgs/{org}/actions/secrets/{name}": { summary: "Delete an organization secret", access: "org-admin", status: 204 },
1025+
1026+ // ------------------------------------------------------------ webhooks
1027+ "GET /repos/{owner}/{repo}/hooks": { summary: "List webhooks", description: "Secrets are never returned.", access: "admin" },
1028+ "POST /repos/{owner}/{repo}/hooks": {
1029+ summary: "Create a webhook",
1030+ access: "admin",
1031+ status: 201,
1032+ body: { config: ["object", "`{ url, secret }`. The secret signs each delivery (`X-Hub-Signature-256`).", true], events: ["string[]", "Event names, or `[\"*\"]` (default) for all."] },
1033+ },
1034+ "DELETE /repos/{owner}/{repo}/hooks/{id}": { summary: "Delete a webhook", access: "admin", status: 204 },
1035+
1036+ // ------------------------------------------------------------ notifications and email
1037+ "GET /notifications": { summary: "List notifications", access: "account", query: { all: ["boolean", "`true` to include read ones."] } },
1038+ "PUT /notifications": { summary: "Mark all notifications as read", access: "account", status: 205 },
1039+ "GET /repos/{owner}/{repo}/subscription": { summary: "Get your watch setting", description: "404 when you only get participating notifications (the default), as on GitHub.", access: "read" },
1040+ "PUT /repos/{owner}/{repo}/subscription": {
1041+ summary: "Watch or ignore a repository",
1042+ access: "read",
1043+ body: { subscribed: ["boolean", "`true` for all activity, `false` for participating only."], ignored: ["boolean", "`true` to ignore the repository."] },
1044+ },
1045+ "DELETE /repos/{owner}/{repo}/subscription": { summary: "Stop watching a repository", description: "Back to participating notifications.", access: "read", status: 204 },
1046+ "GET /diffinery/notification-settings": { summary: "Get your notification settings", access: "account" },
1047+ "PUT /diffinery/notification-settings": { summary: "Update your notification settings", description: "Send only the groups you want to change.", access: "account" },
1048+ "GET /diffinery/emails": {
1049+ summary: "List emails sent to you",
1050+ access: "account",
1051+ query: { kind: ["string", "Only this kind of email."], since: ["string", "ISO timestamp."], per_page: ["integer", "At most this many (default 50)."], all: ["string", "`1` for everyone's (site admins)."] },
1052+ },
1053+ "POST /diffinery/emails/test": { summary: "Send yourself a test email", access: "account" },
1054+ "GET /diffinery/admin/email": { summary: "Email delivery status", description: "Whether sending is configured, and counts for the last 24 hours.", access: "site-admin" },
1055+ "POST /diffinery/admin/emails/samples": { summary: "Send yourself every email template", access: "site-admin", body: { only: ["string[]", "Only these sample ids."] } },
1056+
1057+ // ------------------------------------------------------------ tokens, agents and sandboxes
1058+ "GET /diffinery/tokens": { summary: "List your tokens", access: "account" },
1059+ "POST /diffinery/tokens": {
1060+ summary: "Create a token",
1061+ description: "From a browser session or an admin-scoped token. The token value is returned once. Unknown scopes or an empty list fail with 422.",
1062+ access: "account",
1063+ status: 201,
1064+ body: {
1065+ name: ["string", "A name to recognise it by.", true],
1066+ scopes: ["string[]", "`read`, `write` (default) and/or `admin`."],
1067+ repositories: ["string[]", "Restrict to these `owner/name` repositories."],
1068+ expires_in_days: ["integer", "Lifetime in days."],
1069+ },
1070+ },
1071+ "DELETE /diffinery/tokens/{id}": { summary: "Revoke a token", access: "account", status: 204 },
1072+ "GET /diffinery/agents": { summary: "List your agents", description: "Agents you own, and agents of organizations you administer.", access: "account" },
1073+ "POST /diffinery/agents": {
1074+ summary: "Create an agent",
1075+ description: "People only. Returns the agent and its first token (write scope, 90 days, unless you say otherwise). Unknown scopes or an empty list fail with 422.",
1076+ access: "account",
1077+ status: 201,
1078+ body: {
1079+ login: ["string", "Agent login.", true],
1080+ name: ["string", "Display name."],
1081+ org: ["string", "Organization that owns the agent (default: you)."],
1082+ policy: ["object", "Agent policy."],
1083+ token: ["object", "`{ scopes, expires_in_days }` for the first token."],
1084+ },
1085+ },
1086+ "POST /diffinery/agents/{login}/tokens": {
1087+ summary: "Create a token for an agent",
1088+ description: "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`.",
1089+ access: "account",
1090+ status: 201,
1091+ body: { name: ["string", "Token name."], scopes: ["string[]", "`read`, `write` (default) and/or `admin`."], expires_in_days: ["integer", "Lifetime in days."] },
1092+ },
1093+ "DELETE /diffinery/agents/{login}": { summary: "Disable an agent", description: "Its tokens stop working.", access: "account", status: 204 },
1094+ "POST /repos/{owner}/{repo}/diffinery/sandboxes": {
1095+ summary: "Start a sandbox",
1096+ description: "A container with the repository checked out, for agents to build and test in.",
1097+ access: "write",
1098+ status: 201,
1099+ body: { ref: ["string", "Branch, tag or commit to check out (default: the default branch)."] },
1100+ },
1101+ "POST /repos/{owner}/{repo}/diffinery/sandboxes/{id}/{action}": {
1102+ summary: "Use a sandbox",
1103+ description: "`action` is `exec` (run a command), `read` or `write` (files), or `close`.",
1104+ access: "write",
1105+ },
1106+
1107+ // ------------------------------------------------------------ packages
1108+ "GET /repos/{owner}/{repo}/packages": { summary: "List a repository's packages", description: "Container images linked to the repository.", access: "read" },
1109+ ...packageDocs("/user/packages", "your"),
1110+ ...packageDocs("/users/{owner}/packages", "a user's"),
1111+ ...packageDocs("/orgs/{owner}/packages", "an organization's"),
1112+};
1113+
1114+function packageDocs(prefix: string, whose: string): Record<string, OpDoc> {
1115+ return {
1116+ [`GET ${prefix}`]: { summary: `List ${whose} packages`, description: "Only packages you can pull.", access: "account" },
1117+ [`GET ${prefix}/{type}/{name}`]: { summary: `Get one of ${whose} packages`, description: "`type` is `container`.", access: "read" },
1118+ [`PATCH ${prefix}/{type}/{name}`]: {
1119+ summary: `Update one of ${whose} packages`,
1120+ description: "Change its visibility or the repository it is linked to.",
1121+ access: "admin",
1122+ body: { visibility: ["string", "`private` or `public`."], repository: ["string", "Repository name to link, or `null` to unlink."] },
1123+ },
1124+ [`DELETE ${prefix}/{type}/{name}`]: { summary: `Delete one of ${whose} packages`, access: "admin", status: 204 },
1125+ [`GET ${prefix}/{type}/{name}/versions`]: { summary: "List package versions", access: "read", query: { state: ["string", "`tagged` for tagged versions only."], ...page } },
1126+ [`GET ${prefix}/{type}/{name}/versions/{id}`]: { summary: "Get a package version", access: "read" },
1127+ [`DELETE ${prefix}/{type}/{name}/versions/{id}`]: { summary: "Delete a package version", access: "admin", status: 204 },
1128+ };
1129+}
1130+
1131+/** GitHub's tarball and zipball endpoints, with and without a ref. */
1132+function archiveDocs(kind: "tarball" | "zipball", what: string, format: string): Record<string, OpDoc> {
1133+ const description = `A ${format} 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).`;
1134+ return {
1135+ [`GET /repos/{owner}/{repo}/${kind}`]: { summary: `Download ${what}`, description: `${description} Uses the default branch.`, access: "read" },
1136+ [`GET /repos/{owner}/{repo}/${kind}/{ref}`]: { summary: `Download ${what} of a ref`, description, access: "read" },
1137+ };
1138+}
1139+
1140+/** Reactions on one kind of subject. `note` adds a sentence to the list endpoint. */
1141+function reactionDocs(prefix: string, what: string, note?: string): Record<string, OpDoc> {
1142+ return {
1143+ [`GET ${prefix}/reactions`]: {
1144+ summary: `List reactions to ${what}`,
1145+ ...(note ? { description: note } : {}),
1146+ access: "read",
1147+ query: { content: ["string", "Only reactions with this content."], ...page },
1148+ },
1149+ [`POST ${prefix}/reactions`]: {
1150+ summary: `Add a reaction to ${what}`,
1151+ description: `Needs a signed-in account${what === "a release" ? "" : ", 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.`,
1152+ access: "read",
1153+ status: 201,
1154+ body: { content: ["string", "`+1`, `-1`, `laugh`, `confused`, `heart`, `hooray`, `rocket` or `eyes`.", true] },
1155+ },
1156+ [`DELETE ${prefix}/reactions/{reaction}`]: { summary: `Delete a reaction from ${what}`, description: "Your own reactions, or anyone's with admin access.", access: "read", status: 204 },
1157+ };
1158+}
addedapps/diffinery/src/docs/pages.ts+199−0View file
@@ -1,0 +1,199 @@
1+/**
2+ * The public documentation as data: the Markdown pages built from docs/ (see
3+ * scripts/build-docs.mjs) plus the reference pages generated at runtime, and
4+ * the agent-facing formats built from them (per-page Markdown, llms.txt,
5+ * llms-full.txt and a search index).
6+ */
7+import { slugify } from "../web/markdown.js";
8+import { SITE } from "../web/seo.js";
9+import { DOCS_NAV, DOCS_PAGES } from "./content.generated.js";
10+import { apiReferenceMarkdown, mcpToolsMarkdown } from "./reference.js";
11+
12+export interface Page {
13+ slug: string;
14+ title: string;
15+ description: string;
16+ markdown: string;
17+ section: string;
18+}
19+
20+const RUNTIME: Record<string, { title: string; description: string; build: (origin: string) => string }> = {
21+ api: {
22+ title: "REST API reference",
23+ description: "Every REST API endpoint with its parameters, access rules and an example, generated from the server's router.",
24+ build: apiReferenceMarkdown,
25+ },
26+ "reference/mcp-tools": {
27+ title: "MCP tool reference",
28+ description: "Every tool of the Diffinery MCP server with its arguments, generated from the server's tool list.",
29+ build: mcpToolsMarkdown,
30+ },
31+};
32+
33+const SECTION_OF = new Map<string, string>(DOCS_NAV.flatMap((s) => s.pages.map((p) => [p, s.title] as [string, string])));
34+
35+/** Every page slug, in reading order. */
36+export const SLUGS: string[] = DOCS_NAV.flatMap((s) => s.pages);
37+
38+const runtimeCache = new Map<string, string>();
39+
40+export function getPage(slug: string, origin: string): Page | null {
41+ const section = SECTION_OF.get(slug);
42+ if (!section) return null;
43+ const rt = RUNTIME[slug];
44+ if (rt) {
45+ const key = `${origin} ${slug}`;
46+ let md = runtimeCache.get(key);
47+ if (md === undefined) runtimeCache.set(key, (md = rt.build(origin)));
48+ return { slug, title: rt.title, description: rt.description, markdown: md, section };
49+ }
50+ const p = DOCS_PAGES[slug];
51+ return p ? { slug, ...p, section } : null;
52+}
53+
54+export function allPages(origin: string): Page[] {
55+ return SLUGS.map((s) => getPage(s, origin)).filter((p): p is Page => !!p);
56+}
57+
58+export function nav(): Array<{ title: string; pages: Array<{ slug: string; title: string }> }> {
59+ return DOCS_NAV.map((s) => ({ title: s.title, pages: s.pages.map((slug) => ({ slug, title: RUNTIME[slug]?.title ?? DOCS_PAGES[slug]?.title ?? slug })) }));
60+}
61+
62+export const pageUrl = (slug: string) => (slug === "index" ? "/docs" : `/docs/${slug}`);
63+export const markdownUrl = (slug: string) => `/docs/${slug}.md`;
64+
65+/** Strip inline Markdown from heading text for display. */
66+export const plainHeading = (s: string) =>
67+ s
68+ .replace(/`([^`]*)`/g, "$1")
69+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
70+ .replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, "$1")
71+ .trim();
72+
73+/** The page's `##` and `###` headings, with the ids the renderer gives them. */
74+export function headings(markdown: string): Array<{ level: 2 | 3; id: string; text: string }> {
75+ const out: Array<{ level: 2 | 3; id: string; text: string }> = [];
76+ let fence: string | null = null;
77+ for (const line of markdown.split("\n")) {
78+ const f = /^ {0,3}(`{3,}|~{3,})/.exec(line);
79+ if (f) {
80+ if (!fence) fence = f[1]![0]!;
81+ else if (f[1]![0] === fence) fence = null;
82+ continue;
83+ }
84+ if (fence) continue;
85+ const h = /^(##|###) +(.+?)\s*#*\s*$/.exec(line);
86+ if (h) out.push({ level: h[1]!.length as 2 | 3, id: slugify(h[2]!), text: plainHeading(h[2]!) });
87+ }
88+ return out;
89+}
90+
91+/** Root-relative links (`](/docs/...)`) made absolute, for Markdown read outside the site. */
92+const absolute = (md: string, origin: string) => md.replace(/\]\(\/(?!\/)/g, `](${origin}/`);
93+
94+/** A page as standalone Markdown: what /docs/<slug>.md serves. */
95+export function pageMarkdown(p: Page, origin: string): string {
96+ return `# ${p.title}\n\n> ${p.description}\n\nSource: ${origin}${pageUrl(p.slug)}\n\n${absolute(p.markdown, origin).trim()}\n`;
97+}
98+
99+export function llmsTxt(origin: string): string {
100+ const lines = [
101+ `# ${SITE.name}`,
102+ "",
103+ `> ${SITE.description}`,
104+ "",
105+ "Diffinery is a git forge with GitHub-compatible Actions, a GitHub-style REST API and an MCP server for AI agents. These docs are written for people and agents alike:",
106+ "",
107+ `- Every page is available as Markdown: add \`.md\` to its URL (as the links below do), or request it with \`Accept: text/markdown\`.`,
108+ `- All pages in one file: ${origin}/llms-full.txt`,
109+ `- The REST API as OpenAPI 3.1: ${origin}/docs/api/openapi.json`,
110+ `- MCP server for agents: ${origin}/mcp (send \`Authorization: Bearer <token>\`)`,
111+ `- Command-line client: ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`,
112+ "",
113+ ];
114+ for (const s of DOCS_NAV) {
115+ lines.push(`## ${s.title}`, "");
116+ for (const slug of s.pages) {
117+ const p = getPage(slug, origin);
118+ if (p) lines.push(`- [${p.title}](${origin}${markdownUrl(slug)}): ${p.description}`);
119+ }
120+ lines.push("");
121+ }
122+ return lines.join("\n");
123+}
124+
125+export function llmsFullTxt(origin: string): string {
126+ const head = `# ${SITE.name} documentation\n\n> ${SITE.description}\n\nThis file holds every page of the Diffinery docs (${origin}/docs) as Markdown, 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 ${origin}/docs/api/openapi.json.\n`;
127+ return [head, ...allPages(origin).map((p) => pageMarkdown(p, origin))].join("\n\n---\n\n");
128+}
129+
130+type SearchEntry = { s: string; t: string; d: string; c: string; h: string[][]; x: string };
131+const indexCache = new Map<string, SearchEntry[]>();
132+
133+/** A compact index for the docs search box: titles, headings and text. */
134+export function searchIndex(origin: string): SearchEntry[] {
135+ const hit = indexCache.get(origin);
136+ if (hit) return hit;
137+ const idx = allPages(origin).map((p) => ({
138+ s: p.slug,
139+ t: p.title,
140+ d: p.description,
141+ c: p.section,
142+ h: headings(p.markdown).map((h) => [h.id, h.text]),
143+ x: p.markdown
144+ .replace(/```[\s\S]*?```/g, " ")
145+ .replace(/[`*_#>|]/g, " ")
146+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
147+ .replace(/\s+/g, " ")
148+ .slice(0, 6000),
149+ }));
150+ indexCache.set(origin, idx);
151+ return idx;
152+}
153+
154+/** Rank pages for a query: title matches first, then headings, then text. Every term must match. */
155+export function searchDocs(origin: string, query: string, limit = 8): Array<{ page: string; title: string; section: string; heading: string | null; url: string; markdown_url: string; snippet: string }> {
156+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
157+ if (!terms.length) return [];
158+ const hits: Array<{ score: number; r: ReturnType<typeof searchDocs>[number] }> = [];
159+ for (const p of searchIndex(origin)) {
160+ const title = p.t.toLowerCase();
161+ const text = p.x.toLowerCase();
162+ let score = 0;
163+ let hit: string[] | null = null;
164+ for (const t of terms) {
165+ let s = 0;
166+ if (title.includes(t)) s += 10;
167+ for (const h of p.h) {
168+ if (h[1]!.toLowerCase().includes(t)) {
169+ s += 4;
170+ hit ??= h;
171+ }
172+ }
173+ if (text.includes(t)) s += 1;
174+ if (!s) {
175+ score = 0;
176+ break;
177+ }
178+ score += s;
179+ }
180+ if (!score) continue;
181+ const at = text.indexOf(terms[0]!);
182+ hits.push({
183+ score,
184+ r: {
185+ page: p.s,
186+ title: p.t,
187+ section: p.c,
188+ heading: hit?.[1] ?? null,
189+ url: `${origin}${pageUrl(p.s)}${hit ? `#${hit[0]}` : ""}`,
190+ markdown_url: `${origin}${markdownUrl(p.s)}`,
191+ snippet: at >= 0 ? p.x.slice(Math.max(0, at - 60), at + 160).trim() : p.d,
192+ },
193+ });
194+ }
195+ return hits
196+ .sort((a, b) => b.score - a.score)
197+ .slice(0, limit)
198+ .map((h) => h.r);
199+}
addedapps/diffinery/src/docs/reference.ts+421−0View file
@@ -1,0 +1,421 @@
1+/**
2+ * Reference material generated from the running code: the REST API (from the
3+ * router itself, plus the notes in ./api-meta.ts) and the MCP tools (from the
4+ * MCP server's tool list). Nothing here is written by hand per endpoint or per
5+ * tool, so the docs can't fall behind the code.
6+ */
7+import { app } from "../app.js";
8+import { mcpTools, PROTOCOL_VERSIONS } from "../mcp/server.js";
9+import { ACCOUNT_SCOPES, API_DOCS, type Access, type Field, type OpDoc, type Scope } from "./api-meta.js";
10+
11+export const API_BASE = "/api/v3";
12+
13+export interface ApiOperation {
14+ method: string;
15+ /** OpenAPI-style path relative to /api/v3, like /repos/{owner}/{repo}. */
16+ path: string;
17+ params: string[];
18+ tag: string;
19+ doc: OpDoc;
20+ /** False when the summary was derived from the method and path. */
21+ documented: boolean;
22+ /** The least token scope that may call it, and whether the token must cover every repository. */
23+ scope: Scope;
24+ accountWide: boolean;
25+}
26+
27+const METHOD_ORDER = ["GET", "POST", "PUT", "PATCH", "DELETE"];
28+
29+/** Hono path → OpenAPI path: `:name{regex}` and `:name` → `{name}`, a trailing `*` → `{path}`. */
30+export function openApiPath(honoPath: string): string {
31+ return honoPath.replace(/:(\w+)(\{[^}]*\})?/g, "{$1}").replace(/\/\*$/, "/{path}");
32+}
33+
34+/** Groups, in the order the reference lists them. The first matching rule wins. */
35+const TAGS: Array<[string, RegExp]> = [
36+ ["Meta", /^\/(meta|rate_limit)?$/],
37+ ["Users", /^\/users?(\/\{login\})?$|^\/user\/(orgs|avatar)$/],
38+ ["Organizations", /^\/orgs\/\{org\}\/(members|invitations|avatar)|^\/invitations$/],
39+ ["Search", /^\/search\//],
40+ ["Repositories", /^\/(user|users\/\{login\}|orgs\/\{org\})\/repos$|^\/repos\/\{owner\}\/\{repo\}$|\/(collaborators|assignees|languages|topics|dispatches)(\/|$)/],
41+ ["Contents", /\/(contents|raw|readme|tarball|zipball|diffinery\/commits)(\/|$)/],
42+ ["Releases", /\/releases(\/|$)/],
43+ ["Git database", /\/git\/(blobs|trees|commits|tags|refs|ref|matching-refs)(\/|$)/],
44+ ["Branches and tags", /\/(branches|tags|merges)(\/|$)(?!.*protection)/],
45+ ["Commit statuses", /\/statuses\/|\/commits\/\{ref\}\/status(es)?$/],
46+ ["Checks", /\/check-(runs|suites)/],
47+ ["Commits", /\/(commits|compare)(\/|$)/],
48+ ["Branch protection", /\/protection$/],
49+ ["Issues", /\/(issues|labels)(\/|$)/],
50+ ["Pull requests", /\/pulls(\/|$)(?!.*merge-queue)/],
51+ ["Merge queue", /\/merge-queue/],
52+ ["Deployments", /\/deployments(\/|$)/],
53+ ["Environments", /\/environments(\/|$)/],
54+ ["Actions secrets and variables", /\/actions\/(secrets|variables)/],
55+ ["Actions", /\/actions\//],
56+ ["Webhooks", /\/hooks(\/|$)/],
57+ ["Packages", /\/packages(\/|$)/],
58+ ["Notifications and email", /^\/notifications$|\/subscription$|\/diffinery\/(notification-settings|emails|admin)/],
59+ ["Tokens and agents", /\/diffinery\/(tokens|agents)/],
60+ ["Sandboxes", /\/diffinery\/sandboxes/],
61+];
62+export const TAG_ORDER = [...TAGS.map(([t]) => t), "Other"];
63+
64+function tagFor(path: string): string {
65+ for (const [tag, re] of TAGS) if (re.test(path)) return tag;
66+ return "Other";
67+}
68+
69+const words = (s: string) => s.replace(/[-_]/g, " ");
70+const singular = (s: string) => s.replace(/ies$/, "y").replace(/(ss)$/, "$1").replace(/s$/, "");
71+
72+/** A readable summary for an endpoint nobody has written notes for yet. */
73+export function derivedSummary(method: string, path: string): string {
74+ const segs = path.split("/").filter(Boolean);
75+ const last = segs[segs.length - 1] ?? "";
76+ const endsWithParam = last.startsWith("{");
77+ const noun = words([...segs].reverse().find((s) => !s.startsWith("{")) ?? "resource");
78+ const one = `a ${singular(noun)}`;
79+ switch (method) {
80+ case "GET":
81+ return endsWithParam ? `Get ${one}` : `List ${noun}`;
82+ case "POST":
83+ return endsWithParam ? `Run ${words(last.slice(1, -1))} on ${one}` : `Create ${one}`;
84+ case "PUT":
85+ return endsWithParam ? `Set ${one}` : `Set ${noun}`;
86+ case "PATCH":
87+ return `Update ${endsWithParam ? one : noun}`;
88+ case "DELETE":
89+ return `Delete ${endsWithParam ? one : noun}`;
90+ default:
91+ return `${method} ${path}`;
92+ }
93+}
94+
95+let opsCache: ApiOperation[] | null = null;
96+
97+/** Every REST endpoint the Worker serves under /api/v3. */
98+export function apiOperations(): ApiOperation[] {
99+ if (opsCache) return opsCache;
100+ const seen = new Set<string>();
101+ const ops: ApiOperation[] = [];
102+ for (const r of app.routes) {
103+ if (!METHOD_ORDER.includes(r.method) || !(r.path === API_BASE || r.path.startsWith(`${API_BASE}/`))) continue;
104+ const path = openApiPath(r.path.slice(API_BASE.length)) || "/";
105+ const key = `${r.method} ${path}`;
106+ if (seen.has(key)) continue;
107+ seen.add(key);
108+ const doc = API_DOCS[key];
109+ ops.push({
110+ method: r.method,
111+ path,
112+ params: [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]!),
113+ tag: tagFor(path),
114+ doc: doc ?? { summary: derivedSummary(r.method, path) },
115+ documented: !!doc,
116+ // A token's scope also caps its repository role (write → maintain), so admin endpoints need an admin token.
117+ scope: ACCOUNT_SCOPES[key]?.scope ?? (doc?.access === "admin" ? "admin" : r.method === "GET" ? "read" : "write"),
118+ accountWide: !!ACCOUNT_SCOPES[key] && !ACCOUNT_SCOPES[key]!.anyToken,
119+ });
120+ }
121+ ops.sort((a, b) => TAG_ORDER.indexOf(a.tag) - TAG_ORDER.indexOf(b.tag) || a.path.localeCompare(b.path) || METHOD_ORDER.indexOf(a.method) - METHOD_ORDER.indexOf(b.method));
122+ // Only cache once the web UI is mounted too (index.ts mounts it after app.ts loads).
123+ if (ops.length) opsCache = ops;
124+ return ops;
125+}
126+
127+export const ACCESS_TEXT: Record<Access, string> = {
128+ public: "No authentication needed.",
129+ account: "Any active account (a token, or a browser session).",
130+ read: "Read access to the repository. Public repositories allow anyone to read.",
131+ triage: "Triage access to the repository.",
132+ write: "Write access to the repository.",
133+ maintain: "Maintain access to the repository.",
134+ admin: "Admin access to the repository (or to the package).",
135+ "org-admin": "An owner or admin of the organization (or a site admin).",
136+ "site-admin": "A site administrator.",
137+};
138+
139+/** What a personal access token needs; browser sessions always have full scope. */
140+export function scopeText(op: Pick<ApiOperation, "scope" | "accountWide" | "doc">): string {
141+ if (op.doc.access === "public") return "";
142+ return `Tokens need the \`${op.scope}\` scope${op.accountWide ? " and access to all repositories" : ""}.`;
143+}
144+
145+const PARAM_TEXT: Record<string, string> = {
146+ owner: "Account (user or organization) that owns the repository or package.",
147+ repo: "Repository name.",
148+ org: "Organization login.",
149+ login: "Account login.",
150+ number: "Issue or pull request number.",
151+ branch: "Branch name (for protection rules: a branch name or pattern).",
152+ ref: "Branch, tag or commit SHA.",
153+ path: "Path inside the repository, or a ref name after `refs/`.",
154+ spec: "Two refs as `base...head`.",
155+ id: "Identifier.",
156+ name: "Name.",
157+ type: "Package type: `container`.",
158+ workflow: "Workflow file path (URL-encoded) or file name.",
159+ action: "Action to take.",
160+};
161+
162+const EXAMPLE: Record<string, string> = { owner: "acme", repo: "website", org: "acme", login: "octocat", number: "42", branch: "main", ref: "main", path: "README.md", spec: "main...feature", type: "container", name: "website", workflow: "ci.yml", action: "exec" };
163+
164+function curl(origin: string, op: ApiOperation): string {
165+ const url = `${origin}${API_BASE}${op.path === "/" ? "" : op.path.replace(/\{(\w+)\}/g, (_, p: string) => EXAMPLE[p] ?? (p === "id" ? "1" : p))}`;
166+ const lines = [`curl${op.method === "GET" ? "" : ` -X ${op.method}`} "${url}"`];
167+ if (op.doc.access !== "public") lines.push(` -H "Authorization: Bearer $DIFFINERY_TOKEN"`);
168+ const required = Object.entries(op.doc.body ?? {}).filter(([, f]) => f[2]);
169+ if (required.length) {
170+ const sample = Object.fromEntries(required.map(([k, f]) => [k, f[0] === "string[]" ? ["..."] : f[0] === "array" ? [] : f[0] === "object" ? {} : f[0] === "integer" ? 1 : f[0] === "boolean" ? true : "..."]));
171+ lines.push(` -H "Content-Type: application/json"`, ` -d '${JSON.stringify(sample)}'`);
172+ }
173+ return lines.join(" \\\n");
174+}
175+
176+const cell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
177+
178+function fieldTable(title: string, fields: Record<string, Field>): string[] {
179+ return [`${title}:`, "", "| Name | Type | Required | Description |", "| --- | --- | --- | --- |", ...Object.entries(fields).map(([k, [t, d, req]]) => `| \`${k}\` | ${t} | ${req ? "yes" : "no"} | ${cell(d)} |`), ""];
180+}
181+
182+/** The /docs/api page: every endpoint, grouped, with parameters and an example. */
183+export function apiReferenceMarkdown(origin: string): string {
184+ const ops = apiOperations();
185+ const byTag = new Map<string, ApiOperation[]>();
186+ for (const op of ops) byTag.set(op.tag, [...(byTag.get(op.tag) ?? []), op]);
187+ const used = new Set<string>();
188+ const heading = (op: ApiOperation) => {
189+ let h = op.doc.summary;
190+ if (used.has(h.toLowerCase())) h = `${h} (${op.method} ${op.path.replace(/[{}]/g, "")})`;
191+ used.add(h.toLowerCase());
192+ return h;
193+ };
194+ const out: string[] = [
195+ `Every endpoint of the Diffinery REST API, generated from the server's router, so this list always matches what \`${origin}${API_BASE}\` serves. The same information is available as an [OpenAPI 3.1 document](/docs/api/openapi.json). For authentication, errors, pagination and GitHub compatibility, read the [REST API guide](/docs/reference/rest-api).`,
196+ "",
197+ "## Basics",
198+ "",
199+ `- Base URL: \`${origin}${API_BASE}\``,
200+ "- 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*.",
201+ "- Send and receive JSON. Errors are JSON objects with a `message` and a `documentation_url`, unknown paths included (404).",
202+ "- 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.",
203+ "- 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.",
204+ `- ${ops.length} endpoints in ${byTag.size} groups.`,
205+ "",
206+ "## Endpoints by group",
207+ "",
208+ ...[...byTag.entries()].map(([tag, list]) => `- [${tag}](#${slug(tag)}) (${list.length})`),
209+ "",
210+ ];
211+ for (const [tag, list] of byTag) {
212+ out.push(`## ${tag}`, "", "| Method | Path | Summary |", "| --- | --- | --- |");
213+ const heads = list.map((op) => heading(op));
214+ list.forEach((op, i) => out.push(`| ${op.method} | \`${op.path}\` | [${cell(op.doc.summary)}](#${slug(heads[i]!)}) |`));
215+ out.push("");
216+ list.forEach((op, i) => {
217+ out.push(`### ${heads[i]}`, "", "```http", `${op.method} ${API_BASE}${op.path === "/" ? "" : op.path}`, "```", "");
218+ if (op.doc.description) out.push(op.doc.description, "");
219+ out.push(`**Access:** ${[ACCESS_TEXT[op.doc.access ?? "read"], scopeText(op)].filter(Boolean).join(" ")}${op.doc.status ? ` **Success:** \`${op.doc.status}\`.` : ""}`, "");
220+ if (op.params.length) out.push(...fieldTable("Path parameters", Object.fromEntries(op.params.map((p) => [p, ["string", PARAM_TEXT[p] ?? p, true] as Field]))));
221+ if (op.doc.query) out.push(...fieldTable("Query parameters", op.doc.query));
222+ if (op.doc.body) out.push(...fieldTable("Body (JSON)", op.doc.body));
223+ out.push("```sh", curl(origin, op), "```", "");
224+ });
225+ }
226+ out.push(
227+ "## Other HTTP endpoints",
228+ "",
229+ "These aren't part of the JSON API, but clients and tools talk to them directly.",
230+ "",
231+ "| Endpoint | What it is |",
232+ "| --- | --- |",
233+ ...OTHER_ENDPOINTS.map(([path, what]) => `| \`${path}\` | ${what} |`),
234+ "",
235+ );
236+ return out.join("\n");
237+}
238+
239+/** Non-API endpoints worth knowing. A unit test checks that each one is still routed. */
240+export const OTHER_ENDPOINTS: Array<[string, string]> = [
241+ ["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](/docs/repositories/git)."],
242+ ["POST /{owner}/{repo}.git/git-upload-pack", "Git smart HTTP: fetch and clone."],
243+ ["POST /{owner}/{repo}.git/git-receive-pack", "Git smart HTTP: push."],
244+ ["POST /{owner}/{repo}.git/info/lfs/objects/batch", "Git LFS batch API. See [Git LFS](/docs/repositories/git-lfs)."],
245+ ["GET, PUT /{owner}/{repo}.git/info/lfs/objects/{oid}", "Git LFS object download and upload."],
246+ ["GET /{owner}/{repo}/actions/runs/{run_id}/artifacts/{name}", "Download a workflow artifact as `.tar.gz`."],
247+ ["POST /mcp", "The MCP server (Streamable HTTP, JSON-RPC). See [MCP](/docs/agents/mcp) and the [MCP tool reference](/docs/reference/mcp-tools)."],
248+ ["/v2/...", "The OCI container registry (Docker Registry HTTP API v2). See [Container registry](/docs/packages/container-registry)."],
249+ ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."],
250+ ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."],
251+ ["GET /healthz", "Health check: `{\"ok\": true}`."],
252+ ["GET /llms.txt", "Index of these docs for AI agents. See [Docs for AI agents](/docs/agents/docs-for-agents)."],
253+ ["GET /llms-full.txt", "All of these docs in one Markdown file."],
254+ ["GET /docs/{page}.md", "Any docs page as Markdown."],
255+ ["GET /docs/api/openapi.json", "This reference as an OpenAPI 3.1 document."],
256+];
257+
258+// Same rules as slugify() in src/web/markdown.ts.
259+function slug(text: string): string {
260+ return text
261+ .toLowerCase()
262+ .replace(/[^\p{L}\p{N}\s_-]/gu, "")
263+ .trim()
264+ .replace(/\s+/g, "-")
265+ .slice(0, 80);
266+}
267+
268+// ---------------------------------------------------------------- OpenAPI
269+
270+function schemaFor(type: string): Record<string, unknown> {
271+ if (type.endsWith("[]")) return { type: "array", items: schemaFor(type.slice(0, -2)) };
272+ if (["string", "integer", "boolean", "object", "array", "number"].includes(type)) return { type };
273+ return {};
274+}
275+
276+/** The REST API as an OpenAPI 3.1 document. */
277+export function openApiDocument(origin: string, version: string): Record<string, unknown> {
278+ const paths: Record<string, Record<string, unknown>> = {};
279+ for (const op of apiOperations()) {
280+ const params = [
281+ ...op.params.map((p) => ({ name: p, in: "path", required: true, description: PARAM_TEXT[p] ?? p, schema: { type: "string" } })),
282+ ...Object.entries(op.doc.query ?? {}).map(([name, [t, d, req]]) => ({ name, in: "query", required: !!req, description: d, schema: schemaFor(t) })),
283+ ];
284+ const body = op.doc.body
285+ ? {
286+ required: Object.values(op.doc.body).some((f) => f[2]),
287+ content: {
288+ "application/json": {
289+ schema: {
290+ type: "object",
291+ properties: Object.fromEntries(Object.entries(op.doc.body).map(([k, [t, d]]) => [k, { ...schemaFor(t), description: d }])),
292+ required: Object.entries(op.doc.body)
293+ .filter(([, f]) => f[2])
294+ .map(([k]) => k),
295+ },
296+ },
297+ },
298+ }
299+ : undefined;
300+ const ok = String(op.doc.status ?? 200);
301+ const access = op.doc.access ?? "read";
302+ (paths[op.path] ??= {})[op.method.toLowerCase()] = {
303+ operationId: operationId(op),
304+ summary: op.doc.summary,
305+ description: [op.doc.description, `Access: ${[ACCESS_TEXT[access], scopeText(op)].filter(Boolean).join(" ")}`].filter(Boolean).join("\n\n"),
306+ tags: [op.tag],
307+ ...(params.length ? { parameters: params } : {}),
308+ ...(body ? { requestBody: body } : {}),
309+ ...(access === "public" ? { security: [] } : {}),
310+ "x-diffinery-access": access,
311+ "x-diffinery-token-scope": op.scope,
312+ responses: {
313+ [ok]: { description: ok === "204" ? "No content" : "Success", ...(ok === "204" || ok === "205" ? {} : { content: { "application/json": { schema: {} } } }) },
314+ default: { description: "Error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
315+ },
316+ };
317+ }
318+ return {
319+ openapi: "3.1.0",
320+ info: {
321+ title: "Diffinery REST API",
322+ version,
323+ description: `The Diffinery REST API, generated from the server's router. Paths and payloads follow GitHub's REST API v3 where Diffinery has the same feature. Guide: ${origin}/docs/reference/rest-api`,
324+ },
325+ externalDocs: { description: "Diffinery docs", url: `${origin}/docs` },
326+ servers: [{ url: `${origin}${API_BASE}` }],
327+ security: [{ token: [] }],
328+ tags: TAG_ORDER.filter((t) => apiOperations().some((o) => o.tag === t)).map((name) => ({ name })),
329+ paths,
330+ components: {
331+ securitySchemes: {
332+ token: { type: "http", scheme: "bearer", description: "A Diffinery personal access token (`dfy_...`) from Settings → Access tokens. `Authorization: token dfy_...` and HTTP Basic (token as the password) also work." },
333+ },
334+ schemas: {
335+ Error: { type: "object", properties: { message: { type: "string" } }, required: ["message"] },
336+ },
337+ },
338+ };
339+}
340+
341+function operationId(op: ApiOperation): string {
342+ const parts = op.path
343+ .split("/")
344+ .filter(Boolean)
345+ .map((s) => (s.startsWith("{") ? `by-${s.slice(1, -1)}` : s));
346+ return [op.method.toLowerCase(), ...parts]
347+ .join("-")
348+ .replace(/[^a-zA-Z0-9]+(.)/g, (_, ch: string) => ch.toUpperCase())
349+ .replace(/[^a-zA-Z0-9]/g, "");
350+}
351+
352+// ---------------------------------------------------------------- MCP tools
353+
354+const MCP_GROUPS: Array<[string, RegExp]> = [
355+ ["Identity and repositories", /^(whoami|list_repos)$/],
356+ ["Code", /^(list_tree|get_file|search_code|commit_files)$/],
357+ ["Branches and commits", /branch|commit|compare/],
358+ ["Pull requests", /pull_request|review|merge/],
359+ ["Issues", /issue|comment|label/],
360+ ["Actions", /workflow|run|job|artifact/],
361+ ["Releases", /release/],
362+ ["Sandboxes", /sandbox/],
363+ ["Docs", /docs/],
364+];
365+
366+function mcpGroup(name: string): string {
367+ for (const [g, re] of MCP_GROUPS) if (re.test(name)) return g;
368+ return "Other";
369+}
370+
371+type JsonSchemaProp = { type?: string; description?: string; enum?: string[]; items?: unknown };
372+
373+/** The /docs/reference/mcp-tools page. */
374+export function mcpToolsMarkdown(origin: string): string {
375+ const tools = mcpTools();
376+ const groups = new Map<string, typeof tools>();
377+ for (const g of [...MCP_GROUPS.map(([n]) => n), "Other"]) groups.set(g, []);
378+ for (const t of tools) groups.get(mcpGroup(t.name))!.push(t);
379+ for (const [g, list] of groups) if (!list.length) groups.delete(g);
380+ const out: string[] = [
381+ `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](/docs/agents/mcp).`,
382+ "",
383+ "## Connection",
384+ "",
385+ `- Endpoint: \`${origin}/mcp\` (Streamable HTTP; responses are JSON, not event streams).`,
386+ "- Authentication: `Authorization: Bearer dfy_...`, a token for an agent or a person. Every call needs an active account.",
387+ `- Protocol versions: ${PROTOCOL_VERSIONS.map((v) => `\`${v}\``).join(", ")}. The server answers with the version the client asks for when it supports it, else the newest.`,
388+ "- Every tool runs through the same permission checks, branch protection and agent guardrails as git and the REST API.",
389+ "- 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`.",
390+ "",
391+ "## Tools",
392+ "",
393+ "| Tool | Read-only | Summary |",
394+ "| --- | --- | --- |",
395+ ...tools.map((t) => `| [\`${t.name}\`](#${slug(t.name)}) | ${t.annotations.readOnlyHint ? "yes" : "no"} | ${cell(firstSentence(t.description))} |`),
396+ "",
397+ ];
398+ for (const [g, list] of groups) {
399+ out.push(`## ${g}`, "");
400+ for (const t of list) {
401+ out.push(`### ${t.name}`, "", t.description, "", `Read-only: ${t.annotations.readOnlyHint ? "yes" : "no"}.`, "");
402+ const props = Object.entries(t.inputSchema.properties as Record<string, JsonSchemaProp>);
403+ if (props.length) {
404+ out.push("| Argument | Type | Required | Description |", "| --- | --- | --- | --- |");
405+ for (const [k, p] of props) {
406+ const type = p.type === "array" && p.items && typeof p.items === "object" ? `array of ${(p.items as { type?: string }).type ?? "object"}` : (p.type ?? "any");
407+ out.push(`| \`${k}\` | ${type}${p.enum ? ` (${p.enum.map((e) => `\`${e}\``).join(", ")})` : ""} | ${t.inputSchema.required.includes(k) ? "yes" : "no"} | ${cell(p.description ?? "")} |`);
408+ }
409+ out.push("");
410+ } else out.push("No arguments.", "");
411+ const args = Object.fromEntries(t.inputSchema.required.map((k) => [k, EXAMPLE[k] ?? (k.includes("number") ? 42 : "...")]));
412+ out.push("```json", JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: t.name, arguments: args } }), "```", "");
413+ }
414+ }
415+ return out.join("\n");
416+}
417+
418+function firstSentence(s: string): string {
419+ const m = /^(.+?\.)(\s|$)/.exec(s);
420+ return m ? m[1]! : s;
421+}
addedapps/diffinery/src/docs/web.tsx+288−0View file
@@ -1,0 +1,288 @@
1+/** @jsxImportSource hono/jsx */
2+/**
3+ * The public docs at /docs, readable without signing in, plus the formats AI
4+ * agents read: /docs/<page>.md (or any page with `Accept: text/markdown`),
5+ * /llms.txt, /llms-full.txt, /docs/api/openapi.json and /docs/search.json.
6+ * Also serves the CLI download at /-/cli/diffinery.mjs.
7+ */
8+import { raw } from "hono/html";
9+import type { AppContext } from "../auth/principal.js";
10+import { fenceLangs, renderMarkdown } from "../web/markdown.js";
11+import { fenceHighlighter } from "../web/highlight.js";
12+import { ICONS } from "../web/assets.js";
13+import { render, type WebApp } from "../web/ui.js";
14+import { CLI_BUNDLE, CLI_VERSION } from "./content.generated.js";
15+import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js";
16+import { openApiDocument } from "./reference.js";
17+
18+const origin = (c: AppContext) => c.env.PUBLIC_URL.replace(/\/$/, "");
19+
20+/** Static per deploy; a short shared cache keeps them fresh after a deploy. */
21+const PUBLIC = { "cache-control": "public, max-age=300", "access-control-allow-origin": "*", "x-content-type-options": "nosniff" };
22+
23+const htmlCache = new Map<string, string>();
24+async function pageHtml(p: Page, origin: string): Promise<string> {
25+ const key = `${origin} ${p.slug}`;
26+ let html = htmlCache.get(key);
27+ if (html === undefined) {
28+ html = renderMarkdown(p.markdown, { idPrefix: "", highlight: await fenceHighlighter(fenceLangs(p.markdown)) });
29+ htmlCache.set(key, html);
30+ }
31+ return html;
32+}
33+
34+/** Agents ask for Markdown with `Accept: text/markdown`; browsers never do. */
35+const wantsMarkdown = (c: AppContext) => /\btext\/markdown\b/i.test(c.req.header("accept") ?? "");
36+
37+function markdownResponse(c: AppContext, body: string): Response {
38+ return c.body(body, 200, { ...PUBLIC, "content-type": "text/markdown; charset=utf-8", vary: "Accept" });
39+}
40+
41+const Sidebar = ({ current }: { current: string }) => (
42+ <nav class="docs-nav" aria-label="Documentation">
43+ <div class="docs-search">
44+ <input type="search" placeholder="Search the docs" aria-label="Search the docs" autocomplete="off" spellcheck={false} data-docs-search="1" />
45+ <ul class="docs-results" role="listbox" hidden></ul>
46+ </div>
47+ {nav().map((s) => (
48+ <div class="docs-sec">
49+ <h4>{s.title}</h4>
50+ <ul>
51+ {s.pages.map((p) => (
52+ <li>
53+ <a href={pageUrl(p.slug)} class={p.slug === current ? "on" : undefined} aria-current={p.slug === current ? "page" : undefined}>
54+ {p.title}
55+ </a>
56+ </li>
57+ ))}
58+ </ul>
59+ </div>
60+ ))}
61+ <div class="docs-sec">
62+ <h4>For AI agents</h4>
63+ <ul>
64+ <li>
65+ <a href="/llms.txt">llms.txt</a>
66+ </li>
67+ <li>
68+ <a href="/llms-full.txt">llms-full.txt</a>
69+ </li>
70+ <li>
71+ <a href="/docs/api/openapi.json">OpenAPI 3.1</a>
72+ </li>
73+ </ul>
74+ </div>
75+ </nav>
76+);
77+
78+async function docsPage(c: AppContext, p: Page | null, status = 200): Promise<Response> {
79+ const o = origin(c);
80+ const html = p ? await pageHtml(p, o) : "";
81+ const slug = p?.slug ?? "";
82+ const i = SLUGS.indexOf(slug);
83+ const prev = i > 0 ? getPage(SLUGS[i - 1]!, o) : null;
84+ const next = i >= 0 && i < SLUGS.length - 1 ? getPage(SLUGS[i + 1]!, o) : null;
85+ const toc = p ? headings(p.markdown).filter((h) => h.level === 2 || p.markdown.length < 60_000) : [];
86+ const body = (
87+ <div class="docs">
88+ <style>{raw(DOCS_CSS)}</style>
89+ <details class="docs-mnav">
90+ <summary>{p ? `${p.section}: ${p.title}` : "Documentation"}</summary>
91+ <Sidebar current={slug} />
92+ </details>
93+ <aside class="docs-side">
94+ <Sidebar current={slug} />
95+ </aside>
96+ <article class="docs-main">
97+ {p ? (
98+ <>
99+ <div class="docs-crumbs">
100+ <a href="/docs">Docs</a> <span class="faint">/</span> <span class="muted">{p.section}</span>
101+ </div>
102+ <h1>{p.title}</h1>
103+ <p class="docs-lead">{p.description}</p>
104+ <div class="docs-actions">
105+ <a class="btn sm" href={markdownUrl(slug)} title="This page as plain Markdown">
106+ {raw(ICONS.file)} View as Markdown
107+ </a>
108+ <button type="button" class="btn sm" data-copy-page={markdownUrl(slug)} title="Copy this page as Markdown, to paste into an AI assistant">
109+ {raw(ICONS.copy)} <span>Copy page</span>
110+ </button>
111+ </div>
112+ <div class="markdown docs-body">{raw(html)}</div>
113+ <nav class="docs-pn" aria-label="Previous and next page">
114+ {prev ? (
115+ <a href={pageUrl(prev.slug)} class="prev">
116+ <span class="faint small">Previous</span>
117+ <span>{prev.title}</span>
118+ </a>
119+ ) : (
120+ <span />
121+ )}
122+ {next ? (
123+ <a href={pageUrl(next.slug)} class="next">
124+ <span class="faint small">Next</span>
125+ <span>{next.title}</span>
126+ </a>
127+ ) : (
128+ <span />
129+ )}
130+ </nav>
131+ </>
132+ ) : (
133+ <>
134+ <h1>Page not found</h1>
135+ <p class="docs-lead">There's no docs page at this address. Search the docs, or start from the overview.</p>
136+ <p>
137+ <a class="btn primary" href="/docs">
138+ Go to the overview
139+ </a>
140+ </p>
141+ </>
142+ )}
143+ </article>
144+ <aside class="docs-toc" aria-label="On this page">
145+ {toc.length ? (
146+ <>
147+ <h4>On this page</h4>
148+ <ul>
149+ {toc.map((h) => (
150+ <li class={h.level === 3 ? "l3" : undefined}>
151+ <a href={`#${h.id}`}>{h.text}</a>
152+ </li>
153+ ))}
154+ </ul>
155+ </>
156+ ) : null}
157+ </aside>
158+ </div>
159+ );
160+ if (p) {
161+ c.header("link", `<${o}${markdownUrl(slug)}>; rel="alternate"; type="text/markdown", <${o}/llms.txt>; rel="describedby"; type="text/plain"`);
162+ c.header("vary", "Accept");
163+ }
164+ return render(
165+ c,
166+ {
167+ title: p ? (slug === "index" ? "Documentation" : `${p.title} · Docs`) : "Page not found · Docs",
168+ description: p?.description,
169+ status,
170+ wide: true,
171+ head: p ? <link rel="alternate" type="text/markdown" href={markdownUrl(slug)} title={`${p.title} (Markdown)`} /> : null,
172+ script: DOCS_JS,
173+ },
174+ body,
175+ );
176+}
177+
178+export function registerDocs(web: WebApp): void {
179+ web.get("/llms.txt", (c) => c.body(llmsTxt(origin(c)), 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8" }));
180+ web.get("/llms-full.txt", (c) => c.body(llmsFullTxt(origin(c)), 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8" }));
181+ web.get("/docs/llms.txt", (c) => c.redirect("/llms.txt", 301));
182+ web.get("/docs/llms-full.txt", (c) => c.redirect("/llms-full.txt", 301));
183+ web.get("/docs/api/openapi.json", (c) => c.body(JSON.stringify(openApiDocument(origin(c), CLI_VERSION), null, 2), 200, { ...PUBLIC, "content-type": "application/json; charset=utf-8" }));
184+ web.get("/docs/search.json", (c) => c.body(JSON.stringify(searchIndex(origin(c))), 200, { ...PUBLIC, "content-type": "application/json; charset=utf-8" }));
185+ web.get("/-/cli/diffinery.mjs", (c) =>
186+ c.body(CLI_BUNDLE, 200, {
187+ ...PUBLIC,
188+ "content-type": "text/javascript; charset=utf-8",
189+ "content-disposition": 'inline; filename="diffinery.mjs"',
190+ "x-diffinery-cli-version": CLI_VERSION,
191+ }),
192+ );
193+
194+ web.get("/docs", (c) => {
195+ const p = getPage("index", origin(c))!;
196+ return wantsMarkdown(c) ? markdownResponse(c, pageMarkdown(p, origin(c))) : docsPage(c, p);
197+ });
198+ web.get("/docs/:slug{.+}", (c) => {
199+ let slug = c.req.param("slug").replace(/\/+$/, "");
200+ if (c.req.path.endsWith("/") && slug) return c.redirect(pageUrl(slug), 301);
201+ const md = slug.endsWith(".md");
202+ if (md) slug = slug.slice(0, -3);
203+ if (slug === "index" && !md) return c.redirect("/docs", 301);
204+ const p = getPage(slug, origin(c));
205+ if (!p) {
206+ if (md) return c.body(`# Not found\n\nThere is no docs page "${slug}". See ${origin(c)}/llms.txt for every page.\n`, 404, { "content-type": "text/markdown; charset=utf-8" });
207+ return docsPage(c, null, 404);
208+ }
209+ return md || wantsMarkdown(c) ? markdownResponse(c, pageMarkdown(p, origin(c))) : docsPage(c, p);
210+ });
211+}
212+
213+/** Every docs URL, for the sitemap. */
214+export const docsSitemapPaths = (): string[] => SLUGS.map(pageUrl);
215+export { allPages };
216+
217+const DOCS_CSS = `
218+main.wrap:has(.docs){padding-top:0}
219+.docs{display:grid;grid-template-columns:248px minmax(0,1fr) 220px;gap:40px;max-width:1240px;margin:0 auto}
220+.docs-side,.docs-toc{position:sticky;top:52px;align-self:start;max-height:calc(100vh - 52px);overflow:auto;padding:20px 0 40px}
221+.docs-nav h4,.docs-toc h4{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--fg3);margin:18px 0 6px;font-weight:600}
222+.docs-nav ul,.docs-toc ul{list-style:none;margin:0;padding:0}
223+.docs-nav li a{display:block;padding:4px 10px;border-radius:6px;color:var(--fg2);font-size:13.5px}
224+.docs-nav li a:hover{background:var(--bg-hover);color:var(--fg);text-decoration:none}
225+.docs-nav li a.on{background:var(--accent-soft);color:var(--fg);font-weight:550}
226+.docs-search{position:relative}
227+.docs-results{position:absolute;z-index:30;left:0;right:-160px;top:calc(100% + 4px);background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);list-style:none;margin:0;padding:4px;max-height:60vh;overflow:auto}
228+.docs-results li a{display:block;padding:7px 10px;border-radius:6px;color:var(--fg)}
229+.docs-results li a:hover,.docs-results li.sel a{background:var(--bg-hover);text-decoration:none}
230+.docs-results .r-t{font-weight:550}.docs-results .r-s{display:block;font-size:12px;color:var(--fg2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
231+.docs-results .r-none{padding:8px 10px;color:var(--fg2)}
232+.docs-main{padding:28px 0 60px;min-width:0}
233+.docs-main h1{font-size:30px;letter-spacing:-.02em;margin:6px 0 8px}
234+.docs-crumbs{font-size:13px}
235+.docs-lead{font-size:16px;color:var(--fg2);margin:0 0 14px}
236+.docs-actions{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 22px;padding-bottom:18px;border-bottom:1px solid var(--border2)}
237+.docs-actions .st svg{width:14px;height:14px}
238+.docs-body{font-size:15px;line-height:1.7}
239+.docs-body h2{font-size:21px;margin-top:34px;scroll-margin-top:64px}.docs-body h3{font-size:16.5px;margin-top:26px;scroll-margin-top:64px}.docs-body h4{scroll-margin-top:64px}
240+.docs-body pre{position:relative}.docs-body pre .cp{position:absolute;top:6px;right:6px;opacity:0;transition:opacity .15s}
241+.docs-body pre:hover .cp,.docs-body pre .cp:focus{opacity:1}
242+.docs-body blockquote{background:var(--bg2);border-left-color:var(--accent);border-radius:0 6px 6px 0;padding:10px 14px;color:var(--fg)}
243+.docs-body blockquote p:last-child{margin:0}
244+.docs-body table{font-size:13.5px}.docs-body td code{white-space:nowrap}
245+.docs-pn{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border2)}
246+.docs-pn a{display:flex;flex-direction:column;gap:2px;padding:12px 14px;border:1px solid var(--border);border-radius:var(--r);color:var(--fg)}
247+.docs-pn a:hover{border-color:var(--accent);text-decoration:none}.docs-pn .next{text-align:right}
248+.docs-toc li a{display:block;padding:3px 0 3px 10px;border-left:2px solid var(--border2);color:var(--fg2);font-size:12.5px;line-height:1.4}
249+.docs-toc li.l3 a{padding-left:22px}
250+.docs-toc li a:hover{color:var(--fg);text-decoration:none}.docs-toc li a.on{color:var(--fg);border-left-color:var(--accent)}
251+.docs-mnav{display:none}
252+@media (max-width:1180px){.docs{grid-template-columns:232px minmax(0,1fr);gap:32px}.docs-toc{display:none}}
253+@media (max-width:820px){.docs{grid-template-columns:minmax(0,1fr);gap:0}.docs-side{display:none}
254+.docs-mnav{display:block;position:sticky;top:52px;z-index:15;background:var(--canvas);border-bottom:1px solid var(--border);margin:0 -12px;padding:0 12px}
255+.docs-mnav summary{padding:10px 0;cursor:pointer;font-weight:550}.docs-mnav[open]{max-height:calc(100vh - 52px);overflow:auto}
256+.docs-results{right:0}.docs-main{padding-top:16px}.docs-main h1{font-size:25px}}
257+`;
258+
259+const DOCS_JS = `(()=>{
260+const d=document,$=(s,r=d)=>r.querySelector(s),$$=(s,r=d)=>[...r.querySelectorAll(s)];
261+// Copy buttons on code blocks.
262+$$('.docs-body pre').forEach(pre=>{const b=d.createElement('button');b.type='button';b.className='btn sm cp';b.textContent='Copy';
263+ b.addEventListener('click',()=>{const t=(pre.querySelector('code')||pre).innerText;navigator.clipboard.writeText(t).then(()=>{b.textContent='Copied';setTimeout(()=>{b.textContent='Copy'},1200)})});pre.appendChild(b)});
264+// Copy the whole page as Markdown.
265+$$('[data-copy-page]').forEach(b=>b.addEventListener('click',async()=>{const s=b.querySelector('span');
266+ try{const r=await fetch(b.dataset.copyPage,{headers:{accept:'text/markdown'}});await navigator.clipboard.writeText(await r.text());s.textContent='Copied'}catch(_){s.textContent='Copy failed'}
267+ setTimeout(()=>{s.textContent='Copy page'},1500)}));
268+// Highlight the current section in "On this page".
269+const toc=$$('.docs-toc a');if(toc.length&&'IntersectionObserver'in window){const map=new Map(toc.map(a=>[decodeURIComponent(a.hash.slice(1)),a]));
270+ const io=new IntersectionObserver(es=>{es.forEach(e=>{if(e.isIntersecting){toc.forEach(a=>a.classList.remove('on'));const a=map.get(e.target.id);a&&a.classList.add('on')}})},{rootMargin:'-60px 0px -70% 0px'});
271+ $$('.docs-body h2[id],.docs-body h3[id]').forEach(h=>io.observe(h))}
272+// Search: a small index fetched on first use, ranked by title, heading and text matches.
273+let idx=null,loading=null;const load=()=>loading||(loading=fetch('/docs/search.json').then(r=>r.json()).then(j=>{idx=j.map(p=>Object.assign(p,{tl:p.t.toLowerCase(),xl:p.x.toLowerCase(),hl:p.h.map(h=>h[1].toLowerCase())}))}));
274+const esc=s=>s.replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
275+function search(q){const terms=q.toLowerCase().split(/\\s+/).filter(Boolean);if(!terms.length||!idx)return[];const out=[];
276+ for(const p of idx){let score=0,hit=null;for(const t of terms){let s=0;if(p.tl.includes(t))s+=10;p.hl.forEach((h,i)=>{if(h.includes(t)){s+=4;if(!hit)hit=p.h[i]}});if(p.xl.includes(t))s+=1;if(!s){score=0;break}score+=s}
277+ if(score){const pos=p.xl.indexOf(terms[0]);out.push({p,score,hit,snip:pos>=0?p.x.slice(Math.max(0,pos-40),pos+90):p.d})}}
278+ return out.sort((a,b)=>b.score-a.score).slice(0,12)}
279+$$('[data-docs-search]').forEach(inp=>{const list=inp.parentElement.querySelector('.docs-results');let sel=-1;
280+ const show=()=>{const q=inp.value.trim();if(!q){list.hidden=true;return}const rs=search(q);sel=-1;list.hidden=false;
281+ list.innerHTML=rs.length?rs.map(r=>{const href=(r.p.s==='index'?'/docs':'/docs/'+r.p.s)+(r.hit?'#'+r.hit[0]:'');
282+ return '<li><a href="'+esc(href)+'"><span class="r-t">'+esc(r.p.t)+(r.hit?' › '+esc(r.hit[1]):'')+'</span><span class="r-s">'+esc(r.p.c+' · '+r.snip)+'</span></a></li>'}).join(''):'<li class="r-none">No results</li>'};
283+ inp.addEventListener('focus',()=>{load().then(show)});inp.addEventListener('input',()=>{load().then(show)});
284+ inp.addEventListener('keydown',e=>{const items=$$('li a',list);if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();sel=(sel+(e.key==='ArrowDown'?1:-1)+items.length)%items.length;
285+ $$('li',list).forEach((li,i)=>li.classList.toggle('sel',i===sel))}else if(e.key==='Enter'){const a=items[sel<0?0:sel];if(a){e.preventDefault();location.assign(a.href)}}else if(e.key==='Escape'){list.hidden=true;inp.blur()}});
286+ d.addEventListener('click',e=>{if(!inp.parentElement.contains(e.target))list.hidden=true})});
287+d.addEventListener('keydown',e=>{if(e.key==='/'&&!/input|textarea|select/i.test((e.target.tagName||''))&&!e.target.isContentEditable){const i=$$('[data-docs-search]').find(x=>x.offsetParent);if(i){e.preventDefault();i.focus()}}});
288+})();`;
addedapps/diffinery/src/mcp/docs.ts+45−0View file
Diff not rendered to keep this page fast. View the file
modifiedapps/diffinery/src/mcp/server.ts+431−426View file
Diff not rendered to keep this page fast. View the file
modifiedapps/diffinery/src/web/account.tsx+3−0View file
Diff not rendered to keep this page fast. View the file
modifiedapps/diffinery/src/web/assets.ts+38−38View file
Diff not rendered to keep this page fast. View the file
modifiedapps/diffinery/src/web/markdown.ts+505−503View file
Diff not rendered to keep this page fast. View the file
modifiedapps/diffinery/src/web/packages.tsx+1−1View file
Diff not rendered to keep this page fast. View the file
modifiedapps/diffinery/src/web/routes.tsx+18−15View file
Diff not rendered to keep this page fast. View the file
modifiedapps/diffinery/src/web/seo.tsx+81−79View file
Diff not rendered to keep this page fast. View the file
modifiedapps/diffinery/src/web/ui.tsx+220−211View file
Diff not rendered to keep this page fast. View the file
addedapps/diffinery/test/unit/docs.test.ts+221−0View file
Diff not rendered to keep this page fast. View the file