Releases architecture
Releases are a thin database annotation over git tags, plus an archive writer and
an uploaded-file store. The tag stays the source of truth for what was released;
the releases row only adds how it is presented (title, notes, pre-release flag)
and remembers which commit the tag resolved to when it was published.
Downloads come in two shapes, and the split is the central idea: source archives are generated from the tag on demand, while assets are uploaded because they cannot be derived from the repository at all.
Component map
| Concern | Type | Notes |
|---|---|---|
| Asset entity | de.workaround.model.ReleaseAsset |
release, uploader, fileName, contentType, sizeBytes, downloadCount, uploadedAt; humanSize() renders the size with Locale.ROOT. Repo carries the per-release list, repository-scoped id lookup, the duplicate-name count and the id lists used for file cleanup |
| Asset migration | V33__release_assets.sql |
release_assets, cascading from releases and users |
| Entity | de.workaround.model.Release |
repository, author, tagName, title, body, commitId, prerelease, createdAt; Repo carries the newest-first list, the stable-only list (for "Latest"), tag lookup and the nav count |
| Domain service | git.ReleaseService |
Publish/edit/delete with AccessPolicy.canWrite; cuts a missing tag from the submitted target; blank title falls back to the tag name. Also owns the asset layer: name validation, size cap, streaming to and from the store, download counting, file cleanup |
| Tag writes | git.GitTagService |
resolveCommit (peels annotated tags), exists, createTag — annotated tag written in-core with TagBuilder + RefUpdate, no working tree |
| Archives | git.GitArchiveService |
Format.ZIP/TAR_GZ; streams a ref's tree into an OutputStream |
| Web UI | web.ReleaseResource + templates/ReleaseResource/* |
List, detail (Markdown notes via the shared Markdown renderer), new/edit forms |
| Archive endpoint | web.RepositoryResource#archive |
`GET …/archive/{ref}.zip |
| REST | api.ReleaseApiResource + ApiModels.ReleaseView/NewRelease/ReleaseEdit |
Gitea-shaped release contract, addressed by tag |
| Navigation | web.RepoNav#releaseCount (built in RepoNavService) |
Sidebar entry between Tags and Issues |
| Asset cleanup hook | git.GitRepositoryService#delete |
Calls ReleaseService.deleteFilesFor alongside RepositoryImageService.deleteFileFor, so deleting a repository takes its asset bytes with it |
| Upload-size guard | account.ImageValidation#validateSize |
Called by the avatar and repository-image uploads before Files.readAllBytes, because the raised HTTP body cap otherwise lets a 200 MB body onto the heap just to be rejected for exceeding 2 MB |
Data flow
Publish. ReleaseResource (or ReleaseApiResource) → ReleaseService.create
→ authorize → reject a blank tag name or a tag that already has a release → if the
tag exists, resolve it to a commit; otherwise GitTagService.createTag cuts an
annotated tag on the target and returns the tagged commit → persist the row →
303 to …/releases/tag/{tag}.
Read. The list page reads ReleaseService.list plus findLatest (newest
release with prerelease = false) to place the Latest badge. The detail page
renders body through the shared XSS-safe Markdown renderer and links the two
archive URLs and the commit.
Attach. ReleaseResource#upload (or ReleaseApiResource#uploadAsset) takes a
FileUpload that Quarkus has already buffered to a temporary file →
ReleaseService.addAsset authorizes, validates the name, rejects a duplicate name,
an empty file and anything over gitshark.releases.max-asset-size, persists the row,
and only then copies the bytes to <asset root>/<asset id>. Persisting first is
deliberate: the id names the storage path, so a failed copy rolls the row back.
Asset download. GET …/releases/assets/{id}/{fileName} resolves the asset by
id scoped to the repository (findByRepositoryAndId), so an id belonging to
another repository cannot be read through one the caller can see. The file is handed
over as a File entity and streamed, never read into memory, then the counter is
incremented.
Archive download. RepositoryResource#archive picks the format from the URL suffix,
resolves the ref (404 if unknown), and hands a StreamingOutput to
GitArchiveService, which walks the commit's tree recursively and copies each
blob straight from the object database.
Decisions
- The tag is the key, not a surrogate number. Issues and merge requests carry
per-repo numbers; a release is about a tag, and Gitea's API addresses releases
by tag too (
/releases/tags/{tag}). Aunique (repository_id, tag_name)constraint enforces one release per tag, and the UI routes are…/releases/tag/{tag}with a greedy match so slash-bearing tags work. - Deleting a release keeps the tag. Releases are presentation metadata; deleting one must never rewrite history or break clones that fetched the tag. Deleting a tag is deliberately not offered anywhere in the UI.
- Tag creation is in-core. Cutting the tag while publishing (Gitea's
target_commitish) means the common "tag and release in one step" flow needs no local clone. It reuses the same bare-repo, no-working-tree approachGitMergeServiceestablished for merges, withRefUpdateexpecting a zero-id old value so a concurrent creation loses rather than overwrites. The ref write happens inside the publishing transaction, so a DB failure afterwards can leave an orphan tag — harmless, because publishing again simply adopts the existing tag instead of cutting a new one. commitIdis stored, the rest is read live. The tag could later be deleted or moved by a force push; the release page must still be able to name the exact revision that was published. Everything else (tree, archives) is read live from git and never duplicated.- Archives use the JDK only.
org.eclipse.jgit.archivewould pull in commons-compress purely for tar; a ~60-line ustar header writer plusGZIPOutputStreamandZipOutputStreamkeeps the native image's dependency surface unchanged. Long paths use the ustarprefixfield; a path that fits neither field fails loudly instead of being silently truncated. - Blobs are streamed, not buffered.
ObjectLoader.copyTowrites into the response, so archive memory does not scale with repository size — the same reason the raw-blob endpoint streams. - No drafts. Gitea's
draftis reported as a constantfalse. A draft is a second visibility rule over the same row, and nothing needs it yet. - Assets are stored by UUID, never by name. Same pattern as avatars and
repository images, for the same two reasons: bytes do not belong in PostgreSQL,
and a user-supplied name must not be able to influence a path. The name lives
only in the database and in the
Content-Dispositionheader. - Asset file names are restricted to
[A-Za-z0-9][A-Za-z0-9._+~-]{0,254}. Real artifacts (app-release.apk,gitshark-1.0.0.jar,SHA256SUMS.txt) fit it. The restriction buys two things at once: nothing needs URL-escaping in the download path, and no name can denote a directory or a traversal. Sanitising arbitrary names instead would mean carrying an encoded and a decoded form through templates and headers, for filenames nobody actually ships. - The REST API addresses assets by file name, the web UI by UUID.
GiteaIdsis documented as one-way and lossy — a display surrogate that nothing is looked up by — so an endpoint keyed on the numericidit puts on the wire would be unusable by a client reading that very field. Names are unique per release and already restricted to a URL-safe charset, which makes them the natural key, exactly as owner/name/number are elsewhere in the API. The web UI has the real UUIDs in hand and uses them, and the JSON carries the UUID asuuidplus a ready-madebrowser_download_url. - The web asset route puts the id first and the name last (
…/releases/assets/{id}/{fileName}). Hanging the name directly off the tag route instead would collide with the greedy match that slash-bearing tags require. Keying on the id keeps both routes unambiguous whilecurl -Ostill saves a sensibly named file. - Every asset download is an attachment with
nosniff. The content type is whatever the uploader's browser declared, so it can betext/html. Forcing attachment disposition means it is never rendered on the instance's origin, which closes stored XSS without having to police an allowlist of "safe" content types. - Raising
quarkus.http.limits.max-body-sizeis a whole-instance change, so the two in-memory uploads were guarded in the same step. Asset uploads need a body cap far above Quarkus's 10 MB default; the avatar and repository-image handlers read the full body withFiles.readAllBytesbefore checking their 2 MB limit, which would have turned the higher cap into a memory-exhaustion vector. They now checkFileUpload.size()first. - Download counts cost one
UPDATEper download. Download counts are the main thing people want to know about a published artifact; a single indexed row update is accepted as the price.
What works today
- Publish a release for an existing tag, or cut an annotated tag from a branch, tag or commit while publishing.
- Markdown release notes (XSS-safe), pre-release flag, "Latest" badge that skips pre-releases.
- Edit title/notes/pre-release; delete the release while keeping the tag.
- Source archives (
.zip,.tar.gz) for any ref, visibility-guarded and streamed, nested under a<repo>-<ref>/prefix. - Sidebar entry with a release count on every repository page.
- Gitea-shaped REST: list, create,
latest, get/PATCH/DELETE by tag, withzipball_url/tarball_urlpointing at the archive endpoint, and anassetsarray on every release object. - Release assets: attach a prebuilt file (one per request) from the release page
or over REST, list them with size and download count, download them, delete one or
all of them. Bytes stream to and from
gitshark.storage.release-assets, capped byGITSHARK_MAX_ASSET_SIZE. Deleting an asset, a release, or a repository removes the stored files. - CI can publish end to end with a personal access token: create the release, then
POST …/releases/tags/{tag}/assetswith the artifact.
What still needs to be implemented
- Draft releases, and with them a
draftflag that is more than a constant. - Multi-file asset upload — one file per request; the form has no multiple-file input, and there is no batch REST call.
- Asset checksums — nothing is computed or displayed; publishers upload their
own
SHA256SUMSfile. - Orphaned-asset reconciliation — nothing reconciles rows against files, and a process killed mid-transaction can strand either side. Deletes remove the file before the commit, so an interrupted delete leaves a row whose file is gone (the download 404s, handled gracefully but visible to users); an upload copies the bytes before the commit, so an interrupted upload leaves a file with no row (silently wasted space). Both windows are narrow; neither is swept.
- Asset content-type allowlist — the declared multipart content type is stored
after a
strip()with no further validation. Harmless while every download is an attachment withnosniff, but worth revisiting if that ever changes. - MCP tools for releases and their assets — the MCP surface mirrors the REST API elsewhere and currently has no release tools.
- Federation — a release publishes no ActivityPub activity, so followers of a repository learn about pushes but not about releases.
- Auto-notes — no "generate release notes from commits since the last tag".
- Dashboard/notification integration — releases contribute no
NotificationSourceitems.
# Releases architecture
Releases are a thin database annotation over git tags, plus an archive writer and
an uploaded-file store. The tag stays the source of truth for *what* was released;
the `releases` row only adds *how it is presented* (title, notes, pre-release flag)
and remembers which commit the tag resolved to when it was published.
Downloads come in two shapes, and the split is the central idea: **source archives
are generated from the tag on demand**, while **assets are uploaded** because they
cannot be derived from the repository at all.
## Component map
| Concern | Type | Notes |
|---|---|---|
| Asset entity | `de.workaround.model.ReleaseAsset` | `release`, `uploader`, `fileName`, `contentType`, `sizeBytes`, `downloadCount`, `uploadedAt`; `humanSize()` renders the size with `Locale.ROOT`. `Repo` carries the per-release list, repository-scoped id lookup, the duplicate-name count and the id lists used for file cleanup |
| Asset migration | `V33__release_assets.sql` | `release_assets`, cascading from `releases` and `users` |
| Entity | `de.workaround.model.Release` | `repository`, `author`, `tagName`, `title`, `body`, `commitId`, `prerelease`, `createdAt`; `Repo` carries the newest-first list, the stable-only list (for "Latest"), tag lookup and the nav count |
| Domain service | `git.ReleaseService` | Publish/edit/delete with `AccessPolicy.canWrite`; cuts a missing tag from the submitted target; blank title falls back to the tag name. Also owns the asset layer: name validation, size cap, streaming to and from the store, download counting, file cleanup |
| Tag writes | `git.GitTagService` | `resolveCommit` (peels annotated tags), `exists`, `createTag` — annotated tag written in-core with `TagBuilder` + `RefUpdate`, no working tree |
| Archives | `git.GitArchiveService` | `Format.ZIP`/`TAR_GZ`; streams a ref's tree into an `OutputStream` |
| Web UI | `web.ReleaseResource` + `templates/ReleaseResource/*` | List, detail (Markdown notes via the shared `Markdown` renderer), new/edit forms |
| Archive endpoint | `web.RepositoryResource#archive` | `GET …/archive/{ref}.zip|.tar.gz`, visibility-guarded, `StreamingOutput` |
| REST | `api.ReleaseApiResource` + `ApiModels.ReleaseView`/`NewRelease`/`ReleaseEdit` | Gitea-shaped release contract, addressed by tag |
| Navigation | `web.RepoNav#releaseCount` (built in `RepoNavService`) | Sidebar entry between Tags and Issues |
| Asset cleanup hook | `git.GitRepositoryService#delete` | Calls `ReleaseService.deleteFilesFor` alongside `RepositoryImageService.deleteFileFor`, so deleting a repository takes its asset bytes with it |
| Upload-size guard | `account.ImageValidation#validateSize` | Called by the avatar and repository-image uploads before `Files.readAllBytes`, because the raised HTTP body cap otherwise lets a 200 MB body onto the heap just to be rejected for exceeding 2 MB |
## Data flow
**Publish.** `ReleaseResource` (or `ReleaseApiResource`) → `ReleaseService.create`
→ authorize → reject a blank tag name or a tag that already has a release → if the
tag exists, resolve it to a commit; otherwise `GitTagService.createTag` cuts an
annotated tag on the target and returns the tagged commit → persist the row →
`303` to `…/releases/tag/{tag}`.
**Read.** The list page reads `ReleaseService.list` plus `findLatest` (newest
release with `prerelease = false`) to place the **Latest** badge. The detail page
renders `body` through the shared XSS-safe `Markdown` renderer and links the two
archive URLs and the commit.
**Attach.** `ReleaseResource#upload` (or `ReleaseApiResource#uploadAsset`) takes a
`FileUpload` that Quarkus has already buffered to a temporary file →
`ReleaseService.addAsset` authorizes, validates the name, rejects a duplicate name,
an empty file and anything over `gitshark.releases.max-asset-size`, persists the row,
and only then copies the bytes to `<asset root>/<asset id>`. Persisting first is
deliberate: the id names the storage path, so a failed copy rolls the row back.
**Asset download.** `GET …/releases/assets/{id}/{fileName}` resolves the asset by
**id scoped to the repository** (`findByRepositoryAndId`), so an id belonging to
another repository cannot be read through one the caller can see. The file is handed
over as a `File` entity and streamed, never read into memory, then the counter is
incremented.
**Archive download.** `RepositoryResource#archive` picks the format from the URL suffix,
resolves the ref (404 if unknown), and hands a `StreamingOutput` to
`GitArchiveService`, which walks the commit's tree recursively and copies each
blob straight from the object database.
## Decisions
- **The tag is the key, not a surrogate number.** Issues and merge requests carry
per-repo numbers; a release is *about* a tag, and Gitea's API addresses releases
by tag too (`/releases/tags/{tag}`). A `unique (repository_id, tag_name)`
constraint enforces one release per tag, and the UI routes are
`…/releases/tag/{tag}` with a greedy match so slash-bearing tags work.
- **Deleting a release keeps the tag.** Releases are presentation metadata;
deleting one must never rewrite history or break clones that fetched the tag.
Deleting a *tag* is deliberately not offered anywhere in the UI.
- **Tag creation is in-core.** Cutting the tag while publishing (Gitea's
`target_commitish`) means the common "tag and release in one step" flow needs no
local clone. It reuses the same bare-repo, no-working-tree approach
`GitMergeService` established for merges, with `RefUpdate` expecting a
zero-id old value so a concurrent creation loses rather than overwrites. The
ref write happens inside the publishing transaction, so a DB failure afterwards
can leave an orphan tag — harmless, because publishing again simply adopts the
existing tag instead of cutting a new one.
- **`commitId` is stored, the rest is read live.** The tag could later be deleted
or moved by a force push; the release page must still be able to name the exact
revision that was published. Everything else (tree, archives) is read live from
git and never duplicated.
- **Archives use the JDK only.** `org.eclipse.jgit.archive` would pull in
commons-compress purely for tar; a ~60-line ustar header writer plus
`GZIPOutputStream` and `ZipOutputStream` keeps the native image's dependency
surface unchanged. Long paths use the ustar `prefix` field; a path that fits
neither field fails loudly instead of being silently truncated.
- **Blobs are streamed, not buffered.** `ObjectLoader.copyTo` writes into the
response, so archive memory does not scale with repository size — the same
reason the raw-blob endpoint streams.
- **No drafts.** Gitea's `draft` is reported as a constant `false`. A draft is a
second visibility rule over the same row, and nothing needs it yet.
- **Assets are stored by UUID, never by name.** Same pattern as avatars and
repository images, for the same two reasons: bytes do not belong in PostgreSQL,
and a user-supplied name must not be able to influence a path. The name lives
only in the database and in the `Content-Disposition` header.
- **Asset file names are restricted to `[A-Za-z0-9][A-Za-z0-9._+~-]{0,254}`.** Real
artifacts (`app-release.apk`, `gitshark-1.0.0.jar`, `SHA256SUMS.txt`) fit it. The
restriction buys two things at once: nothing needs URL-escaping in the download
path, and no name can denote a directory or a traversal. Sanitising arbitrary
names instead would mean carrying an encoded and a decoded form through templates
and headers, for filenames nobody actually ships.
- **The REST API addresses assets by file name, the web UI by UUID.** `GiteaIds` is
documented as one-way and lossy — a display surrogate that nothing is looked up
by — so an endpoint keyed on the numeric `id` it puts on the wire would be
unusable by a client reading that very field. Names are unique per release and
already restricted to a URL-safe charset, which makes them the natural key,
exactly as owner/name/number are elsewhere in the API. The web UI has the real
UUIDs in hand and uses them, and the JSON carries the UUID as `uuid` plus a
ready-made `browser_download_url`.
- **The web asset route puts the id first and the name last** (`…/releases/assets/{id}/{fileName}`).
Hanging the name directly off the tag route instead would collide with the greedy
match that slash-bearing tags require. Keying on the id keeps both routes
unambiguous while `curl -O` still saves a sensibly named file.
- **Every asset download is an attachment with `nosniff`.** The content type is
whatever the uploader's browser declared, so it can be `text/html`. Forcing
attachment disposition means it is never rendered on the instance's origin, which
closes stored XSS without having to police an allowlist of "safe" content types.
- **Raising `quarkus.http.limits.max-body-size` is a whole-instance change, so the
two in-memory uploads were guarded in the same step.** Asset uploads need a body
cap far above Quarkus's 10 MB default; the avatar and repository-image handlers
read the full body with `Files.readAllBytes` before checking their 2 MB limit,
which would have turned the higher cap into a memory-exhaustion vector. They now
check `FileUpload.size()` first.
- **Download counts cost one `UPDATE` per download.** Download counts are the main
thing people want to know about a published artifact; a single indexed row update
is accepted as the price.
## What works today
- Publish a release for an existing tag, or cut an annotated tag from a branch,
tag or commit while publishing.
- Markdown release notes (XSS-safe), pre-release flag, "Latest" badge that skips
pre-releases.
- Edit title/notes/pre-release; delete the release while keeping the tag.
- Source archives (`.zip`, `.tar.gz`) for any ref, visibility-guarded and
streamed, nested under a `<repo>-<ref>/` prefix.
- Sidebar entry with a release count on every repository page.
- Gitea-shaped REST: list, create, `latest`, get/PATCH/DELETE by tag, with
`zipball_url`/`tarball_url` pointing at the archive endpoint, and an `assets`
array on every release object.
- **Release assets**: attach a prebuilt file (one per request) from the release page
or over REST, list them with size and download count, download them, delete one or
all of them. Bytes stream to and from `gitshark.storage.release-assets`, capped by
`GITSHARK_MAX_ASSET_SIZE`. Deleting an asset, a release, or a repository removes
the stored files.
- CI can publish end to end with a personal access token: create the release, then
`POST …/releases/tags/{tag}/assets` with the artifact.
## What still needs to be implemented
- **Draft releases**, and with them a `draft` flag that is more than a constant.
- **Multi-file asset upload** — one file per request; the form has no multiple-file
input, and there is no batch REST call.
- **Asset checksums** — nothing is computed or displayed; publishers upload their
own `SHA256SUMS` file.
- **Orphaned-asset reconciliation** — nothing reconciles rows against files, and a
process killed mid-transaction can strand either side. Deletes remove the file
before the commit, so an interrupted delete leaves a **row whose file is gone**
(the download 404s, handled gracefully but visible to users); an upload copies
the bytes before the commit, so an interrupted upload leaves a **file with no
row** (silently wasted space). Both windows are narrow; neither is swept.
- **Asset content-type allowlist** — the declared multipart content type is stored
after a `strip()` with no further validation. Harmless while every download is an
attachment with `nosniff`, but worth revisiting if that ever changes.
- **MCP tools** for releases and their assets — the MCP surface mirrors the REST
API elsewhere and currently has no release tools.
- **Federation** — a release publishes no ActivityPub activity, so followers of a
repository learn about pushes but not about releases.
- **Auto-notes** — no "generate release notes from commits since the last tag".
- **Dashboard/notification integration** — releases contribute no
`NotificationSource` items.