Releases
Releases annotate git tags with a title, Markdown notes and a pre-release flag, expose source-code archives of any ref, and carry uploaded prebuilt files (APKs, JARs, installers) as release assets.
Notes, archives and tag creation need no configuration. Asset uploads add three properties and one filesystem store — see Asset storage and limits below.
Endpoints
| Method | Path | Access |
|---|---|---|
GET |
/repos/{owner}/{name}/releases |
Repository read |
GET |
/repos/{owner}/{name}/releases/new |
Repository write |
POST |
/repos/{owner}/{name}/releases |
Repository write |
GET |
/repos/{owner}/{name}/releases/tag/{tag} |
Repository read |
GET/POST |
/repos/{owner}/{name}/releases/tag/{tag}/edit |
Repository write |
POST |
/repos/{owner}/{name}/releases/tag/{tag}/delete |
Repository write |
GET |
/repos/{owner}/{name}/archive/{ref}.zip |
Repository read |
GET |
/repos/{owner}/{name}/archive/{ref}.tar.gz |
Repository read |
GET |
/api/v1/repos/{owner}/{name}/releases |
Repository read (token optional) |
POST |
/api/v1/repos/{owner}/{name}/releases |
Token + repository write |
GET |
/api/v1/repos/{owner}/{name}/releases/latest |
Repository read |
GET/PATCH/DELETE |
/api/v1/repos/{owner}/{name}/releases/tags/{tag} |
Read / token + write |
POST |
/repos/{owner}/{name}/releases/tag/{tag}/assets |
Repository write (multipart, field asset) |
GET |
/repos/{owner}/{name}/releases/assets/{id}/{fileName} |
Repository read |
POST |
/repos/{owner}/{name}/releases/assets/{id}/delete |
Repository write |
GET |
/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets |
Repository read |
POST |
/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets |
Token + write (multipart, field attachment) |
GET/DELETE |
/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets/{fileName} |
Read / token + write |
Write access follows the single access policy: personal-repo owner and
collaborators, organisation MEMBER and above. A private repository's release and
archive endpoints answer 404 for anyone without read access, so they never
confirm the repository exists.
Archives
GET …/archive/{ref}.zip|.tar.gz streams the tree of any ref (tag, branch or
commit id) straight out of the object database:
- Entries are nested under a single
<repo>-<ref>/directory; slashes in the ref collapse to dashes. - Both formats are produced by the JDK (
ZipOutputStream, ustar +GZIPOutputStream) — no extra archiving dependency, and no temporary files on disk. - Blobs are copied through, never buffered whole, so a large tree does not scale memory with repository size. There is no size cap and no rate limit — a public instance serving very large repositories should rate-limit this path at the reverse proxy, the same way a clone would be.
- Submodules are skipped (they have no content here); symlinks become regular files holding their target path.
Responses carry Content-Disposition: attachment and application/zip /
application/gzip. They are generated per request and are not cached.
Asset storage and limits
| Variable | Default | Purpose |
|---|---|---|
GITSHARK_RELEASE_ASSET_ROOT |
data/release-assets |
Directory holding the uploaded asset files |
GITSHARK_MAX_ASSET_SIZE |
200M |
Largest single asset upload; a larger file is rejected with 400 |
GITSHARK_MAX_BODY_SIZE |
210M |
Cap on any HTTP request body (quarkus.http.limits.max-body-size) |
GITSHARK_MAX_BODY_SIZE must stay above GITSHARK_MAX_ASSET_SIZE: the
request also carries the multipart envelope, and the body cap is enforced first —
a request over it is rejected at the HTTP layer with 413, before the
application can produce a readable error. The 10 MB headroom in the defaults is
there for that reason. Raising the asset limit means raising both together:
GITSHARK_MAX_ASSET_SIZE: 1G
GITSHARK_MAX_BODY_SIZE: 1100M
Note that the body cap replaces Quarkus's own default of 10 MB for every
request body on the instance. The two uploads that read a body into memory
(avatars, repository images) check the upload's reported length against their own
2 MB limit before reading it (ImageValidation.validateSize), so the higher cap
does not turn them into a memory-exhaustion vector.
Asset bytes are written to GITSHARK_RELEASE_ASSET_ROOT in a flat directory,
one file per asset, named by the asset's UUID. The uploader's file name is
validated against [A-Za-z0-9][A-Za-z0-9._+~-]{0,254}, stored in the database,
and used only for the Content-Disposition header — it never reaches a
filesystem path, so no upload can escape the store.
The web download addresses an asset by its UUID; the REST API addresses it by
file name within its release, because the numeric id in the JSON is a
one-way surrogate (see GiteaIds) that nothing can be looked up by. The real id
is also on the wire as uuid, and browser_download_url is the ready-made web
link.
Uploads are streamed: Quarkus buffers the multipart body to a temporary file and
git-shark copies it into the store, so a large asset costs disk and not heap.
Plan the volume for the sum of everything users attach — unlike git objects,
release assets do not deduplicate. Downloads are always sent as
Content-Disposition: attachment with X-Content-Type-Options: nosniff, so an
uploaded file's content type can never be rendered on the instance's origin, and
each download costs one indexed UPDATE on its counter.
This directory must be on a persistent volume; it is part of the inventory in Persistent data.
Tag creation
Publishing a release whose tag does not exist yet creates an annotated tag
in-core against the bare repository, authored by the publishing user (display
name + account email, falling back to <username>@localhost when the account has
no address). This is a real ref write in GITSHARK_STORAGE_ROOT, so it shows up
for every clone and push mirror afterwards. Existing tags are never moved or
rewritten, and deleting a release never deletes its tag.
Database
Two tables, added in V32__releases.sql and V33__release_assets.sql:
| Table | Columns | Notes |
|---|---|---|
releases |
id, repository_id, author_id, tag_name, title, body, commit_id, prerelease, created_at |
unique (repository_id, tag_name) — one release per tag; ON DELETE CASCADE from both repositories and users |
release_assets |
id, release_id, uploader_id, file_name, content_type, size_bytes, download_count, uploaded_at |
unique (release_id, file_name); ON DELETE CASCADE from releases and users. Asset bytes are not here — they are on the filesystem, named by id |
Release notes live in the database; the tag and its objects live in the git
repository; asset bytes live in GITSHARK_RELEASE_ASSET_ROOT. A restore
therefore needs all three stores in sync — see
Persistent data.
Deleting an asset, a release, or a whole repository removes the stored files in the same operation. There is no reconciliation job, and rows and files can drift apart in both directions if a process is killed mid-transaction:
- A row without its file. Every delete path runs inside one transaction with
the file removal before the commit, so a crash in that window rolls the row
back while the file is already gone. The asset stays listed and its download
answers
404. Find these by checking eachidfromrelease_assetsagainst the asset root. - A file without its row. An upload persists the row, copies the bytes, then
commits; a failure after the copy rolls the row back and leaves the file.
Find these by checking each file name in the asset root against
select id from release_assets;.
Both need a literal process kill in a narrow window, so neither is expected in normal operation — but only the second one wastes space silently; the first one is visible to users as a broken download.
Troubleshooting
| Symptom | Cause |
|---|---|
400 "does not exist — pick a target to create it from" |
The tag is unknown and no target branch was submitted. |
400 "already has a release" |
One release per tag; edit the existing one instead. |
400 "is not a valid tag name" |
git ref rules (no spaces, no .., no trailing /). |
Archive returns 404 |
The ref does not resolve, or the suffix is neither .zip nor .tar.gz. |
| Archive download stalls on a huge repository | Expected — it streams; put a proxy timeout/rate limit in front if this is abused. |
Upload fails with 413 and no error page |
Over GITSHARK_MAX_BODY_SIZE, which is enforced before the application sees the request. Raise both size variables. |
400 "File is too large" |
Under the body cap but over GITSHARK_MAX_ASSET_SIZE. |
400 "Use a file name of letters, digits, …" |
The asset name has a space or a directory in it; the user must rename before uploading. |
400 "already has a file named …" |
One file name per release; delete the old one first. |
Asset download returns 404 although the page lists the file |
The asset root is not the directory it was at upload time (missing or changed volume mount). Check GITSHARK_RELEASE_ASSET_ROOT against the actual mount. |
| The asset root grows unexpectedly | Nothing prunes it. Compare the total against select sum(size_bytes) from release_assets; — a large gap means orphaned files from interrupted uploads. |
# Releases
Releases annotate git tags with a title, Markdown notes and a pre-release flag,
expose source-code archives of any ref, and carry **uploaded prebuilt files**
(APKs, JARs, installers) as release assets.
Notes, archives and tag creation need **no configuration**. Asset uploads add
three properties and one filesystem store — see [Asset storage and
limits](#asset-storage-and-limits) below.
## Endpoints
| Method | Path | Access |
|---|---|---|
| `GET` | `/repos/{owner}/{name}/releases` | Repository read |
| `GET` | `/repos/{owner}/{name}/releases/new` | Repository write |
| `POST` | `/repos/{owner}/{name}/releases` | Repository write |
| `GET` | `/repos/{owner}/{name}/releases/tag/{tag}` | Repository read |
| `GET`/`POST` | `/repos/{owner}/{name}/releases/tag/{tag}/edit` | Repository write |
| `POST` | `/repos/{owner}/{name}/releases/tag/{tag}/delete` | Repository write |
| `GET` | `/repos/{owner}/{name}/archive/{ref}.zip` | Repository read |
| `GET` | `/repos/{owner}/{name}/archive/{ref}.tar.gz` | Repository read |
| `GET` | `/api/v1/repos/{owner}/{name}/releases` | Repository read (token optional) |
| `POST` | `/api/v1/repos/{owner}/{name}/releases` | Token + repository write |
| `GET` | `/api/v1/repos/{owner}/{name}/releases/latest` | Repository read |
| `GET`/`PATCH`/`DELETE` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}` | Read / token + write |
| `POST` | `/repos/{owner}/{name}/releases/tag/{tag}/assets` | Repository write (multipart, field `asset`) |
| `GET` | `/repos/{owner}/{name}/releases/assets/{id}/{fileName}` | Repository read |
| `POST` | `/repos/{owner}/{name}/releases/assets/{id}/delete` | Repository write |
| `GET` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets` | Repository read |
| `POST` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets` | Token + write (multipart, field `attachment`) |
| `GET`/`DELETE` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}/assets/{fileName}` | Read / token + write |
Write access follows the single access policy: personal-repo owner and
collaborators, organisation MEMBER and above. A private repository's release and
archive endpoints answer `404` for anyone without read access, so they never
confirm the repository exists.
## Archives
`GET …/archive/{ref}.zip|.tar.gz` streams the tree of any ref (tag, branch or
commit id) straight out of the object database:
- Entries are nested under a single `<repo>-<ref>/` directory; slashes in the ref
collapse to dashes.
- Both formats are produced by the JDK (`ZipOutputStream`, ustar + `GZIPOutputStream`) —
no extra archiving dependency, and no temporary files on disk.
- Blobs are copied through, never buffered whole, so a large tree does not scale
memory with repository size. There is **no size cap and no rate limit** — a
public instance serving very large repositories should rate-limit this path at
the reverse proxy, the same way a clone would be.
- Submodules are skipped (they have no content here); symlinks become regular
files holding their target path.
Responses carry `Content-Disposition: attachment` and `application/zip` /
`application/gzip`. They are generated per request and are not cached.
## Asset storage and limits
| Variable | Default | Purpose |
|---|---|---|
| `GITSHARK_RELEASE_ASSET_ROOT` | `data/release-assets` | Directory holding the uploaded asset files |
| `GITSHARK_MAX_ASSET_SIZE` | `200M` | Largest single asset upload; a larger file is rejected with `400` |
| `GITSHARK_MAX_BODY_SIZE` | `210M` | Cap on any HTTP request body (`quarkus.http.limits.max-body-size`) |
`GITSHARK_MAX_BODY_SIZE` must stay **above** `GITSHARK_MAX_ASSET_SIZE`: the
request also carries the multipart envelope, and the body cap is enforced first —
a request over it is rejected at the HTTP layer with **413**, before the
application can produce a readable error. The 10 MB headroom in the defaults is
there for that reason. Raising the asset limit means raising both together:
```yaml
GITSHARK_MAX_ASSET_SIZE: 1G
GITSHARK_MAX_BODY_SIZE: 1100M
```
Note that the body cap replaces Quarkus's own default of 10 MB for **every**
request body on the instance. The two uploads that read a body into memory
(avatars, repository images) check the upload's reported length against their own
2 MB limit before reading it (`ImageValidation.validateSize`), so the higher cap
does not turn them into a memory-exhaustion vector.
Asset bytes are written to `GITSHARK_RELEASE_ASSET_ROOT` in a **flat directory,
one file per asset, named by the asset's UUID**. The uploader's file name is
validated against `[A-Za-z0-9][A-Za-z0-9._+~-]{0,254}`, stored in the database,
and used only for the `Content-Disposition` header — it never reaches a
filesystem path, so no upload can escape the store.
The web download addresses an asset by its UUID; the REST API addresses it by
**file name within its release**, because the numeric `id` in the JSON is a
one-way surrogate (see `GiteaIds`) that nothing can be looked up by. The real id
is also on the wire as `uuid`, and `browser_download_url` is the ready-made web
link.
Uploads are streamed: Quarkus buffers the multipart body to a temporary file and
git-shark copies it into the store, so a large asset costs disk and not heap.
Plan the volume for the sum of everything users attach — unlike git objects,
release assets do not deduplicate. Downloads are always sent as
`Content-Disposition: attachment` with `X-Content-Type-Options: nosniff`, so an
uploaded file's content type can never be rendered on the instance's origin, and
each download costs one indexed `UPDATE` on its counter.
This directory must be on a persistent volume; it is part of the inventory in
[Persistent data](persistent-data.md).
## Tag creation
Publishing a release whose tag does not exist yet creates an **annotated tag**
in-core against the bare repository, authored by the publishing user (display
name + account email, falling back to `<username>@localhost` when the account has
no address). This is a real ref write in `GITSHARK_STORAGE_ROOT`, so it shows up
for every clone and push mirror afterwards. Existing tags are never moved or
rewritten, and deleting a release never deletes its tag.
## Database
Two tables, added in `V32__releases.sql` and `V33__release_assets.sql`:
| Table | Columns | Notes |
|---|---|---|
| `releases` | `id`, `repository_id`, `author_id`, `tag_name`, `title`, `body`, `commit_id`, `prerelease`, `created_at` | `unique (repository_id, tag_name)` — one release per tag; `ON DELETE CASCADE` from both `repositories` and `users` |
| `release_assets` | `id`, `release_id`, `uploader_id`, `file_name`, `content_type`, `size_bytes`, `download_count`, `uploaded_at` | `unique (release_id, file_name)`; `ON DELETE CASCADE` from `releases` and `users`. Asset **bytes** are not here — they are on the filesystem, named by `id` |
Release notes live in the database; the tag and its objects live in the git
repository; asset bytes live in `GITSHARK_RELEASE_ASSET_ROOT`. A restore
therefore needs all three stores in sync — see
[Persistent data](persistent-data.md).
Deleting an asset, a release, or a whole repository removes the stored files in
the same operation. There is no reconciliation job, and rows and files can drift
apart in **both** directions if a process is killed mid-transaction:
- **A row without its file.** Every delete path runs inside one transaction with
the file removal *before* the commit, so a crash in that window rolls the row
back while the file is already gone. The asset stays listed and its download
answers `404`. Find these by checking each `id` from `release_assets` against
the asset root.
- **A file without its row.** An upload persists the row, copies the bytes, then
commits; a failure after the copy rolls the row back and leaves the file.
Find these by checking each file name in the asset root against
`select id from release_assets;`.
Both need a literal process kill in a narrow window, so neither is expected in
normal operation — but only the second one wastes space silently; the first one
is visible to users as a broken download.
## Troubleshooting
| Symptom | Cause |
|---|---|
| `400` "does not exist — pick a target to create it from" | The tag is unknown and no target branch was submitted. |
| `400` "already has a release" | One release per tag; edit the existing one instead. |
| `400` "is not a valid tag name" | git ref rules (no spaces, no `..`, no trailing `/`). |
| Archive returns `404` | The ref does not resolve, or the suffix is neither `.zip` nor `.tar.gz`. |
| Archive download stalls on a huge repository | Expected — it streams; put a proxy timeout/rate limit in front if this is abused. |
| Upload fails with `413` and no error page | Over `GITSHARK_MAX_BODY_SIZE`, which is enforced before the application sees the request. Raise both size variables. |
| `400` "File is too large" | Under the body cap but over `GITSHARK_MAX_ASSET_SIZE`. |
| `400` "Use a file name of letters, digits, …" | The asset name has a space or a directory in it; the user must rename before uploading. |
| `400` "already has a file named …" | One file name per release; delete the old one first. |
| Asset download returns `404` although the page lists the file | The asset root is not the directory it was at upload time (missing or changed volume mount). Check `GITSHARK_RELEASE_ASSET_ROOT` against the actual mount. |
| The asset root grows unexpectedly | Nothing prunes it. Compare the total against `select sum(size_bytes) from release_assets;` — a large gap means orphaned files from interrupted uploads. |