| 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) 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 | |
| 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": "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 | |
| 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/install | bash`, then `diffinery login`): |
| 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 @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 | |
| 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": "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 | |
| 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 | permissions: |
| 424 | contents: write |
| 425 | jobs: |
| 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 | |
| 442 | The job's own `GITHUB_TOKEN` uploads the asset, which needs `contents: write` (set above for the whole workflow). Uploads go to the release's `upload_url`, under `/api/uploads/repos/...` (GitHub's `uploads.github.com`). To use a personal or agent token instead, store it as an Actions secret and put it in `TOKEN`. |
| 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 | |
| 456 | Like 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 | |
| 476 | 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. |
| 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. | |