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.

522 lines · 33.2 KBRaw History
1---
2title: Releases
3description: 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
6A 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
18Each 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) can create, edit and delete releases and upload assets when the job has `contents: write`, as on GitHub. With only `contents: read` (the default for new repositories) it can read releases, and a write fails with `403` and `Resource not accessible by integration (the workflow's GITHUB_TOKEN needs permissions: contents: write)`. Releases a job token creates or changes don't start `on: release` workflows (see [Workflows triggered by releases](#workflows-triggered-by-releases)).
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
36See [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
48Each 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
521. 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).
532. 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.
543. 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.
554. Enter a **Release title**. When you leave it empty, the tag name is shown instead.
565. 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.
576. 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.
587. 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.
598. Select **Publish release**, or **Save draft** to keep it private to writers.
60
61After 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
72The 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
94curl -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
117The 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": "diffy",
128 "id": 7220136606554077,
129 "node_id": "VXNlcjphMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MA",
130 "avatar_url": "https://diffinery.com/api/avatars/diffy",
131 "gravatar_id": "",
132 "url": "https://diffinery.com/api/v3/users/diffy",
133 "html_url": "https://diffinery.com/diffy",
134 "repos_url": "https://diffinery.com/api/v3/users/diffy/repos",
135 "organizations_url": "https://diffinery.com/api/v3/users/diffy/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 @diffy in https://diffinery.com/acme/website/pull/41\n\n**Full Changelog**: https://diffinery.com/acme/website/compare/v1.1.0...v1.2.0",
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
161Creating 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
163The same request with the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`):
164
165```sh
166cat > release.json <<'EOF'
167{ "tag_name": "v1.2.0", "name": "Website 1.2.0", "generate_release_notes": true }
168EOF
169diffinery api POST /repos/acme/website/releases --input release.json
170```
171
172There 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
180curl -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
184curl -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
197curl -sS -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/releases/latest
198
199# A release by tag
200curl -sS -H "Authorization: Bearer dfy_..." https://diffinery.com/api/v3/repos/acme/website/releases/tags/v1.2.0
201```
202
203For 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
207Diffinery picks the latest release like GitHub:
208
2091. 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.
2102. Otherwise, the most recently created published release that is not a pre-release is the latest.
211
212Drafts 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
220Booleans `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
222When the repository has no published full release, `GET .../releases/latest` returns `404` with `no published releases`.
223
224## Generated release notes
225
226Diffinery generates release notes the way GitHub's **Generate release notes** does:
227
2281. 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`.
2292. It collects the pull requests merged into the commits between the previous tag and the new one (by merge commit or head commit).
2303. It groups them by the categories in `.github/release.yml`, if that file exists.
2314. It lists people whose first merged pull request in the repository is in the range under **New Contributors**.
2325. 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
234Each pull request becomes one line: `* <title> by @<author> in <link>`.
235
236### Generate notes without creating a release
237
238```sh
239curl -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
251The 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 @diffy in https://diffinery.com/acme/website/pull/40\n\n### Bug Fixes\n* Fix broken footer links by @diffy in https://diffinery.com/acme/website/pull/41\n\n**Full Changelog**: https://diffinery.com/acme/website/compare/v1.1.0...v1.2.0"
257}
258```
259
260### Configure categories with .github/release.yml
261
262Diffinery 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
265changelog:
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
287These 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
310Upload 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
313curl -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
327The 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": "diffy",
338 "id": 7220136606554077,
339 "node_id": "VXNlcjphMWIyYzNkNGU1ZjYwNzE4MjkzYTRiNWM2ZDdlOGY5MA",
340 "avatar_url": "https://diffinery.com/api/avatars/diffy",
341 "gravatar_id": "",
342 "url": "https://diffinery.com/api/v3/users/diffy",
343 "html_url": "https://diffinery.com/diffy",
344 "repos_url": "https://diffinery.com/api/v3/users/diffy/repos",
345 "organizations_url": "https://diffinery.com/api/v3/users/diffy/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
361The 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
369curl -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
374Downloads 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
380curl -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
385curl -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
399Agents 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
410There is no MCP tool to delete a release; use the REST API or the web UI.
411
412The 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
416A 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
419name: publish
420on:
421 release:
422 types: [published]
423permissions:
424 contents: write
425jobs:
426 build:
427 runs-on: ubuntu-latest
428 steps:
429 - uses: actions/checkout@v4
430 - run: ./build.sh "${{ github.event.release.tag_name }}"
431 - name: Attach the build to the release
432 env:
433 TOKEN: ${{ secrets.GITHUB_TOKEN }}
434 run: |
435 curl -fsS -X POST \
436 -H "Authorization: Bearer $TOKEN" \
437 -H "Content-Type: application/gzip" \
438 --data-binary @dist/website.tar.gz \
439 "https://diffinery.com/api/uploads/repos/${{ github.repository }}/releases/${{ github.event.release.id }}/assets?name=website.tar.gz"
440```
441
442The job's own `GITHUB_TOKEN` uploads the asset, which needs `contents: write` (set above for the whole workflow). Uploads go to the release's `upload_url`, under `/api/uploads/repos/...` (GitHub's `uploads.github.com`). To use a personal or agent token instead, store it as an Actions secret and put it in `TOKEN`.
443
444### Activity types
445
446| Type | When |
447|---|---|
448| `created` | A release is created (published or not). |
449| `published` | A release is published: created as published, or a draft is published. |
450| `released` | A full release is published, or a published pre-release becomes a full release. |
451| `prereleased` | A pre-release is published, or a published release becomes a pre-release. |
452| `edited` | A published release is edited (without changing its draft state). |
453| `unpublished` | A published release is turned back into a draft. |
454| `deleted` | A published release is deleted. |
455
456Like GitHub:
457
458- 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".
459- Changes to drafts (creating, editing or deleting a draft) never start workflows. They do send webhooks.
460- The workflow only runs when the release's tag exists.
461- Releases created or changed with a job's `GITHUB_TOKEN` don't start workflows, so a workflow can't trigger itself in a loop. Releases made with a personal or agent token do.
462
463### The event payload
464
465`github.event` holds:
466
467| Field | Description |
468|---|---|
469| `action` | The activity type, for example `published` |
470| `release` | The release, in the same shape as the REST API (`id`, `tag_name`, `name`, `body`, `draft`, `prerelease`, `assets`, `upload_url`, `html_url`, `author`, ...) |
471| `repository` | GitHub's full repository object, as `GET /repos/{owner}/{repo}` returns it: `id`, `name`, `full_name`, `owner`, `private` and `visibility` (the repository's real visibility), `default_branch`, `html_url`, `clone_url`, the API URLs and so on |
472| `sender` | `login` and `type` (`User` or `Bot`) of who caused the event |
473
474## Release webhooks
475
476Webhooks 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.
477
478## Agents and releases
479
480[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:
481
482- 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.
483- Workflow runs started by an agent's release get environment secrets only when the environment is limited to protected branches.
484- Agents can't change branch protection, secrets or environments, so they can't loosen those rules to ship a release.
485
486## Differences from GitHub
487
488| GitHub | Diffinery |
489|---|---|
490| Uploads go to `uploads.github.com` | `upload_url` is on the same host, under `/api/uploads/`. |
491| Asset downloads through the API redirect to a storage URL | `Accept: application/octet-stream` returns the bytes directly with `200`. |
492| Assets up to 2 GiB | Assets up to 100 MB (25 MB over MCP). |
493| `make_latest: legacy` considers creation date and semantic version | `legacy` uses creation date only. |
494| Release ids are global | Release and asset ids are numbered per repository. |
495| `discussion_category_name`, immutable releases, asset digests | Not supported: `discussion_category_name` is ignored, `immutable` is always `false` and `digest` is always `null`. |
496| No `latest` field | Releases have a `latest` boolean. |
497
498## Troubleshooting
499
500| Message | Cause and fix |
501|---|---|
502| `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`). |
503| `a release for this tag already exists` | Only one published release per tag. Edit the existing release, or use another tag. |
504| `'<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`). |
505| `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`. |
506| `tag was created concurrently; retry` | Someone created the tag at the same moment. Send the request again. |
507| `write access required` | Your role is below write, or the request used a workflow's `GITHUB_TOKEN` without any write permission. |
508| `Resource not accessible by integration (...)` | A workflow's `GITHUB_TOKEN` lacks `contents: write`. Add it to the job's or workflow's `permissions:`. |
509| `this token needs the write scope` | Your token has only the `read` scope. |
510| `release not found` | Wrong id, or the release is a draft and you can't write to the repository. |
511| `no published releases` | `/releases/latest` with no published full release. Publish one, or mark one as latest. |
512| `body is too long (maximum is 125000 characters)` | Shorten the notes, or attach the full changelog as an asset. |
513| `the name query parameter is required` | Add `?name=<file name>` to the upload URL (drop the `{?name,label}` template part). |
514| `release assets are limited to 100 MB` | Split the file or host it elsewhere and link to it from the notes. |
515| `an asset named <name> already exists` | Delete or rename the existing asset first. |
516| `asset name must be 1 to 255 characters` | The name is empty after sanitizing, or too long. |
517| `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`. |
518| `MCP uploads are limited to 25 MB; use the upload_url of the REST API for larger files` | Upload large files over REST. |
519| `previous tag '<name>' does not exist` | Check `previous_tag_name`, or leave it out to let Diffinery find the previous release. |
520| An `on: release` workflow runs three times | It has no `types` filter. Add `types: [published]`. |
521| An `on: release` workflow doesn't run for a draft | Draft changes never start workflows. Publish the release. |
522| 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. |