gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

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}). 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.

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog