gitshark

Clone repository

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

← Commits

✨ (releases): Publish tags as releases with source archives

8a05e1f458e41732dd773e5178a5f0961033f51e · Michael Hainz · 2026-08-13T10:16:06Z

Changes

30 files changed, +2052 -8

MODIFY README.md +15 -4
diff --git a/README.md b/README.md
index f69def2..073b120 100644
--- a/README.md
+++ b/README.md
@@ -24,10 +24,18 @@
24 24 - public-key authentication only; keys managed per user in the UI
25 25 - the server binds an unprivileged port (default 2222); the port shown in URLs is set
26 26 separately via `GITSHARK_SSH_EXTERNAL_PORT` (default 22) to match your host mapping
27 -- Web UI: an auth-aware header nav (a "Log in" button for visitors; for signed-in users a top-level Following link plus an Account dropdown holding Profile, SSH keys, Access tokens, and Logout — a JS-free `<details>` menu), landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files; Markdown files render to HTML by default with a Rendered/Code toggle), a rendered README (commonmark-java with GFM tables, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests, plus Settings for the owner) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
27 +- Web UI: an auth-aware header nav (a "Log in" button for visitors; for signed-in users a top-level Following link plus an Account dropdown holding Profile, SSH keys, Access tokens, and Logout — a JS-free `<details>` menu), landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files; Markdown files render to HTML by default with a Rendered/Code toggle), a rendered README (commonmark-java with GFM tables, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Releases, Issues, Merge requests, plus Settings for the owner) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
28 28 - Per-repository issues: title, optional description (rendered as Markdown, XSS-safe), per-repo sequential number (`#1`, `#2`, …), an author, and an optional assignee (any local user, set by username; blank clears it); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page; title and description can be edited afterwards via an "Edit issue" page. Issue pages are addressed by number (`…/issues/1`); old UUID URLs redirect permanently to the number form
29 29 - Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
30 30 - Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
31 +- **Releases** — publish a git tag as a release with a title, Markdown release notes (XSS-safe) and a
32 + pre-release flag, from a dedicated "New release" page; the tag can be cut from a branch, tag or commit
33 + while publishing (annotated tag written in-core, no working tree). Releases are listed newest-first with a
34 + **Latest** badge on the newest non-prerelease, are addressed by tag (`…/releases/tag/v1.0.0`), and can be
35 + edited or deleted by the owner and collaborators — deleting a release never deletes its git tag. Every
36 + release links source archives, served for any ref at `…/archive/{ref}.zip` and `…/archive/{ref}.tar.gz`
37 + (streamed from the object database, JDK-only zip/ustar writer, nested under a `<repo>-<ref>/` prefix).
38 + Guide: [releases](docs/users/releases.md)
31 39 - Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), an author, and an optional assignee and reviewer (any local user, set by username from a GitHub-style picker that suggests the repo owner, its collaborators, and the repository's top commit authors; blank clears); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches. Merge-request pages are addressed by number (`…/merge-requests/1`, matching the issue URL scheme; the REST API exposes the same objects as Gitea pull requests under `…/pulls/1`); old UUID URLs redirect permanently to the number form
32 40 - Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and left sidebar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
33 41 - Dashboard notifications: the signed-in home page (`/`) surfaces the open issues and merge requests you are involved in — ones you authored, are assigned to, or (for merge requests) are asked to review — each linking straight to the item and labelled with its repository; items in a repository you can no longer read never appear. Built on a pluggable `NotificationSource` aggregation, so future item types can contribute without touching the dashboard
@@ -67,9 +75,9 @@
67 75 git-over-HTTP (`Authorization: Bearer <token>` or the Gitea-style `Authorization: token <token>`),
68 76 auto-documented via OpenAPI/Swagger UI. The contract is **Gitea-compatible**, so Gitea tooling
69 77 (Renovate, `tea`) can drive it (see below)
70 -- **MCP server** at `/mcp` (Streamable HTTP), exposing the same feature set as the REST API as
78 +- **MCP server** at `/mcp` (Streamable HTTP), exposing most of the REST API surface as
71 79 MCP tools so an AI client can manage repositories, issues, merge requests, and MR line-comments
72 - (see below)
80 + (releases are REST-only so far; see below)
73 81 - **Push mirrors** — the repository owner can replicate a repository to external remotes on
74 82 every push (`git push --mirror` semantics, all refs including deletions), over HTTPS with
75 83 stored credentials or over SSH with a server-generated Ed25519 deploy key. Syncs run
@@ -162,6 +170,9 @@
162 170 | POST | `/api/v1/repos/{owner}/{name}/pulls/{number}/merge` | Merge |
163 171 | GET, POST | `/api/v1/repos/{owner}/{name}/pulls/{number}/comments` | List / add line-level review comments (any reader may comment) |
164 172 | DELETE | `/api/v1/repos/{owner}/{name}/pulls/{number}/comments/{commentId}` | Delete a comment (author, repo owner, or collaborator) |
173 +| GET, POST | `/api/v1/repos/{owner}/{name}/releases` | List / publish releases (`{tag_name, target_commitish, name, body, prerelease}`) |
174 +| GET | `/api/v1/repos/{owner}/{name}/releases/latest` | Newest non-prerelease release (`404` when there is none) |
175 +| GET, PATCH, DELETE | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}` | Get / edit / delete a release by tag (the git tag is kept) |
165 176 | GET | `/api/v1/repos/{owner}/{name}/labels` | Labels (empty — no label model yet) |
166 177 | GET | `/api/v1/repos/{owner}/{name}/commits/{ref}/status` | Combined commit status (all-clear stub — no status store yet) |
167 178 | GET, POST | `/api/v1/repos/{owner}/{name}/commits/{ref}/statuses` · `/statuses/{sha}` | List statuses (empty) / post a status (echoed, not persisted; needs write) |
@@ -268,7 +279,7 @@
268 279
269 280 | Store | What |
270 281 |---|---|
271 -| PostgreSQL | `users`, `repositories` (metadata), `repository_pins` (per-user pinned repositories), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used), push-mirror tables (`push_mirror` with AES-GCM-encrypted secrets, `mirror_sync` queue), federation tables (`federation_keys`, `remote_actors`, `repository_followers`, `remote_follows`, `remote_user_follows`, `received_pushes`, `federation_outbox`, `federation_inbox`, `federation_delivery`) |
282 +| PostgreSQL | `users`, `repositories` (metadata), `repository_pins` (per-user pinned repositories), `ssh_keys` (public keys + fingerprints), `access_tokens` (SHA-256 hashes, labels, last-used), `releases` (title, notes, pre-release flag and published commit per tag), push-mirror tables (`push_mirror` with AES-GCM-encrypted secrets, `mirror_sync` queue), federation tables (`federation_keys`, `remote_actors`, `repository_followers`, `remote_follows`, `remote_user_follows`, `received_pushes`, `federation_outbox`, `federation_inbox`, `federation_delivery`) |
272 283 | Filesystem (`GITSHARK_STORAGE_ROOT`) | Bare Git repositories |
273 284 | Filesystem (`GITSHARK_AVATAR_ROOT`) | Uploaded profile pictures, one file per user (UUID-named) |
274 285 | Filesystem (`GITSHARK_SSH_HOST_KEY`) | SSH host key |
MODIFY docs/README.md +8 -0
diff --git a/docs/README.md b/docs/README.md
index 8a0c426..033bfcd 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -15,6 +15,8 @@
15 15 Commits tab to see the diff it introduced, including root commits.
16 16 - **[Forking](users/forking.md)** — make your own copy of any repository you can
17 17 read, what a fork carries, and the one-fork-per-name rule.
18 +- **[Releases](users/releases.md)** — publish a tag with title, Markdown notes
19 + and source archives, the Latest badge, pre-releases, and today's limits.
18 20 - **[Search](users/search.md)** — the header search box: case-insensitive
19 21 substring search over repositories and people, and how visibility is honored.
20 22 - **[Profile settings](users/profile.md)** — change your username and display
@@ -46,6 +48,9 @@
46 48 access.
47 49 - **[Forks](admins/forking.md)** — the fork endpoints, the `parent_repo_id`
48 50 column and its `ON DELETE SET NULL` semantics, and storage implications.
51 +- **[Releases](admins/releases.md)** — the release and archive endpoints, tag
52 + creation on publish, the `releases` table, and archive streaming behavior
53 + (no configuration needed).
49 54 - **[Search](admins/search.md)** — the `/search` and `/api/v1/search` endpoints,
50 55 JSON shape, matching semantics, visibility enforcement (no configuration).
51 56 - **[Persistent data](admins/persistent-data.md)** — every store that must survive
@@ -82,6 +87,9 @@
82 87 - **[Repository images](maintainers/repo-images.md)** — per-repository image
83 88 storage, how it reuses the avatar machinery, owner-avatar fallback, and the
84 89 visibility-guarded serving endpoint.
90 +- **[Releases architecture](maintainers/releases.md)** — releases as an
91 + annotation over tags, in-core tag creation, the dependency-free archive
92 + writer, and the works/gaps list.
85 93 - **[ForgeFed architecture](maintainers/forgefed.md)** — how federation is
86 94 implemented, the decisions behind it, what works and what is still missing.
87 95 - **[Federated collaboration roadmap](maintainers/federation-roadmap.md)** — the
MODIFY docs/admins/persistent-data.md +1 -1
diff --git a/docs/admins/persistent-data.md b/docs/admins/persistent-data.md
index 5ce7576..06f1fde 100644
--- a/docs/admins/persistent-data.md
+++ b/docs/admins/persistent-data.md
@@ -12,7 +12,7 @@
12 12
13 13 | Store | Configured by | Default (in container) | Contents | If you lose it |
14 14 |---|---|---|---|---|
15 -| PostgreSQL | `QUARKUS_DATASOURCE_*` | external service | Users, repository records, issues, merge requests, comments (issue discussion in `issue_comments`; merge-request discussion and per-line review comments share `merge_request_comments`), SSH public keys, access-token hashes, push-mirror and federation state, CI runner registration-token and runner records (hashed secrets). Also each avatar's and repository image's content type and update timestamp — but **not** the image bytes. | Everything except the raw git objects and images. Total loss. |
15 +| PostgreSQL | `QUARKUS_DATASOURCE_*` | external service | Users, repository records, issues, merge requests, comments (issue discussion in `issue_comments`; merge-request discussion and per-line review comments share `merge_request_comments`), releases (title, notes and pre-release flag per tag — the tags themselves live in git), SSH public keys, access-token hashes, push-mirror and federation state, CI runner registration-token and runner records (hashed secrets). Also each avatar's and repository image's content type and update timestamp — but **not** the image bytes. | Everything except the raw git objects and images. Total loss. |
16 16 | Repositories | `GITSHARK_STORAGE_ROOT` | `data/repositories` | The bare git repositories (all commits, branches, tags). | All hosted code. The DB rows survive but point at nothing. |
17 17 | Avatars | `GITSHARK_AVATAR_ROOT` | `data/avatars` | Uploaded profile pictures, one file per user, named by user UUID. | Profile pictures render as broken images (the DB still says the user has one, but `GET /users/{username}/avatar` returns 404). Users must re-upload. |
18 18 | Repository images | `GITSHARK_REPO_IMAGE_ROOT` | `data/repo-images` | Uploaded per-repository images, one file per repository, named by repository UUID. | Repository images render as broken images (the DB still says the repo has one, but `GET /repos/{owner}/{name}/image` returns 404); repos fall back to the owner's avatar once the DB row is also cleared. Owners must re-upload. |
ADD docs/admins/releases.md +77 -0
diff --git a/docs/admins/releases.md b/docs/admins/releases.md
new file mode 100644
index 0000000..41b9e95
--- /dev/null
+++ b/docs/admins/releases.md
@@ -0,0 +1,77 @@
1 +# Releases
2 +
3 +Releases annotate git tags with a title, Markdown notes and a pre-release flag,
4 +and expose source-code archives of any ref. **No configuration is needed** — the
5 +feature is always on and adds no `GITSHARK_*` property.
6 +
7 +## Endpoints
8 +
9 +| Method | Path | Access |
10 +|---|---|---|
11 +| `GET` | `/repos/{owner}/{name}/releases` | Repository read |
12 +| `GET` | `/repos/{owner}/{name}/releases/new` | Repository write |
13 +| `POST` | `/repos/{owner}/{name}/releases` | Repository write |
14 +| `GET` | `/repos/{owner}/{name}/releases/tag/{tag}` | Repository read |
15 +| `GET`/`POST` | `/repos/{owner}/{name}/releases/tag/{tag}/edit` | Repository write |
16 +| `POST` | `/repos/{owner}/{name}/releases/tag/{tag}/delete` | Repository write |
17 +| `GET` | `/repos/{owner}/{name}/archive/{ref}.zip` | Repository read |
18 +| `GET` | `/repos/{owner}/{name}/archive/{ref}.tar.gz` | Repository read |
19 +| `GET` | `/api/v1/repos/{owner}/{name}/releases` | Repository read (token optional) |
20 +| `POST` | `/api/v1/repos/{owner}/{name}/releases` | Token + repository write |
21 +| `GET` | `/api/v1/repos/{owner}/{name}/releases/latest` | Repository read |
22 +| `GET`/`PATCH`/`DELETE` | `/api/v1/repos/{owner}/{name}/releases/tags/{tag}` | Read / token + write |
23 +
24 +Write access follows the single access policy: personal-repo owner and
25 +collaborators, organisation MEMBER and above. A private repository's release and
26 +archive endpoints answer `404` for anyone without read access, so they never
27 +confirm the repository exists.
28 +
29 +## Archives
30 +
31 +`GET …/archive/{ref}.zip|.tar.gz` streams the tree of any ref (tag, branch or
32 +commit id) straight out of the object database:
33 +
34 +- Entries are nested under a single `<repo>-<ref>/` directory; slashes in the ref
35 + collapse to dashes.
36 +- Both formats are produced by the JDK (`ZipOutputStream`, ustar + `GZIPOutputStream`) —
37 + no extra archiving dependency, and no temporary files on disk.
38 +- Blobs are copied through, never buffered whole, so a large tree does not scale
39 + memory with repository size. There is **no size cap and no rate limit** — a
40 + public instance serving very large repositories should rate-limit this path at
41 + the reverse proxy, the same way a clone would be.
42 +- Submodules are skipped (they have no content here); symlinks become regular
43 + files holding their target path.
44 +
45 +Responses carry `Content-Disposition: attachment` and `application/zip` /
46 +`application/gzip`. They are generated per request and are not cached.
47 +
48 +## Tag creation
49 +
50 +Publishing a release whose tag does not exist yet creates an **annotated tag**
51 +in-core against the bare repository, authored by the publishing user (display
52 +name + account email, falling back to `<username>@localhost` when the account has
53 +no address). This is a real ref write in `GITSHARK_STORAGE_ROOT`, so it shows up
54 +for every clone and push mirror afterwards. Existing tags are never moved or
55 +rewritten, and deleting a release never deletes its tag.
56 +
57 +## Database
58 +
59 +One table, added in `V32__releases.sql`:
60 +
61 +| Table | Columns | Notes |
62 +|---|---|---|
63 +| `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` |
64 +
65 +Release notes live in the database; the tag and its objects live in the git
66 +repository. A restore therefore needs both stores in sync — see
67 +[Persistent data](persistent-data.md).
68 +
69 +## Troubleshooting
70 +
71 +| Symptom | Cause |
72 +|---|---|
73 +| `400` "does not exist — pick a target to create it from" | The tag is unknown and no target branch was submitted. |
74 +| `400` "already has a release" | One release per tag; edit the existing one instead. |
75 +| `400` "is not a valid tag name" | git ref rules (no spaces, no `..`, no trailing `/`). |
76 +| Archive returns `404` | The ref does not resolve, or the suffix is neither `.zip` nor `.tar.gz`. |
77 +| Archive download stalls on a huge repository | Expected — it streams; put a proxy timeout/rate limit in front if this is abused. |
MODIFY docs/maintainers/gitea-api.md +10 -0
diff --git a/docs/maintainers/gitea-api.md b/docs/maintainers/gitea-api.md
index 8d2a02d..e6a334e 100644
--- a/docs/maintainers/gitea-api.md
+++ b/docs/maintainers/gitea-api.md
@@ -30,6 +30,7 @@
30 30 | Version probe | `VersionApiResource` | `GET /api/v1/version`; string from `gitshark.gitea-api.version` |
31 31 | Repositories | `RepositoryApiResource` | Gitea repository object incl. `owner`, `full_name`, `default_branch`, `clone_url`, `html_url`, `permissions`, merge flags |
32 32 | Pulls | `PullApiResource` | Merge requests as Gitea pull requests (list/create/get/PATCH/merge + line-review comments); domain stays `MergeRequest*` |
33 +| Releases | `ReleaseApiResource` | Gitea release object addressed by tag; `draft` constant false, `zipball_url`/`tarball_url` point at git-shark's own `…/archive/{ref}.zip|.tar.gz` |
33 34 | Labels | `LabelApiResource` | Always `[]` — no label model yet |
34 35 | Statuses | `CommitStatusApiResource` | All-clear combined status + echoing `POST /statuses/{sha}`; no status store yet |
35 36 | User | `UserApiResource` | Self identity in Gitea user shape |
@@ -88,6 +89,13 @@
88 89 merge commits). List supports `?state=open|closed|all` and pagination (page
89 90 size capped at 50 so Renovate's paging terminates). The line-review comments
90 91 are git-shark's own feature, kept under `pulls/{number}/comments`.
92 +- `releases` resource (`/api/v1/repos/{owner}/{name}/releases`) — list, create
93 + (`{tag_name, target_commitish, name, body, prerelease}`; a missing tag is cut
94 + from `target_commitish`), `GET latest` (newest non-prerelease, else `404`), and
95 + `GET`/`PATCH`/`DELETE tags/{tag}` with the tag matched greedily for slashes.
96 + `draft` is always false and `published_at` mirrors `created_at`; release assets
97 + are not implemented, so only the source-archive URLs are exposed. See
98 + [Releases architecture](releases.md).
91 99 - `GET labels` → `[]` (no label model yet; Renovate skips labels when empty).
92 100 - Commit-status stubs: `GET commits/{ref}/status` reports an all-clear combined
93 101 status, `GET commits/{ref}/statuses` is empty, and `POST statuses/{sha}` echoes
@@ -104,6 +112,8 @@
104 112 priority).
105 113 - Issue open/closed mapping + issue-comment REST endpoints (dependency dashboard);
106 114 deferred — run Renovate with `dependencyDashboard: false`.
115 +- Release **assets** (`/releases/{id}/assets`) and draft releases; see the gap
116 + list in [Releases architecture](releases.md).
107 117
108 118 ## Validation
109 119
ADD docs/maintainers/releases.md +97 -0
diff --git a/docs/maintainers/releases.md b/docs/maintainers/releases.md
new file mode 100644
index 0000000..acfb3f1
--- /dev/null
+++ b/docs/maintainers/releases.md
@@ -0,0 +1,97 @@
1 +# Releases architecture
2 +
3 +Releases are a thin database annotation over git tags, plus an archive writer.
4 +The tag stays the source of truth for *what* was released; the `releases` row only
5 +adds *how it is presented* (title, notes, pre-release flag) and remembers which
6 +commit the tag resolved to when it was published.
7 +
8 +## Component map
9 +
10 +| Concern | Type | Notes |
11 +|---|---|---|
12 +| 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 |
13 +| 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 |
14 +| Tag writes | `git.GitTagService` | `resolveCommit` (peels annotated tags), `exists`, `createTag` — annotated tag written in-core with `TagBuilder` + `RefUpdate`, no working tree |
15 +| Archives | `git.GitArchiveService` | `Format.ZIP`/`TAR_GZ`; streams a ref's tree into an `OutputStream` |
16 +| Web UI | `web.ReleaseResource` + `templates/ReleaseResource/*` | List, detail (Markdown notes via the shared `Markdown` renderer), new/edit forms |
17 +| Archive endpoint | `web.RepositoryResource#archive` | `GET …/archive/{ref}.zip|.tar.gz`, visibility-guarded, `StreamingOutput` |
18 +| REST | `api.ReleaseApiResource` + `ApiModels.ReleaseView`/`NewRelease`/`ReleaseEdit` | Gitea-shaped release contract, addressed by tag |
19 +| Navigation | `web.RepoNav#releaseCount` (built in `RepoNavService`) | Sidebar entry between Tags and Issues |
20 +
21 +## Data flow
22 +
23 +**Publish.** `ReleaseResource` (or `ReleaseApiResource`) → `ReleaseService.create`
24 +→ authorize → reject a blank tag name or a tag that already has a release → if the
25 +tag exists, resolve it to a commit; otherwise `GitTagService.createTag` cuts an
26 +annotated tag on the target and returns the tagged commit → persist the row →
27 +`303` to `…/releases/tag/{tag}`.
28 +
29 +**Read.** The list page reads `ReleaseService.list` plus `findLatest` (newest
30 +release with `prerelease = false`) to place the **Latest** badge. The detail page
31 +renders `body` through the shared XSS-safe `Markdown` renderer and links the two
32 +archive URLs and the commit.
33 +
34 +**Download.** `RepositoryResource#archive` picks the format from the URL suffix,
35 +resolves the ref (404 if unknown), and hands a `StreamingOutput` to
36 +`GitArchiveService`, which walks the commit's tree recursively and copies each
37 +blob straight from the object database.
38 +
39 +## Decisions
40 +
41 +- **The tag is the key, not a surrogate number.** Issues and merge requests carry
42 + per-repo numbers; a release is *about* a tag, and Gitea's API addresses releases
43 + by tag too (`/releases/tags/{tag}`). A `unique (repository_id, tag_name)`
44 + constraint enforces one release per tag, and the UI routes are
45 + `…/releases/tag/{tag}` with a greedy match so slash-bearing tags work.
46 +- **Deleting a release keeps the tag.** Releases are presentation metadata;
47 + deleting one must never rewrite history or break clones that fetched the tag.
48 + Deleting a *tag* is deliberately not offered anywhere in the UI.
49 +- **Tag creation is in-core.** Cutting the tag while publishing (Gitea's
50 + `target_commitish`) means the common "tag and release in one step" flow needs no
51 + local clone. It reuses the same bare-repo, no-working-tree approach
52 + `GitMergeService` established for merges, with `RefUpdate` expecting a
53 + zero-id old value so a concurrent creation loses rather than overwrites. The
54 + ref write happens inside the publishing transaction, so a DB failure afterwards
55 + can leave an orphan tag — harmless, because publishing again simply adopts the
56 + existing tag instead of cutting a new one.
57 +- **`commitId` is stored, the rest is read live.** The tag could later be deleted
58 + or moved by a force push; the release page must still be able to name the exact
59 + revision that was published. Everything else (tree, archives) is read live from
60 + git and never duplicated.
61 +- **Archives use the JDK only.** `org.eclipse.jgit.archive` would pull in
62 + commons-compress purely for tar; a ~60-line ustar header writer plus
63 + `GZIPOutputStream` and `ZipOutputStream` keeps the native image's dependency
64 + surface unchanged. Long paths use the ustar `prefix` field; a path that fits
65 + neither field fails loudly instead of being silently truncated.
66 +- **Blobs are streamed, not buffered.** `ObjectLoader.copyTo` writes into the
67 + response, so archive memory does not scale with repository size — the same
68 + reason the raw-blob endpoint streams.
69 +- **No drafts.** Gitea's `draft` is reported as a constant `false`. A draft is a
70 + second visibility rule over the same row, and nothing needs it yet.
71 +
72 +## What works today
73 +
74 +- Publish a release for an existing tag, or cut an annotated tag from a branch,
75 + tag or commit while publishing.
76 +- Markdown release notes (XSS-safe), pre-release flag, "Latest" badge that skips
77 + pre-releases.
78 +- Edit title/notes/pre-release; delete the release while keeping the tag.
79 +- Source archives (`.zip`, `.tar.gz`) for any ref, visibility-guarded and
80 + streamed, nested under a `<repo>-<ref>/` prefix.
81 +- Sidebar entry with a release count on every repository page.
82 +- Gitea-shaped REST: list, create, `latest`, get/PATCH/DELETE by tag, with
83 + `zipball_url`/`tarball_url` pointing at the archive endpoint.
84 +
85 +## What still needs to be implemented
86 +
87 +- **Release assets** — uploaded files per release (`/releases/{id}/assets` in
88 + Gitea), with a storage root, a size cap and a download endpoint. The archive
89 + links are the only downloads today.
90 +- **Draft releases**, and with them a `draft` flag that is more than a constant.
91 +- **MCP tools** for releases — the MCP surface mirrors the REST API elsewhere and
92 + currently has no release tools.
93 +- **Federation** — a release publishes no ActivityPub activity, so followers of a
94 + repository learn about pushes but not about releases.
95 +- **Auto-notes** — no "generate release notes from commits since the last tag".
96 +- **Dashboard/notification integration** — releases contribute no
97 + `NotificationSource` items.
ADD docs/users/releases.md +68 -0
diff --git a/docs/users/releases.md b/docs/users/releases.md
new file mode 100644
index 0000000..2461d3e
--- /dev/null
+++ b/docs/users/releases.md
@@ -0,0 +1,68 @@
1 +# Releases
2 +
3 +A **release** turns a git tag into something people can read and download: a
4 +title, Markdown release notes, and source-code archives of exactly that tag.
5 +Every repository has a **Releases** section in its left sidebar, next to Tags.
6 +
7 +Releases annotate tags — they never change history. Deleting a release keeps the
8 +tag it was published from.
9 +
10 +## Who can do what
11 +
12 +| Action | Who |
13 +|---|---|
14 +| Browse releases, read notes, download archives | Anyone who can read the repository |
15 +| Publish, edit, delete a release | The repository owner, its collaborators, and organisation members |
16 +
17 +A private repository's releases (and its archives) are invisible to everyone
18 +without read access — the pages answer `404`, not `403`.
19 +
20 +## Publishing a release
21 +
22 +1. Open **Releases → New release**.
23 +2. **Tag** — type the tag name, e.g. `v1.0.0`. The field suggests tags that
24 + already exist and have no release yet.
25 +3. **Create the tag from** — pick a branch when the tag does not exist yet.
26 + git-shark then creates an annotated tag on that branch's current commit as
27 + part of publishing. When the tag already exists, this choice is ignored.
28 +4. **Title** — optional; defaults to the tag name.
29 +5. **Release notes** — optional Markdown (headings, lists, tables, links).
30 + Embedded raw HTML is escaped, so notes can never inject markup into the page.
31 +6. **This is a pre-release** — tick for a release candidate or beta.
32 +
33 +Publishing takes you straight to the new release page.
34 +
35 +## The release page
36 +
37 +- The **Latest** badge marks the newest release that is *not* a pre-release.
38 + Pre-releases are listed normally but never become "latest".
39 +- **Source code (zip)** and **Source code (tar.gz)** download the repository tree
40 + at that tag. Both unpack into a single `<repo>-<tag>/` directory.
41 +- The commit link opens the exact revision the tag pointed at when the release
42 + was published.
43 +- Owners and collaborators get **Edit** and **Delete** buttons.
44 +
45 +Editing changes the title, the notes and the pre-release flag. The tag and its
46 +commit stay fixed — publish a new release to ship a new version.
47 +
48 +## Downloading without the UI
49 +
50 +The archive URLs work for any ref, not just released tags:
51 +
52 +```
53 +https://<host>/repos/<owner>/<repo>/archive/<ref>.zip
54 +https://<host>/repos/<owner>/<repo>/archive/<ref>.tar.gz
55 +```
56 +
57 +`<ref>` may be a tag, a branch, or a commit id. For a private repository, the
58 +usual login (browser session) is required.
59 +
60 +## Limits today
61 +
62 +- **No file attachments.** A release carries notes and the source archives; you
63 + cannot upload binaries or checksums yet.
64 +- **No drafts.** A published release is immediately visible to everyone who can
65 + read the repository.
66 +- Releases are not announced to federation followers.
67 +
68 +Related: [Comments](comments.md) · [Repository visibility](repository-visibility.md)
MODIFY src/main/java/de/workaround/api/ApiModels.java +36 -0
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index b1641ee..d7133ad 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -9,6 +9,7 @@
9 9 import de.workaround.model.IssueComment;
10 10 import de.workaround.model.MergeRequest;
11 11 import de.workaround.model.MergeRequestComment;
12 +import de.workaround.model.Release;
12 13 import de.workaround.model.Repository;
13 14 import de.workaround.model.User;
14 15
@@ -188,6 +189,27 @@
188 189 {
189 190 }
190 191
192 + /**
193 + * A release in the Gitea contract. git-shark has no draft releases, so {@code draft} is always false, and
194 + * releases are never edited into existence later, so {@code published_at} mirrors {@code created_at}.
195 + * {@code target_commitish} reports the commit the tag resolved to when the release was published.
196 + * The archive URLs point at git-shark's own {@code /repos/{owner}/{name}/archive/{ref}.zip|.tar.gz}.
197 + */
198 + public record ReleaseView(long id, @JsonProperty("tag_name") String tagName,
199 + @JsonProperty("target_commitish") String targetCommitish, String name, String body, boolean draft,
200 + boolean prerelease, @JsonProperty("created_at") Instant createdAt,
201 + @JsonProperty("published_at") Instant publishedAt, PersonView author,
202 + @JsonProperty("zipball_url") String zipballUrl, @JsonProperty("tarball_url") String tarballUrl)
203 + {
204 + public static ReleaseView of(Release release, String archiveBase)
205 + {
206 + return new ReleaseView(GiteaIds.of(release.id), release.tagName, release.commitId, release.title,
207 + release.body, false, release.prerelease, release.createdAt, release.createdAt,
208 + PersonView.of(release.author), archiveBase + release.tagName + ".zip",
209 + archiveBase + release.tagName + ".tar.gz");
210 + }
211 + }
212 +
191 213 // -- requests --
192 214
193 215 public record NewRepository(String name, Repository.Visibility visibility, String description)
@@ -245,4 +267,18 @@
245 267 public record NewComment(String filePath, int oldLine, int newLine, String body)
246 268 {
247 269 }
270 +
271 + /**
272 + * Gitea release-creation payload. {@code target_commitish} names the branch, tag or commit a missing tag
273 + * is cut from; it is ignored when the tag already exists. git-shark accepts no {@code draft}.
274 + */
275 + public record NewRelease(@JsonProperty("tag_name") String tagName,
276 + @JsonProperty("target_commitish") String targetCommitish, String name, String body, boolean prerelease)
277 + {
278 + }
279 +
280 + /** Gitea release edit: any null field is left unchanged. The tag itself can never be edited. */
281 + public record ReleaseEdit(String name, String body, Boolean prerelease)
282 + {
283 + }
248 284 }
ADD src/main/java/de/workaround/api/ReleaseApiResource.java +145 -0
diff --git a/src/main/java/de/workaround/api/ReleaseApiResource.java b/src/main/java/de/workaround/api/ReleaseApiResource.java
new file mode 100644
index 0000000..aeee78c
--- /dev/null
+++ b/src/main/java/de/workaround/api/ReleaseApiResource.java
@@ -0,0 +1,145 @@
1 +package de.workaround.api;
2 +
3 +import java.util.List;
4 +
5 +import de.workaround.git.AccessPolicy;
6 +import de.workaround.git.GitRepositoryService;
7 +import de.workaround.git.ReleaseService;
8 +import de.workaround.model.Release;
9 +import de.workaround.model.Repository;
10 +import de.workaround.model.User;
11 +import jakarta.inject.Inject;
12 +import jakarta.transaction.Transactional;
13 +import jakarta.ws.rs.Consumes;
14 +import jakarta.ws.rs.DELETE;
15 +import jakarta.ws.rs.GET;
16 +import jakarta.ws.rs.NotFoundException;
17 +import jakarta.ws.rs.PATCH;
18 +import jakarta.ws.rs.POST;
19 +import jakarta.ws.rs.Path;
20 +import jakarta.ws.rs.PathParam;
21 +import jakarta.ws.rs.Produces;
22 +import jakarta.ws.rs.core.Context;
23 +import jakarta.ws.rs.core.MediaType;
24 +import jakarta.ws.rs.core.Response;
25 +import jakarta.ws.rs.core.UriInfo;
26 +
27 +/**
28 + * JSON REST access to a repository's releases under {@code /api/v1/repos/{owner}/{name}/releases}, shaped
29 + * like Gitea's release API. Releases are addressed by their tag. Listing/reading follow repository
30 + * read-visibility; creating, editing and deleting require a token and write access (enforced by
31 + * {@link ReleaseService}). Draft releases do not exist in git-shark.
32 + */
33 +@Path("/api/v1/repos/{owner}/{name}/releases")
34 +@Produces(MediaType.APPLICATION_JSON)
35 +public class ReleaseApiResource
36 +{
37 + @Inject
38 + GitRepositoryService repositories;
39 +
40 + @Inject
41 + ReleaseService releases;
42 +
43 + @Inject
44 + AccessPolicy accessPolicy;
45 +
46 + @Inject
47 + ApiPrincipal principal;
48 +
49 + @Context
50 + UriInfo uriInfo;
51 +
52 + @GET
53 + public List<ApiModels.ReleaseView> list(@PathParam("owner") String owner, @PathParam("name") String name)
54 + {
55 + Repository repo = requireReadable(owner, name);
56 + String archiveBase = archiveBase(repo);
57 + return releases.list(repo).stream().map(release -> ApiModels.ReleaseView.of(release, archiveBase)).toList();
58 + }
59 +
60 + @POST
61 + @Consumes(MediaType.APPLICATION_JSON)
62 + public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
63 + ApiModels.NewRelease request)
64 + {
65 + User user = principal.require();
66 + Repository repo = requireReadable(owner, name);
67 + Release release = releases.create(user, repo, request.tagName(), request.targetCommitish(), request.name(),
68 + request.body(), request.prerelease());
69 + return Response.status(Response.Status.CREATED)
70 + .entity(ApiModels.ReleaseView.of(release, archiveBase(repo)))
71 + .build();
72 + }
73 +
74 + /** The newest non-prerelease release, or 404 when the repository has none. */
75 + @GET
76 + @Path("latest")
77 + public ApiModels.ReleaseView latest(@PathParam("owner") String owner, @PathParam("name") String name)
78 + {
79 + Repository repo = requireReadable(owner, name);
80 + Release release = releases.findLatest(repo).orElseThrow(NotFoundException::new);
81 + return ApiModels.ReleaseView.of(release, archiveBase(repo));
82 + }
83 +
84 + @GET
85 + @Path("tags/{tag:.+}")
86 + public ApiModels.ReleaseView get(@PathParam("owner") String owner, @PathParam("name") String name,
87 + @PathParam("tag") String tag)
88 + {
89 + Repository repo = requireReadable(owner, name);
90 + return ApiModels.ReleaseView.of(require(repo, tag), archiveBase(repo));
91 + }
92 +
93 + @PATCH
94 + @Path("tags/{tag:.+}")
95 + @Consumes(MediaType.APPLICATION_JSON)
96 + @Transactional
97 + public ApiModels.ReleaseView update(@PathParam("owner") String owner, @PathParam("name") String name,
98 + @PathParam("tag") String tag, ApiModels.ReleaseEdit request)
99 + {
100 + User user = principal.require();
101 + Repository repo = requireReadable(owner, name);
102 + Release release = require(repo, tag);
103 + // null means "leave unchanged", so every field falls back to what the release already carries
104 + releases.update(user, release, request.name() == null ? release.title : request.name(),
105 + request.body() == null ? release.body : request.body(),
106 + request.prerelease() == null ? release.prerelease : request.prerelease());
107 + return ApiModels.ReleaseView.of(require(repo, tag), archiveBase(repo));
108 + }
109 +
110 + @DELETE
111 + @Path("tags/{tag:.+}")
112 + public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
113 + @PathParam("tag") String tag)
114 + {
115 + User user = principal.require();
116 + Repository repo = requireReadable(owner, name);
117 + releases.delete(user, require(repo, tag));
118 + return Response.noContent().build();
119 + }
120 +
121 + private Release require(Repository repo, String tag)
122 + {
123 + return releases.find(repo, tag).orElseThrow(NotFoundException::new);
124 + }
125 +
126 + /** Absolute prefix the archive URLs are built from, e.g. {@code https://host/repos/alice/demo/archive/}. */
127 + private String archiveBase(Repository repo)
128 + {
129 + return uriInfo.getBaseUri()
130 + .resolve("/repos/" + repo.ownerHandle() + "/" + repo.name + "/archive/")
131 + .toString();
132 + }
133 +
134 + private Repository requireReadable(String owner, String name)
135 + {
136 + Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
137 + if (!accessPolicy.canRead(principal.orNull(), repo))
138 + {
139 + // a private repository must not even confirm its existence
140 + throw new NotFoundException();
141 + }
142 + return repo;
143 + }
144 +
145 +}
ADD src/main/java/de/workaround/git/GitArchiveService.java +235 -0
diff --git a/src/main/java/de/workaround/git/GitArchiveService.java b/src/main/java/de/workaround/git/GitArchiveService.java
new file mode 100644
index 0000000..a1b3d04
--- /dev/null
+++ b/src/main/java/de/workaround/git/GitArchiveService.java
@@ -0,0 +1,235 @@
1 +package de.workaround.git;
2 +
3 +import java.io.IOException;
4 +import java.io.OutputStream;
5 +import java.io.UncheckedIOException;
6 +import java.nio.charset.StandardCharsets;
7 +import java.nio.file.Path;
8 +import java.util.zip.GZIPOutputStream;
9 +import java.util.zip.ZipEntry;
10 +import java.util.zip.ZipOutputStream;
11 +
12 +import org.eclipse.jgit.lib.FileMode;
13 +import org.eclipse.jgit.lib.ObjectId;
14 +import org.eclipse.jgit.lib.ObjectLoader;
15 +import org.eclipse.jgit.lib.Repository;
16 +import org.eclipse.jgit.revwalk.RevCommit;
17 +import org.eclipse.jgit.revwalk.RevWalk;
18 +import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
19 +import org.eclipse.jgit.treewalk.TreeWalk;
20 +
21 +import jakarta.enterprise.context.ApplicationScoped;
22 +
23 +/**
24 + * Streams the source tree of a ref as a downloadable archive, the way a release page offers "source code
25 + * (zip/tar.gz)". Both formats are written with the JDK alone ({@link ZipOutputStream}, a minimal ustar writer
26 + * plus {@link GZIPOutputStream}) so no extra archiving dependency has to survive the native build. Blobs are
27 + * copied straight out of the object database, so even large trees never sit in memory as a whole.
28 + *
29 + * <p>Submodules (gitlinks) are skipped — they have no content in this repository. Symlinks are written as
30 + * regular files holding their target path, which is what the tree object stores.
31 + */
32 +@ApplicationScoped
33 +public class GitArchiveService
34 +{
35 + private static final int BLOCK = 512;
36 +
37 + public enum Format
38 + {
39 + ZIP(".zip", "application/zip"),
40 + TAR_GZ(".tar.gz", "application/gzip");
41 +
42 + /** The file-name suffix this format is requested and served under. */
43 + public final String suffix;
44 +
45 + public final String mediaType;
46 +
47 + Format(String suffix, String mediaType)
48 + {
49 + this.suffix = suffix;
50 + this.mediaType = mediaType;
51 + }
52 + }
53 +
54 + /**
55 + * Writes the tree of {@code ref} into {@code out}, with every entry below a single {@code prefix}
56 + * directory (as git's own archive does, so unpacking never litters the current directory).
57 + */
58 + public void write(Path barePath, String ref, String prefix, Format format, OutputStream out)
59 + {
60 + try (Repository repo = open(barePath); RevWalk walk = new RevWalk(repo))
61 + {
62 + ObjectId id = repo.resolve(ref + "^{commit}");
63 + if (id == null)
64 + {
65 + throw new InvalidReleaseException("'" + ref + "' does not name a branch, tag or commit");
66 + }
67 + RevCommit commit = walk.parseCommit(id);
68 + long modified = commit.getCommitTime() * 1000L;
69 + if (format == Format.ZIP)
70 + {
71 + writeZip(repo, commit, prefix, modified, out);
72 + }
73 + else
74 + {
75 + writeTarGz(repo, commit, prefix, modified / 1000L, out);
76 + }
77 + }
78 + catch (IOException e)
79 + {
80 + throw new UncheckedIOException(e);
81 + }
82 + }
83 +
84 + private static void writeZip(Repository repo, RevCommit commit, String prefix, long modified, OutputStream out)
85 + throws IOException
86 + {
87 + ZipOutputStream zip = new ZipOutputStream(out, StandardCharsets.UTF_8);
88 + try (TreeWalk walk = files(repo, commit))
89 + {
90 + while (walk.next())
91 + {
92 + // gitlinks point at a commit of another repository and have no content to archive
93 + if (walk.getFileMode() == FileMode.GITLINK)
94 + {
95 + continue;
96 + }
97 + ObjectLoader loader = repo.open(walk.getObjectId(0));
98 + ZipEntry entry = new ZipEntry(prefix + "/" + walk.getPathString());
99 + entry.setTime(modified);
100 + zip.putNextEntry(entry);
101 + loader.copyTo(zip);
102 + zip.closeEntry();
103 + }
104 + }
105 + zip.finish();
106 + }
107 +
108 + private static void writeTarGz(Repository repo, RevCommit commit, String prefix, long modified, OutputStream out)
109 + throws IOException
110 + {
111 + GZIPOutputStream gzip = new GZIPOutputStream(out);
112 + try (TreeWalk walk = files(repo, commit))
113 + {
114 + while (walk.next())
115 + {
116 + if (walk.getFileMode() == FileMode.GITLINK)
117 + {
118 + continue;
119 + }
120 + ObjectLoader loader = repo.open(walk.getObjectId(0));
121 + long size = loader.getSize();
122 + int mode = walk.getFileMode() == FileMode.EXECUTABLE_FILE ? 0755 : 0644;
123 + gzip.write(header(prefix + "/" + walk.getPathString(), size, mode, modified));
124 + loader.copyTo(gzip);
125 + gzip.write(new byte[padding(size)]);
126 + }
127 + }
128 + // a tar stream ends with two zero-filled blocks
129 + gzip.write(new byte[2 * BLOCK]);
130 + gzip.finish();
131 + }
132 +
133 + private static TreeWalk files(Repository repo, RevCommit commit) throws IOException
134 + {
135 + TreeWalk walk = new TreeWalk(repo);
136 + walk.addTree(commit.getTree());
137 + walk.setRecursive(true);
138 + return walk;
139 + }
140 +
141 + private static int padding(long size)
142 + {
143 + int remainder = (int) (size % BLOCK);
144 + return remainder == 0 ? 0 : BLOCK - remainder;
145 + }
146 +
147 + /** Builds one 512-byte ustar header block for a regular file. */
148 + private static byte[] header(String path, long size, int mode, long modified)
149 + {
150 + byte[] block = new byte[BLOCK];
151 + String name = path;
152 + String prefix = "";
153 + if (name.getBytes(StandardCharsets.UTF_8).length > 100)
154 + {
155 + int split = splitPoint(name);
156 + if (split < 0)
157 + {
158 + throw new IllegalStateException("Path too long for a tar archive: " + path);
159 + }
160 + prefix = name.substring(0, split);
161 + name = name.substring(split + 1);
162 + }
163 + text(block, 0, 100, name);
164 + octal(block, 100, 8, mode);
165 + octal(block, 108, 8, 0);
166 + octal(block, 116, 8, 0);
167 + octal(block, 124, 12, size);
168 + octal(block, 136, 12, modified);
169 + // the checksum is computed over the header with its own field blank-filled
170 + for (int i = 148; i < 156; i++)
171 + {
172 + block[i] = ' ';
173 + }
174 + block[156] = '0';
175 + text(block, 257, 6, "ustar");
176 + block[263] = '0';
177 + block[264] = '0';
178 + text(block, 265, 32, "root");
179 + text(block, 297, 32, "root");
180 + text(block, 345, 155, prefix);
181 + int checksum = 0;
182 + for (byte b : block)
183 + {
184 + checksum += b & 0xff;
185 + }
186 + byte[] digits = pad(Integer.toOctalString(checksum), 6).getBytes(StandardCharsets.US_ASCII);
187 + System.arraycopy(digits, 0, block, 148, 6);
188 + block[154] = 0;
189 + block[155] = ' ';
190 + return block;
191 + }
192 +
193 + /** The last slash that leaves ≤ 100 bytes of file name and ≤ 155 bytes of prefix, or -1 if there is none. */
194 + private static int splitPoint(String path)
195 + {
196 + for (int slash = path.indexOf('/'); slash >= 0; slash = path.indexOf('/', slash + 1))
197 + {
198 + int nameBytes = path.substring(slash + 1).getBytes(StandardCharsets.UTF_8).length;
199 + int prefixBytes = path.substring(0, slash).getBytes(StandardCharsets.UTF_8).length;
200 + if (nameBytes <= 100 && prefixBytes <= 155)
201 + {
202 + return slash;
203 + }
204 + }
205 + return -1;
206 + }
207 +
208 + private static void text(byte[] block, int offset, int length, String value)
209 + {
210 + byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
211 + System.arraycopy(bytes, 0, block, offset, Math.min(bytes.length, length - 1));
212 + }
213 +
214 + private static void octal(byte[] block, int offset, int length, long value)
215 + {
216 + byte[] digits = pad(Long.toOctalString(value), length - 1).getBytes(StandardCharsets.US_ASCII);
217 + System.arraycopy(digits, 0, block, offset, length - 1);
218 + block[offset + length - 1] = 0;
219 + }
220 +
221 + private static String pad(String octal, int width)
222 + {
223 + if (octal.length() > width)
224 + {
225 + throw new IllegalStateException("Value does not fit a tar header field: " + octal);
226 + }
227 + return "0".repeat(width - octal.length()) + octal;
228 + }
229 +
230 + private static Repository open(Path barePath) throws IOException
231 + {
232 + return new FileRepositoryBuilder().setGitDir(barePath.toFile()).setMustExist(true).build();
233 + }
234 +
235 +}
ADD src/main/java/de/workaround/git/GitTagService.java +114 -0
diff --git a/src/main/java/de/workaround/git/GitTagService.java b/src/main/java/de/workaround/git/GitTagService.java
new file mode 100644
index 0000000..9cee641
--- /dev/null
+++ b/src/main/java/de/workaround/git/GitTagService.java
@@ -0,0 +1,114 @@
1 +package de.workaround.git;
2 +
3 +import java.io.IOException;
4 +import java.io.UncheckedIOException;
5 +import java.nio.file.Path;
6 +import java.util.Optional;
7 +
8 +import org.eclipse.jgit.lib.Constants;
9 +import org.eclipse.jgit.lib.ObjectId;
10 +import org.eclipse.jgit.lib.ObjectInserter;
11 +import org.eclipse.jgit.lib.PersonIdent;
12 +import org.eclipse.jgit.lib.RefUpdate;
13 +import org.eclipse.jgit.lib.Repository;
14 +import org.eclipse.jgit.lib.TagBuilder;
15 +import org.eclipse.jgit.revwalk.RevCommit;
16 +import org.eclipse.jgit.revwalk.RevWalk;
17 +import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
18 +
19 +import jakarta.enterprise.context.ApplicationScoped;
20 +
21 +/**
22 + * Reads and writes tags of a bare repository. Tag creation runs in-core against the bare repo (no working
23 + * tree), the same way {@link GitMergeService} writes merge commits, so a release can cut its tag from a branch
24 + * without anyone having to push one first.
25 + */
26 +@ApplicationScoped
27 +public class GitTagService
28 +{
29 + /** Resolves any ref (tag, branch or object id) to the commit it points at, peeling annotated tags. */
30 + public Optional<String> resolveCommit(Path barePath, String ref)
31 + {
32 + if (ref == null || ref.isBlank())
33 + {
34 + return Optional.empty();
35 + }
36 + try (Repository repo = open(barePath); RevWalk walk = new RevWalk(repo))
37 + {
38 + ObjectId id = repo.resolve(ref + "^{commit}");
39 + return id == null ? Optional.empty() : Optional.of(walk.parseCommit(id).getName());
40 + }
41 + catch (IOException e)
42 + {
43 + throw new UncheckedIOException(e);
44 + }
45 + }
46 +
47 + public boolean exists(Path barePath, String tag)
48 + {
49 + try (Repository repo = open(barePath))
50 + {
51 + return repo.exactRef(Constants.R_TAGS + tag) != null;
52 + }
53 + catch (IOException e)
54 + {
55 + throw new UncheckedIOException(e);
56 + }
57 + }
58 +
59 + /**
60 + * Creates an annotated tag named {@code tag} at the commit {@code target} resolves to and returns that
61 + * commit's id. Rejects an invalid or already existing tag name and an unresolvable target.
62 + */
63 + public String createTag(Path barePath, String tag, String target, PersonIdent tagger, String message)
64 + {
65 + if (!Repository.isValidRefName(Constants.R_TAGS + tag))
66 + {
67 + throw new InvalidReleaseException("'" + tag + "' is not a valid tag name");
68 + }
69 + try (Repository repo = open(barePath); RevWalk walk = new RevWalk(repo))
70 + {
71 + if (repo.exactRef(Constants.R_TAGS + tag) != null)
72 + {
73 + throw new InvalidReleaseException("Tag '" + tag + "' already exists");
74 + }
75 + ObjectId targetId = repo.resolve(target + "^{commit}");
76 + if (targetId == null)
77 + {
78 + throw new InvalidReleaseException("'" + target + "' does not name a branch, tag or commit");
79 + }
80 + RevCommit commit = walk.parseCommit(targetId);
81 + ObjectId tagId;
82 + try (ObjectInserter inserter = repo.newObjectInserter())
83 + {
84 + TagBuilder builder = new TagBuilder();
85 + builder.setTag(tag);
86 + builder.setObjectId(commit);
87 + builder.setTagger(tagger);
88 + builder.setMessage(message);
89 + tagId = inserter.insert(builder);
90 + inserter.flush();
91 + }
92 + RefUpdate update = repo.updateRef(Constants.R_TAGS + tag);
93 + update.setNewObjectId(tagId);
94 + update.setExpectedOldObjectId(ObjectId.zeroId());
95 + update.setRefLogMessage("tagged " + tag, false);
96 + RefUpdate.Result result = update.update();
97 + if (result != RefUpdate.Result.NEW && result != RefUpdate.Result.FORCED)
98 + {
99 + throw new IllegalStateException("Failed to create tag " + tag + ": " + result);
100 + }
101 + return commit.getName();
102 + }
103 + catch (IOException e)
104 + {
105 + throw new UncheckedIOException(e);
106 + }
107 + }
108 +
109 + private static Repository open(Path barePath) throws IOException
110 + {
111 + return new FileRepositoryBuilder().setGitDir(barePath.toFile()).setMustExist(true).build();
112 + }
113 +
114 +}
ADD src/main/java/de/workaround/git/InvalidReleaseException.java +10 -0
diff --git a/src/main/java/de/workaround/git/InvalidReleaseException.java b/src/main/java/de/workaround/git/InvalidReleaseException.java
new file mode 100644
index 0000000..e429e61
--- /dev/null
+++ b/src/main/java/de/workaround/git/InvalidReleaseException.java
@@ -0,0 +1,10 @@
1 +package de.workaround.git;
2 +
3 +/** Thrown when a release is rejected for invalid input, e.g. a blank tag name or an unknown target. */
4 +public class InvalidReleaseException extends RuntimeException
5 +{
6 + public InvalidReleaseException(String message)
7 + {
8 + super(message);
9 + }
10 +}
ADD src/main/java/de/workaround/git/ReleaseService.java +146 -0
diff --git a/src/main/java/de/workaround/git/ReleaseService.java b/src/main/java/de/workaround/git/ReleaseService.java
new file mode 100644
index 0000000..361bdbd
--- /dev/null
+++ b/src/main/java/de/workaround/git/ReleaseService.java
@@ -0,0 +1,146 @@
1 +package de.workaround.git;
2 +
3 +import java.nio.file.Path;
4 +import java.util.List;
5 +import java.util.Optional;
6 +
7 +import org.eclipse.jgit.lib.PersonIdent;
8 +
9 +import de.workaround.model.Release;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.User;
12 +import jakarta.enterprise.context.ApplicationScoped;
13 +import jakarta.inject.Inject;
14 +import jakarta.transaction.Transactional;
15 +
16 +/**
17 + * Publishes and manages a repository's releases. A release annotates exactly one git tag with a title and
18 + * notes; the tag stays the source of truth for what was released. When the named tag does not exist yet, an
19 + * annotated tag is cut from the given target (a branch, tag or commit) as part of publishing — the way the
20 + * "New release" page and Gitea's {@code target_commitish} work.
21 + *
22 + * <p>Reading follows the repository's read-visibility rule (enforced by callers); publishing, editing and
23 + * deleting require write access (owner, collaborator or org member). Deleting a release never deletes its tag.
24 + */
25 +@ApplicationScoped
26 +public class ReleaseService
27 +{
28 + @Inject
29 + Release.Repo releases;
30 +
31 + @Inject
32 + GitRepositoryService repositories;
33 +
34 + @Inject
35 + GitTagService tags;
36 +
37 + @Inject
38 + AccessPolicy accessPolicy;
39 +
40 + @Transactional
41 + public Release create(User actor, Repository repository, String tagName, String target, String title,
42 + String body, boolean prerelease)
43 + {
44 + requireWrite(actor, repository);
45 + String tag = tagName == null ? "" : tagName.strip();
46 + if (tag.isEmpty())
47 + {
48 + throw new InvalidReleaseException("Tag name must not be empty");
49 + }
50 + if (releases.findByRepositoryAndTagName(repository, tag).isPresent())
51 + {
52 + throw new InvalidReleaseException("Tag '" + tag + "' already has a release");
53 + }
54 + Path barePath = repositories.repositoryPath(repository);
55 + String commitId = tags.exists(barePath, tag)
56 + ? tags.resolveCommit(barePath, tag)
57 + .orElseThrow(() -> new InvalidReleaseException("Tag '" + tag + "' does not point at a commit"))
58 + : cutTag(actor, barePath, tag, target);
59 + Release release = new Release();
60 + release.repository = repository;
61 + release.author = actor;
62 + release.tagName = tag;
63 + release.title = blankTo(title, tag);
64 + release.body = blankToNull(body);
65 + release.commitId = commitId;
66 + release.prerelease = prerelease;
67 + release.persist();
68 + return release;
69 + }
70 +
71 + public List<Release> list(Repository repository)
72 + {
73 + return releases.findByRepository(repository);
74 + }
75 +
76 + public Optional<Release> find(Repository repository, String tagName)
77 + {
78 + return releases.findByRepositoryAndTagName(repository, tagName);
79 + }
80 +
81 + /** The newest release that is not a prerelease — what the list page badges as "Latest". */
82 + public Optional<Release> findLatest(Repository repository)
83 + {
84 + return releases.findStable(repository).stream().findFirst();
85 + }
86 +
87 + public long count(Repository repository)
88 + {
89 + return releases.countByRepository(repository);
90 + }
91 +
92 + /** Edits title, notes and the prerelease flag; the tag, commit, author and publish time never change. */
93 + @Transactional
94 + public void update(User actor, Release release, String title, String body, boolean prerelease)
95 + {
96 + requireWrite(actor, release.repository);
97 + // re-attach: the release may have been loaded in a previous request/transaction, and may have been
98 + // deleted concurrently since then, so guard against a missing row (findById returns null).
99 + Release managed = releases.findById(release.id);
100 + if (managed != null)
101 + {
102 + managed.title = blankTo(title, managed.tagName);
103 + managed.body = blankToNull(body);
104 + managed.prerelease = prerelease;
105 + }
106 + }
107 +
108 + /** Removes the release. The git tag it published is left in place — history is never rewritten here. */
109 + @Transactional
110 + public void delete(User actor, Release release)
111 + {
112 + requireWrite(actor, release.repository);
113 + releases.deleteById(release.id);
114 + }
115 +
116 + private String cutTag(User actor, Path barePath, String tag, String target)
117 + {
118 + if (target == null || target.isBlank())
119 + {
120 + throw new InvalidReleaseException("Tag '" + tag + "' does not exist — pick a target to create it from");
121 + }
122 + String email = actor.email == null || actor.email.isBlank() ? actor.username + "@localhost" : actor.email;
123 + String name = actor.displayName == null || actor.displayName.isBlank() ? actor.username : actor.displayName;
124 + return tags.createTag(barePath, tag, target.strip(), new PersonIdent(name, email), "Release " + tag);
125 + }
126 +
127 + private static String blankTo(String value, String fallback)
128 + {
129 + return value == null || value.isBlank() ? fallback : value.strip();
130 + }
131 +
132 + private static String blankToNull(String value)
133 + {
134 + return value == null || value.isBlank() ? null : value.strip();
135 + }
136 +
137 + private void requireWrite(User actor, Repository repository)
138 + {
139 + if (!accessPolicy.canWrite(actor, repository))
140 + {
141 + throw new ForbiddenOperationException(
142 + "Only the repository owner or a collaborator can manage releases");
143 + }
144 + }
145 +
146 +}
ADD src/main/java/de/workaround/model/Release.java +78 -0
diff --git a/src/main/java/de/workaround/model/Release.java b/src/main/java/de/workaround/model/Release.java
new file mode 100644
index 0000000..41ec790
--- /dev/null
+++ b/src/main/java/de/workaround/model/Release.java
@@ -0,0 +1,78 @@
1 +package de.workaround.model;
2 +
3 +import java.time.Instant;
4 +import java.util.List;
5 +import java.util.Optional;
6 +import java.util.UUID;
7 +
8 +import org.hibernate.annotations.processing.Find;
9 +import org.hibernate.annotations.processing.HQL;
10 +
11 +import io.quarkus.hibernate.panache.PanacheEntity;
12 +import io.quarkus.hibernate.panache.PanacheRepository;
13 +import jakarta.persistence.Entity;
14 +import jakarta.persistence.GeneratedValue;
15 +import jakarta.persistence.GenerationType;
16 +import jakarta.persistence.Id;
17 +import jakarta.persistence.ManyToOne;
18 +import jakarta.persistence.Table;
19 +
20 +/**
21 + * A published version of a repository, pinned to exactly one git tag and carrying human-facing release notes.
22 + * The tag itself lives in git, not here — this row only annotates it with a title, notes and a prerelease flag,
23 + * so deleting a release never touches the repository's history. Owned by the repository and removed with it
24 + * (DB-level ON DELETE CASCADE).
25 + */
26 +@Entity
27 +@Table(name = "releases")
28 +public class Release implements PanacheEntity.Managed
29 +{
30 + @Id
31 + @GeneratedValue(strategy = GenerationType.UUID)
32 + public UUID id;
33 +
34 + @ManyToOne(optional = false)
35 + public Repository repository;
36 +
37 + @ManyToOne(optional = false)
38 + public User author;
39 +
40 + /** The git tag this release publishes; unique within the repository. */
41 + public String tagName;
42 +
43 + public String title;
44 +
45 + /** Release notes, rendered as Markdown; null when the author left them empty. */
46 + public String body;
47 +
48 + /** The commit the tag resolved to when the release was published, so the page can link the exact revision. */
49 + public String commitId;
50 +
51 + /** A prerelease is listed like any other release but never counts as the repository's latest one. */
52 + public boolean prerelease;
53 +
54 + public Instant createdAt = Instant.now();
55 +
56 + /** The first 7 characters of {@link #commitId}, matching how commits are abbreviated elsewhere in the UI. */
57 + public String shortCommitId()
58 + {
59 + return commitId == null ? null : commitId.substring(0, Math.min(7, commitId.length()));
60 + }
61 +
62 + public interface Repo extends PanacheRepository.Managed<Release, UUID>
63 + {
64 + // tagName breaks ties so two releases published in the same instant still have a stable order
65 + @HQL("select r from Release r join fetch r.author where r.repository = :repository order by r.createdAt desc, r.tagName desc")
66 + List<Release> findByRepository(Repository repository);
67 +
68 + @HQL("select r from Release r join fetch r.author where r.repository = :repository and r.prerelease = false order by r.createdAt desc, r.tagName desc")
69 + List<Release> findStable(Repository repository);
70 +
71 + @Find
72 + Optional<Release> findByRepositoryAndTagName(Repository repository, String tagName);
73 +
74 + @HQL("select count(r) from Release r where r.repository = :repository")
75 + long countByRepository(Repository repository);
76 + }
77 +
78 +}
ADD src/main/java/de/workaround/web/InvalidReleaseExceptionMapper.java +25 -0
diff --git a/src/main/java/de/workaround/web/InvalidReleaseExceptionMapper.java b/src/main/java/de/workaround/web/InvalidReleaseExceptionMapper.java
new file mode 100644
index 0000000..cd59fc0
--- /dev/null
+++ b/src/main/java/de/workaround/web/InvalidReleaseExceptionMapper.java
@@ -0,0 +1,25 @@
1 +package de.workaround.web;
2 +
3 +import de.workaround.git.InvalidReleaseException;
4 +import jakarta.ws.rs.core.MediaType;
5 +import jakarta.ws.rs.core.Response;
6 +import jakarta.ws.rs.ext.ExceptionMapper;
7 +import jakarta.ws.rs.ext.Provider;
8 +
9 +/**
10 + * Maps the domain {@link InvalidReleaseException} to HTTP 400, so invalid release input (a blank tag name, an
11 + * unknown target, a tag that already has a release) surfaces as a clean bad request instead of a generic 500.
12 + * Mirrors {@link InvalidIssueExceptionMapper}.
13 + */
14 +@Provider
15 +public class InvalidReleaseExceptionMapper implements ExceptionMapper<InvalidReleaseException>
16 +{
17 + @Override
18 + public Response toResponse(InvalidReleaseException exception)
19 + {
20 + return Response.status(Response.Status.BAD_REQUEST)
21 + .entity(exception.getMessage())
22 + .type(MediaType.TEXT_PLAIN)
23 + .build();
24 + }
25 +}
ADD src/main/java/de/workaround/web/ReleaseResource.java +202 -0
diff --git a/src/main/java/de/workaround/web/ReleaseResource.java b/src/main/java/de/workaround/web/ReleaseResource.java
new file mode 100644
index 0000000..ef6d7b5
--- /dev/null
+++ b/src/main/java/de/workaround/web/ReleaseResource.java
@@ -0,0 +1,202 @@
1 +package de.workaround.web;
2 +
3 +import java.net.URI;
4 +import java.net.URLEncoder;
5 +import java.nio.charset.StandardCharsets;
6 +import java.nio.file.Path;
7 +import java.util.List;
8 +
9 +import de.workaround.account.CurrentUser;
10 +import de.workaround.git.AccessPolicy;
11 +import de.workaround.git.ForbiddenOperationException;
12 +import de.workaround.git.GitBrowseService;
13 +import de.workaround.git.GitRepositoryService;
14 +import de.workaround.git.ReleaseService;
15 +import de.workaround.model.Release;
16 +import de.workaround.model.Repository;
17 +import io.quarkus.qute.CheckedTemplate;
18 +import io.quarkus.qute.TemplateInstance;
19 +import jakarta.inject.Inject;
20 +import jakarta.ws.rs.Consumes;
21 +import jakarta.ws.rs.DefaultValue;
22 +import jakarta.ws.rs.FormParam;
23 +import jakarta.ws.rs.GET;
24 +import jakarta.ws.rs.NotFoundException;
25 +import jakarta.ws.rs.POST;
26 +import jakarta.ws.rs.PathParam;
27 +import jakarta.ws.rs.Produces;
28 +import jakarta.ws.rs.core.Context;
29 +import jakarta.ws.rs.core.MediaType;
30 +import jakarta.ws.rs.core.Response;
31 +import jakarta.ws.rs.core.UriInfo;
32 +
33 +/**
34 + * The repository's Releases pages: the list with its "Latest" badge, the per-tag detail page with rendered
35 + * notes and source-archive links, and the publish/edit/delete forms. Reading follows repository visibility;
36 + * every mutation is authorized in {@link ReleaseService}.
37 + */
38 +@jakarta.ws.rs.Path("/repos/{owner}/{name}/releases")
39 +@Produces(MediaType.TEXT_HTML)
40 +public class ReleaseResource
41 +{
42 + @CheckedTemplate
43 + static class Templates
44 + {
45 + static native TemplateInstance releases(Repository repo, RepoNav nav, boolean canWrite,
46 + List<Release> releases, String latestTag);
47 +
48 + static native TemplateInstance newRelease(Repository repo, RepoNav nav, List<String> targets,
49 + List<String> untaggedTags);
50 +
51 + static native TemplateInstance release(Repository repo, RepoNav nav, boolean canWrite, Release release,
52 + String bodyHtml, boolean latest);
53 +
54 + static native TemplateInstance editRelease(Repository repo, RepoNav nav, Release release);
55 + }
56 +
57 + @Inject
58 + CurrentUser currentUser;
59 +
60 + @Inject
61 + GitRepositoryService service;
62 +
63 + @Inject
64 + GitBrowseService browse;
65 +
66 + @Inject
67 + AccessPolicy accessPolicy;
68 +
69 + @Inject
70 + ReleaseService releaseService;
71 +
72 + @Inject
73 + RepoNavService repoNav;
74 +
75 + @Context
76 + UriInfo uriInfo;
77 +
78 + @GET
79 + public TemplateInstance list(@PathParam("owner") String owner, @PathParam("name") String name)
80 + {
81 + Repository repo = requireReadable(owner, name);
82 + String latestTag = releaseService.findLatest(repo).map(release -> release.tagName).orElse(null);
83 + return Templates.releases(repo, repoNav.build(repo, uriInfo), canWrite(repo), releaseService.list(repo),
84 + latestTag);
85 + }
86 +
87 + @GET
88 + @jakarta.ws.rs.Path("new")
89 + public TemplateInstance newForm(@PathParam("owner") String owner, @PathParam("name") String name)
90 + {
91 + Repository repo = requireWritable(owner, name);
92 + Path barePath = service.repositoryPath(repo);
93 + List<String> branches = browse.branches(barePath).stream().map(GitBrowseService.BranchInfo::name).toList();
94 + // tags that have no release yet can be published as-is; the rest need a new tag cut from a branch
95 + List<String> untagged = browse.tags(barePath).stream()
96 + .filter(tag -> releaseService.find(repo, tag).isEmpty())
97 + .toList();
98 + return Templates.newRelease(repo, repoNav.build(repo, uriInfo), branches, untagged);
99 + }
100 +
101 + @POST
102 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
103 + public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
104 + @FormParam("tagName") String tagName, @FormParam("target") String target,
105 + @FormParam("title") String title, @FormParam("body") String body,
106 + @FormParam("prerelease") @DefaultValue("") String prerelease)
107 + {
108 + Repository repo = requireReadable(owner, name);
109 + Release release = releaseService.create(currentUser.require(), repo, tagName, target, title, body,
110 + checked(prerelease));
111 + return Response.seeOther(releaseUri(repo, release.tagName)).build();
112 + }
113 +
114 + @GET
115 + @jakarta.ws.rs.Path("tag/{tag:.+}")
116 + public TemplateInstance detail(@PathParam("owner") String owner, @PathParam("name") String name,
117 + @PathParam("tag") String tag)
118 + {
119 + Repository repo = requireReadable(owner, name);
120 + Release release = require(repo, tag);
121 + String bodyHtml = release.body == null ? null : Markdown.render(release.body);
122 + boolean latest = releaseService.findLatest(repo).filter(r -> r.id.equals(release.id)).isPresent();
123 + return Templates.release(repo, repoNav.build(repo, uriInfo), canWrite(repo), release, bodyHtml, latest);
124 + }
125 +
126 + @GET
127 + @jakarta.ws.rs.Path("tag/{tag:.+}/edit")
128 + public TemplateInstance editForm(@PathParam("owner") String owner, @PathParam("name") String name,
129 + @PathParam("tag") String tag)
130 + {
131 + Repository repo = requireWritable(owner, name);
132 + return Templates.editRelease(repo, repoNav.build(repo, uriInfo), require(repo, tag));
133 + }
134 +
135 + @POST
136 + @jakarta.ws.rs.Path("tag/{tag:.+}/edit")
137 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
138 + public Response edit(@PathParam("owner") String owner, @PathParam("name") String name,
139 + @PathParam("tag") String tag, @FormParam("title") String title, @FormParam("body") String body,
140 + @FormParam("prerelease") @DefaultValue("") String prerelease)
141 + {
142 + Repository repo = requireReadable(owner, name);
143 + Release release = require(repo, tag);
144 + releaseService.update(currentUser.require(), release, title, body, checked(prerelease));
145 + return Response.seeOther(releaseUri(repo, release.tagName)).build();
146 + }
147 +
148 + @POST
149 + @jakarta.ws.rs.Path("tag/{tag:.+}/delete")
150 + public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
151 + @PathParam("tag") String tag)
152 + {
153 + Repository repo = requireReadable(owner, name);
154 + releaseService.delete(currentUser.require(), require(repo, tag));
155 + return Response.seeOther(URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name + "/releases")).build();
156 + }
157 +
158 + private Release require(Repository repo, String tag)
159 + {
160 + return releaseService.find(repo, tag).orElseThrow(NotFoundException::new);
161 + }
162 +
163 + /** An unchecked checkbox is not submitted at all, so any value present means the box was ticked. */
164 + private static boolean checked(String value)
165 + {
166 + return value != null && !value.isBlank();
167 + }
168 +
169 + private static URI releaseUri(Repository repo, String tag)
170 + {
171 + return URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name + "/releases/tag/"
172 + + URLEncoder.encode(tag, StandardCharsets.UTF_8).replace("%2F", "/").replace("+", "%20"));
173 + }
174 +
175 + private boolean canWrite(Repository repo)
176 + {
177 + return accessPolicy.canWrite(currentUser.get(), repo);
178 + }
179 +
180 + private Repository requireReadable(String owner, String name)
181 + {
182 + Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
183 + if (!accessPolicy.canRead(currentUser.get(), repo))
184 + {
185 + // a private repository must not even confirm its existence
186 + throw new NotFoundException();
187 + }
188 + return repo;
189 + }
190 +
191 + private Repository requireWritable(String owner, String name)
192 + {
193 + Repository repo = requireReadable(owner, name);
194 + if (!canWrite(repo))
195 + {
196 + throw new ForbiddenOperationException(
197 + "Only the repository owner or a collaborator can manage releases");
198 + }
199 + return repo;
200 + }
201 +
202 +}
MODIFY src/main/java/de/workaround/web/RepoNav.java +2 -2
diff --git a/src/main/java/de/workaround/web/RepoNav.java b/src/main/java/de/workaround/web/RepoNav.java
index 4005d7c..6e190b1 100644
--- a/src/main/java/de/workaround/web/RepoNav.java
+++ b/src/main/java/de/workaround/web/RepoNav.java
@@ -8,8 +8,8 @@
8 8 * next to each nav item. Assembled once per request by {@link RepoNavService}.
9 9 */
10 10 public record RepoNav(Repository repo, boolean loggedIn, boolean isOwner, boolean pinned, boolean empty,
11 - String defaultBranch, int commitCount, int branchCount, int tagCount, long openIssueCount, long openMrCount,
12 - String httpUrl, String sshUrl, String currentPath, boolean parentVisible)
11 + String defaultBranch, int commitCount, int branchCount, int tagCount, long releaseCount, long openIssueCount,
12 + long openMrCount, String httpUrl, String sshUrl, String currentPath, boolean parentVisible)
13 13 {
14 14 /** Whether to reveal the "forked from" link: the repo is a fork and the current viewer may read its parent. */
15 15 public boolean showParent()
MODIFY src/main/java/de/workaround/web/RepoNavService.java +7 -1
diff --git a/src/main/java/de/workaround/web/RepoNavService.java b/src/main/java/de/workaround/web/RepoNavService.java
index e005e56..bc4f8b4 100644
--- a/src/main/java/de/workaround/web/RepoNavService.java
+++ b/src/main/java/de/workaround/web/RepoNavService.java
@@ -8,6 +8,7 @@
8 8 import de.workaround.git.GitRepositoryService;
9 9 import de.workaround.git.IssueService;
10 10 import de.workaround.git.MergeRequestService;
11 +import de.workaround.git.ReleaseService;
11 12 import de.workaround.git.RepositoryPinService;
12 13 import de.workaround.model.Repository;
13 14 import de.workaround.model.User;
@@ -45,6 +46,9 @@
45 46 @Inject
46 47 MergeRequestService mergeRequestService;
47 48
49 + @Inject
50 + ReleaseService releaseService;
51 +
48 52 // The port shown in clone URLs. This is the port users actually connect to (e.g. 22), which may differ from
49 53 // gitshark.ssh.port — the port the embedded server binds inside the container (e.g. 2222, so it needs no root).
50 54 @ConfigProperty(name = "gitshark.ssh.external-port")
@@ -65,12 +69,14 @@
65 69 int commitCount = empty ? 0 : browse.commitCount(path, defaultBranch);
66 70 int branchCount = browse.branches(path).size();
67 71 int tagCount = browse.tags(path).size();
72 + long releaseCount = releaseService.count(repo);
68 73 long openIssueCount = issueService.countOpen(repo);
69 74 long openMrCount = mergeRequestService.countOpen(repo);
70 75 String httpUrl = uriInfo.getBaseUri().resolve("/git/" + repo.ownerHandle() + "/" + repo.name + ".git")
71 76 .toString();
72 77 String sshUrl = CloneUrls.ssh(uriInfo.getBaseUri().getHost(), sshExternalPort, repo.ownerHandle(), repo.name);
73 78 return new RepoNav(repo, loggedIn, isOwner, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
74 - openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath(), parentVisible);
79 + releaseCount, openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath(),
80 + parentVisible);
75 81 }
76 82 }
MODIFY src/main/java/de/workaround/web/RepositoryResource.java +48 -0
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index 645df3a..ae16aa9 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -21,9 +21,11 @@
21 21 import de.workaround.account.CurrentUser;
22 22 import de.workaround.account.InvalidImageException;
23 23 import de.workaround.git.AccessPolicy;
24 +import de.workaround.git.GitArchiveService;
24 25 import de.workaround.git.GitBrowseService;
25 26 import de.workaround.git.GitMergeService;
26 27 import de.workaround.git.GitRepositoryService;
28 +import de.workaround.git.GitTagService;
27 29 import de.workaround.git.RepositoryImageService;
28 30 import de.workaround.git.RepositoryPinService;
29 31 import de.workaround.model.Repository;
@@ -44,6 +46,7 @@
44 46 import jakarta.ws.rs.core.HttpHeaders;
45 47 import jakarta.ws.rs.core.MediaType;
46 48 import jakarta.ws.rs.core.Response;
49 +import jakarta.ws.rs.core.StreamingOutput;
47 50 import jakarta.ws.rs.core.UriInfo;
48 51
49 52 @jakarta.ws.rs.Path("/repos/{owner}/{name}")
@@ -109,6 +112,12 @@
109 112 @Inject
110 113 RepositoryImageService images;
111 114
115 + @Inject
116 + GitArchiveService archives;
117 +
118 + @Inject
119 + GitTagService tagService;
120 +
112 121 @Context
113 122 UriInfo uriInfo;
114 123
@@ -232,6 +241,45 @@
232 241 return Response.ok(blob.content()).build();
233 242 }
234 243
244 + /**
245 + * Serves the source tree of a ref as a downloadable archive, e.g. {@code archive/v1.0.0.zip} or
246 + * {@code archive/v1.0.0.tar.gz} — the "source code" downloads a release links to. Any ref works, not just
247 + * tags, and the same read-visibility rule as every other repository page applies.
248 + */
249 + @GET
250 + @jakarta.ws.rs.Path("archive/{rest:.+}")
251 + @Produces(MediaType.APPLICATION_OCTET_STREAM)
252 + public Response archive(@PathParam("owner") String owner, @PathParam("name") String name,
253 + @PathParam("rest") String rest)
254 + {
255 + Repository repo = requireReadable(owner, name);
256 + GitArchiveService.Format format = archiveFormat(rest);
257 + String ref = rest.substring(0, rest.length() - format.suffix.length());
258 + Path repoPath = service.repositoryPath(repo);
259 + if (tagService.resolveCommit(repoPath, ref).isEmpty())
260 + {
261 + throw new NotFoundException();
262 + }
263 + // slashes in a ref would create nested directories inside the archive, so they collapse to dashes
264 + String prefix = repo.name + "-" + ref.replace('/', '-');
265 + StreamingOutput body = out -> archives.write(repoPath, ref, prefix, format, out);
266 + return Response.ok(body, format.mediaType)
267 + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + format.suffix + "\"")
268 + .build();
269 + }
270 +
271 + private static GitArchiveService.Format archiveFormat(String rest)
272 + {
273 + for (GitArchiveService.Format format : GitArchiveService.Format.values())
274 + {
275 + if (rest.length() > format.suffix.length() && rest.endsWith(format.suffix))
276 + {
277 + return format;
278 + }
279 + }
280 + throw new NotFoundException();
281 + }
282 +
235 283 @GET
236 284 @jakarta.ws.rs.Path("commits/{ref:.+}")
237 285 public TemplateInstance commits(@PathParam("owner") String owner, @PathParam("name") String name,
MODIFY src/main/resources/META-INF/resources/shark.css +21 -0
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index a7b4889..0c295a8 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -942,6 +942,27 @@
942 942 gap: var(--s2);
943 943 }
944 944
945 +/* release form: the target select and the pre-release checkbox sit inline with their label text,
946 + instead of stretching to the full field width like the text inputs above them */
947 +.release-target,
948 +.release-flag {
949 + display: flex;
950 + align-items: center;
951 + gap: var(--s2);
952 + font: 14px/1.4 var(--font);
953 + color: var(--muted);
954 +}
955 +
956 +.release-target select,
957 +.release-flag input[type="checkbox"] {
958 + width: auto;
959 + margin-top: 0;
960 +}
961 +
962 +.release-flag input[type="checkbox"] {
963 + accent-color: var(--accent);
964 +}
965 +
945 966 /* the discussion comment box and its submit button would otherwise touch */
946 967 .comment-form .form-actions {
947 968 margin-top: var(--s3);
ADD src/main/resources/db/migration/V32__releases.sql +15 -0
diff --git a/src/main/resources/db/migration/V32__releases.sql b/src/main/resources/db/migration/V32__releases.sql
new file mode 100644
index 0000000..c5f239d
--- /dev/null
+++ b/src/main/resources/db/migration/V32__releases.sql
@@ -0,0 +1,15 @@
1 +create table releases
2 +(
3 + id uuid primary key,
4 + repository_id uuid not null references repositories (id) on delete cascade,
5 + author_id uuid not null references users (id) on delete cascade,
6 + tag_name text not null,
7 + title text not null,
8 + body text,
9 + commit_id varchar(40) not null,
10 + prerelease boolean not null default false,
11 + created_at timestamptz not null default now(),
12 + unique (repository_id, tag_name)
13 +);
14 +
15 +create index releases_repository_idx on releases (repository_id);
ADD src/main/resources/templates/ReleaseResource/editRelease.html +22 -0
diff --git a/src/main/resources/templates/ReleaseResource/editRelease.html b/src/main/resources/templates/ReleaseResource/editRelease.html
new file mode 100644
index 0000000..7662518
--- /dev/null
+++ b/src/main/resources/templates/ReleaseResource/editRelease.html
@@ -0,0 +1,22 @@
1 +{#include layout}
2 +{#title}Edit release – {repo.name}{/title}
3 +<div class="repo-layout">
4 + {#include RepositoryResource/sidebar nav=nav active='releases' /}
5 + <section class="repo-main">
6 + <p><a href="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}">← {release.tagName}</a></p>
7 + <h2>Edit release</h2>
8 + <form class="issue-form" method="post"
9 + action="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}/edit">
10 + <input type="text" name="title" value="{release.title}" placeholder="Release title" autocomplete="off">
11 + <textarea name="body" placeholder="Release notes (Markdown, optional)" rows="10">{release.body ?: ''}</textarea>
12 + <label class="release-flag">
13 + <input type="checkbox" name="prerelease" {#if release.prerelease}checked{/if}> This is a pre-release
14 + </label>
15 + <div class="form-actions">
16 + <button type="submit" class="btn btn-primary">Save release</button>
17 + <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}">Cancel</a>
18 + </div>
19 + </form>
20 + </section>
21 +</div>
22 +{/include}
ADD src/main/resources/templates/ReleaseResource/newRelease.html +36 -0
diff --git a/src/main/resources/templates/ReleaseResource/newRelease.html b/src/main/resources/templates/ReleaseResource/newRelease.html
new file mode 100644
index 0000000..66526f7
--- /dev/null
+++ b/src/main/resources/templates/ReleaseResource/newRelease.html
@@ -0,0 +1,36 @@
1 +{#include layout}
2 +{#title}New release – {repo.name}{/title}
3 +<div class="repo-layout">
4 + {#include RepositoryResource/sidebar nav=nav active='releases' /}
5 + <section class="repo-main">
6 + <p><a href="/repos/{repo.ownerHandle}/{repo.name}/releases">← Releases</a></p>
7 + <h2>New release</h2>
8 + <form class="issue-form" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/releases">
9 + <input type="text" name="tagName" placeholder="Tag (e.g. v1.0.0)" required autocomplete="off"
10 + list="release-tags">
11 + <datalist id="release-tags">
12 + {#for tag in untaggedTags}
13 + <option value="{tag}"></option>
14 + {/for}
15 + </datalist>
16 + <label class="release-target">
17 + Create the tag from
18 + <select name="target">
19 + {#for target in targets}
20 + <option value="{target}">{target}</option>
21 + {/for}
22 + </select>
23 + </label>
24 + <input type="text" name="title" placeholder="Release title (defaults to the tag)" autocomplete="off">
25 + <textarea name="body" placeholder="Release notes (Markdown, optional)" rows="10"></textarea>
26 + <label class="release-flag">
27 + <input type="checkbox" name="prerelease"> This is a pre-release
28 + </label>
29 + <div class="form-actions">
30 + <button type="submit" class="btn btn-primary">Publish release</button>
31 + <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/releases">Cancel</a>
32 + </div>
33 + </form>
34 + </section>
35 +</div>
36 +{/include}
ADD src/main/resources/templates/ReleaseResource/release.html +50 -0
diff --git a/src/main/resources/templates/ReleaseResource/release.html b/src/main/resources/templates/ReleaseResource/release.html
new file mode 100644
index 0000000..9d39bfb
--- /dev/null
+++ b/src/main/resources/templates/ReleaseResource/release.html
@@ -0,0 +1,50 @@
1 +{#include layout}
2 +{#title}{release.title} – {repo.name}{/title}
3 +<div class="repo-layout">
4 + {#include RepositoryResource/sidebar nav=nav active='releases' /}
5 + <section class="repo-main">
6 + <p class="issue-back"><a href="/repos/{repo.ownerHandle}/{repo.name}/releases">← Releases</a></p>
7 + <div class="issue-detail">
8 + <div class="issue-content">
9 + <header class="issue-header">
10 + <h2 class="issue-heading">{release.title} <span class="issue-no">{release.tagName}</span></h2>
11 + {#if canWrite}
12 + <div class="issue-header-actions">
13 + <a class="btn btn-secondary btn-sm" href="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}/edit">Edit</a>
14 + <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}/delete"
15 + onsubmit="return confirm('Delete this release? The git tag itself is kept.')">
16 + <button type="submit" class="btn btn-danger btn-sm">Delete</button>
17 + </form>
18 + </div>
19 + {/if}
20 + </header>
21 + <div class="issue-byline">
22 + {#if latest}<span class="badge badge-default">Latest</span>{/if}
23 + {#if release.prerelease}<span class="badge status-PLANNED">Pre-release</span>{/if}
24 + <span class="issue-person">{#avatar user=release.author /} <span><strong>{release.author.username}</strong> published this release</span></span>
25 + <span class="muted" title="{release.createdAt}">{release.createdAt.since}</span>
26 + </div>
27 + {#if bodyHtml}
28 + <div class="panel issue-desc">
29 + <div class="readme-body">{bodyHtml.raw}</div>
30 + </div>
31 + {#else}
32 + <p class="issue-nodesc muted">No release notes provided.</p>
33 + {/if}
34 + <h3 class="comments-title">Assets</h3>
35 + <div class="panel">
36 + <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/archive/{release.tagName}.zip">
37 + <span class="fname"><span class="g">⤓</span> <span class="n">Source code (zip)</span></span>
38 + </a>
39 + <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/archive/{release.tagName}.tar.gz">
40 + <span class="fname"><span class="g">⤓</span> <span class="n">Source code (tar.gz)</span></span>
41 + </a>
42 + <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/commit/{release.commitId}">
43 + <span class="fname"><span class="g">◴</span> <span class="n">Commit {release.shortCommitId}</span></span>
44 + </a>
45 + </div>
46 + </div>
47 + </div>
48 + </section>
49 +</div>
50 +{/include}
ADD src/main/resources/templates/ReleaseResource/releases.html +31 -0
diff --git a/src/main/resources/templates/ReleaseResource/releases.html b/src/main/resources/templates/ReleaseResource/releases.html
new file mode 100644
index 0000000..810cb3e
--- /dev/null
+++ b/src/main/resources/templates/ReleaseResource/releases.html
@@ -0,0 +1,31 @@
1 +{#include layout}
2 +{#title}Releases – {repo.name}{/title}
3 +<div class="repo-layout">
4 + {#include RepositoryResource/sidebar nav=nav active='releases' /}
5 + <section class="repo-main">
6 + <div class="issues-head">
7 + <h2>Releases</h2>
8 + {#if canWrite}
9 + <a class="btn btn-primary" href="/repos/{repo.ownerHandle}/{repo.name}/releases/new">New release</a>
10 + {/if}
11 + </div>
12 + {#if releases.isEmpty()}
13 + <p class="muted">No releases published yet.</p>
14 + {#else}
15 + <div class="panel">
16 + {#for release in releases}
17 + <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/releases/tag/{release.tagName}">
18 + <span class="fname">
19 + <span class="n">{release.title}</span>
20 + <span class="issue-no">{release.tagName}</span>
21 + {#if release.tagName == latestTag}<span class="badge badge-default">Latest</span>{/if}
22 + {#if release.prerelease}<span class="badge status-PLANNED">Pre-release</span>{/if}
23 + </span>
24 + <span class="muted" title="{release.createdAt}">{release.createdAt.since}</span>
25 + </a>
26 + {/for}
27 + </div>
28 + {/if}
29 + </section>
30 +</div>
31 +{/include}
MODIFY src/main/resources/templates/RepositoryResource/sidebar.html +1 -0
diff --git a/src/main/resources/templates/RepositoryResource/sidebar.html b/src/main/resources/templates/RepositoryResource/sidebar.html
index bfdba93..8be60a6 100644
--- a/src/main/resources/templates/RepositoryResource/sidebar.html
+++ b/src/main/resources/templates/RepositoryResource/sidebar.html
@@ -34,6 +34,7 @@
34 34 {/if}
35 35 <a class="{#if active == 'branches'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/branches"><span class="g">⑂</span> Branches <span class="ct">{nav.branchCount}</span></a>
36 36 <a class="{#if active == 'tags'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/tags"><span class="g">⬡</span> Tags <span class="ct">{nav.tagCount}</span></a>
37 + <a class="{#if active == 'releases'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/releases"><span class="g">⎋</span> Releases <span class="ct">{nav.releaseCount}</span></a>
37 38 <a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
38 39 <a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
39 40 <a class="{#if active == 'actions'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/actions"><span class="g">▷</span> Actions</a>
ADD src/test/java/de/workaround/api/ReleaseApiTest.java +149 -0
diff --git a/src/test/java/de/workaround/api/ReleaseApiTest.java b/src/test/java/de/workaround/api/ReleaseApiTest.java
new file mode 100644
index 0000000..4be4252
--- /dev/null
+++ b/src/test/java/de/workaround/api/ReleaseApiTest.java
@@ -0,0 +1,149 @@
1 +package de.workaround.api;
2 +
3 +import java.nio.charset.StandardCharsets;
4 +import java.util.HashMap;
5 +import java.util.Map;
6 +
7 +import org.junit.jupiter.api.Test;
8 +
9 +import de.workaround.git.GitRepositoryService;
10 +import de.workaround.git.GitTestSeeder;
11 +import de.workaround.http.AccessTokenService;
12 +import de.workaround.model.Repository;
13 +import de.workaround.model.User;
14 +import io.quarkus.test.junit.QuarkusTest;
15 +import jakarta.inject.Inject;
16 +import jakarta.transaction.Transactional;
17 +
18 +import static io.restassured.RestAssured.given;
19 +import static org.hamcrest.CoreMatchers.equalTo;
20 +import static org.hamcrest.Matchers.containsString;
21 +import static org.hamcrest.Matchers.hasItem;
22 +
23 +@QuarkusTest
24 +class ReleaseApiTest
25 +{
26 + @Inject
27 + GitRepositoryService service;
28 +
29 + @Inject
30 + AccessTokenService tokenService;
31 +
32 + @Inject
33 + User.Repo userRepo;
34 +
35 + @Test
36 + void createListGetUpdateAndDeleteARelease() throws Exception
37 + {
38 + User owner = persistUser("api-release-owner");
39 + String token = mintToken(owner);
40 + Repository repo = seededRepo(owner, "released");
41 + String base = "/api/v1/repos/" + owner.username + "/released/releases";
42 +
43 + // create — the tag is cut from target_commitish when it does not exist yet
44 + given().header("Authorization", "Bearer " + token)
45 + .contentType("application/json")
46 + .body(Map.of("tag_name", "v1.0.0", "target_commitish", "main", "name", "1.0.0", "body", "notes"))
47 + .when().post(base)
48 + .then().statusCode(201)
49 + .body("tag_name", equalTo("v1.0.0"))
50 + .body("name", equalTo("1.0.0"))
51 + .body("body", equalTo("notes"))
52 + .body("prerelease", equalTo(false))
53 + .body("draft", equalTo(false))
54 + .body("author.login", equalTo(owner.username))
55 + .body("zipball_url", containsString("/repos/" + owner.username + "/released/archive/v1.0.0.zip"))
56 + .body("tarball_url", containsString("/repos/" + owner.username + "/released/archive/v1.0.0.tar.gz"));
57 +
58 + // list (anonymous read on a public repo)
59 + given().when().get(base)
60 + .then().statusCode(200)
61 + .body("tag_name", hasItem("v1.0.0"));
62 +
63 + // get by tag
64 + given().when().get(base + "/tags/v1.0.0")
65 + .then().statusCode(200)
66 + .body("name", equalTo("1.0.0"));
67 +
68 + // latest
69 + given().when().get(base + "/latest")
70 + .then().statusCode(200)
71 + .body("tag_name", equalTo("v1.0.0"));
72 +
73 + // update
74 + Map<String, Object> edit = new HashMap<>();
75 + edit.put("name", "1.0.0 final");
76 + edit.put("body", "shipped");
77 + edit.put("prerelease", true);
78 + given().header("Authorization", "Bearer " + token)
79 + .contentType("application/json").body(edit)
80 + .when().patch(base + "/tags/v1.0.0")
81 + .then().statusCode(200)
82 + .body("name", equalTo("1.0.0 final"))
83 + .body("prerelease", equalTo(true));
84 +
85 + // a prerelease is not the latest release
86 + given().when().get(base + "/latest").then().statusCode(404);
87 +
88 + // delete
89 + given().header("Authorization", "Bearer " + token)
90 + .when().delete(base + "/tags/v1.0.0")
91 + .then().statusCode(204);
92 + given().when().get(base + "/tags/v1.0.0").then().statusCode(404);
93 + }
94 +
95 + @Test
96 + void anonymousCannotCreateReleases() throws Exception
97 + {
98 + User owner = persistUser("api-release-anon");
99 + seededRepo(owner, "readonly");
100 +
101 + given().contentType("application/json")
102 + .body(Map.of("tag_name", "v1.0.0", "target_commitish", "main", "name", "1.0.0"))
103 + .when().post("/api/v1/repos/" + owner.username + "/readonly/releases")
104 + .then().statusCode(401);
105 + }
106 +
107 + @Test
108 + void anUnknownTagWithoutATargetIsRejected() throws Exception
109 + {
110 + User owner = persistUser("api-release-badtag");
111 + String token = mintToken(owner);
112 + seededRepo(owner, "picky");
113 +
114 + given().header("Authorization", "Bearer " + token)
115 + .contentType("application/json")
116 + .body(Map.of("tag_name", "v9.9.9", "name", "9.9.9"))
117 + .when().post("/api/v1/repos/" + owner.username + "/picky/releases")
118 + .then().statusCode(400);
119 + }
120 +
121 + private Repository seededRepo(User owner, String name) throws Exception
122 + {
123 + Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
124 + GitTestSeeder.seed(service.repositoryPath(repo),
125 + Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
126 + return repo;
127 + }
128 +
129 + private String mintToken(User owner)
130 + {
131 + return tokenService.create(owner, "release-api").plaintext();
132 + }
133 +
134 + @Transactional
135 + User persistUser(String name)
136 + {
137 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
138 + if (existing != null)
139 + {
140 + return existing;
141 + }
142 + User user = new User();
143 + user.oidcSub = name;
144 + user.username = name;
145 + user.persist();
146 + return user;
147 + }
148 +
149 +}
MODIFY src/test/java/de/workaround/git/GitTestSeeder.java +11 -0
diff --git a/src/test/java/de/workaround/git/GitTestSeeder.java b/src/test/java/de/workaround/git/GitTestSeeder.java
index 3d0e318..4d725bd 100644
--- a/src/test/java/de/workaround/git/GitTestSeeder.java
+++ b/src/test/java/de/workaround/git/GitTestSeeder.java
@@ -62,6 +62,17 @@
62 62 }
63 63 }
64 64
65 + /** Pushes a lightweight tag pointing at the current tip of the default branch. */
66 + public static void seedTag(Path barePath, String tag) throws Exception
67 + {
68 + Path work = Files.createTempDirectory("seed");
69 + try (Git git = Git.cloneRepository().setURI(barePath.toUri().toString()).setDirectory(work.toFile()).call())
70 + {
71 + git.tag().setName(tag).setAnnotated(false).call();
72 + git.push().setRefSpecs(new RefSpec("refs/tags/" + tag + ":refs/tags/" + tag)).call();
73 + }
74 + }
75 +
65 76 /** Pushes a single commit with the given message to refs/heads/main and returns its object id. */
66 77 public static ObjectId seedCommit(Path barePath, String message) throws Exception
67 78 {
ADD src/test/java/de/workaround/git/ReleaseServiceTest.java +184 -0
diff --git a/src/test/java/de/workaround/git/ReleaseServiceTest.java b/src/test/java/de/workaround/git/ReleaseServiceTest.java
new file mode 100644
index 0000000..5fe696f
--- /dev/null
+++ b/src/test/java/de/workaround/git/ReleaseServiceTest.java
@@ -0,0 +1,184 @@
1 +package de.workaround.git;
2 +
3 +import java.nio.charset.StandardCharsets;
4 +import java.nio.file.Path;
5 +import java.util.List;
6 +import java.util.Map;
7 +
8 +import org.junit.jupiter.api.Test;
9 +
10 +import de.workaround.model.Release;
11 +import de.workaround.model.Repository;
12 +import de.workaround.model.User;
13 +import io.quarkus.test.junit.QuarkusTest;
14 +import jakarta.inject.Inject;
15 +import jakarta.transaction.Transactional;
16 +
17 +import static org.junit.jupiter.api.Assertions.assertEquals;
18 +import static org.junit.jupiter.api.Assertions.assertFalse;
19 +import static org.junit.jupiter.api.Assertions.assertThrows;
20 +import static org.junit.jupiter.api.Assertions.assertTrue;
21 +
22 +@QuarkusTest
23 +class ReleaseServiceTest
24 +{
25 + @Inject
26 + GitRepositoryService service;
27 +
28 + @Inject
29 + ReleaseService releases;
30 +
31 + @Inject
32 + GitBrowseService browse;
33 +
34 + @Inject
35 + CollaboratorService collaborators;
36 +
37 + @Inject
38 + User.Repo users;
39 +
40 + @Test
41 + void publishesAReleaseForAnExistingTag() throws Exception
42 + {
43 + User owner = persistUser("rel-existing");
44 + Repository repo = seededRepo(owner, "ship");
45 + GitTestSeeder.seedTag(service.repositoryPath(repo), "v1.0.0");
46 +
47 + Release release = releases.create(owner, repo, "v1.0.0", null, "1.0.0", "First cut", false);
48 +
49 + assertEquals("v1.0.0", release.tagName);
50 + assertEquals("1.0.0", release.title);
51 + assertEquals("First cut", release.body);
52 + assertFalse(release.prerelease);
53 + assertEquals(owner.id, release.author.id);
54 + // the tag is resolved to a commit at publish time, so the page can link the exact revision
55 + assertEquals(40, release.commitId.length());
56 + assertEquals(List.of("v1.0.0"), releases.list(repo).stream().map(r -> r.tagName).toList());
57 + }
58 +
59 + @Test
60 + void creatingWithATargetCreatesTheTag() throws Exception
61 + {
62 + User owner = persistUser("rel-newtag");
63 + Repository repo = seededRepo(owner, "tagme");
64 + Path bare = service.repositoryPath(repo);
65 + assertFalse(browse.tags(bare).contains("v2.0.0"));
66 +
67 + releases.create(owner, repo, "v2.0.0", "main", "2.0.0", null, false);
68 +
69 + assertTrue(browse.tags(bare).contains("v2.0.0"));
70 + }
71 +
72 + @Test
73 + void rejectsAnUnknownTagWithoutATarget() throws Exception
74 + {
75 + User owner = persistUser("rel-notag");
76 + Repository repo = seededRepo(owner, "missing");
77 +
78 + assertThrows(InvalidReleaseException.class,
79 + () -> releases.create(owner, repo, "v9.9.9", null, "9.9.9", null, false));
80 + }
81 +
82 + @Test
83 + void rejectsASecondReleaseForTheSameTag() throws Exception
84 + {
85 + User owner = persistUser("rel-dupe");
86 + Repository repo = seededRepo(owner, "dupe");
87 + releases.create(owner, repo, "v1.0.0", "main", "1.0.0", null, false);
88 +
89 + assertThrows(InvalidReleaseException.class,
90 + () -> releases.create(owner, repo, "v1.0.0", "main", "again", null, false));
91 + }
92 +
93 + @Test
94 + void rejectsABlankTagName() throws Exception
95 + {
96 + User owner = persistUser("rel-blank");
97 + Repository repo = seededRepo(owner, "blank");
98 +
99 + assertThrows(InvalidReleaseException.class,
100 + () -> releases.create(owner, repo, " ", "main", "1.0.0", null, false));
101 + }
102 +
103 + @Test
104 + void aStrangerCannotPublishOrDelete() throws Exception
105 + {
106 + User owner = persistUser("rel-owner");
107 + User stranger = persistUser("rel-stranger");
108 + Repository repo = seededRepo(owner, "guarded");
109 + Release release = releases.create(owner, repo, "v1.0.0", "main", "1.0.0", null, false);
110 +
111 + assertThrows(ForbiddenOperationException.class,
112 + () -> releases.create(stranger, repo, "v1.1.0", "main", "1.1.0", null, false));
113 + assertThrows(ForbiddenOperationException.class, () -> releases.delete(stranger, release));
114 + }
115 +
116 + @Test
117 + void aCollaboratorCanPublish() throws Exception
118 + {
119 + User owner = persistUser("rel-collab-owner");
120 + User collab = persistUser("rel-collab");
121 + Repository repo = seededRepo(owner, "shared");
122 + collaborators.add(owner, repo, collab.username);
123 +
124 + Release release = releases.create(collab, repo, "v1.0.0", "main", "1.0.0", null, false);
125 +
126 + assertEquals(collab.id, release.author.id);
127 + }
128 +
129 + @Test
130 + void theLatestReleaseSkipsPrereleases() throws Exception
131 + {
132 + User owner = persistUser("rel-latest");
133 + Repository repo = seededRepo(owner, "latest");
134 + releases.create(owner, repo, "v1.0.0", "main", "1.0.0", null, false);
135 + releases.create(owner, repo, "v1.1.0-rc1", "main", "1.1.0-rc1", null, true);
136 +
137 + assertEquals("v1.0.0", releases.findLatest(repo).orElseThrow().tagName);
138 + // the list is newest first, prereleases included
139 + assertEquals(List.of("v1.1.0-rc1", "v1.0.0"), releases.list(repo).stream().map(r -> r.tagName).toList());
140 + }
141 +
142 + @Test
143 + void updatesAndDeletesARelease() throws Exception
144 + {
145 + User owner = persistUser("rel-edit");
146 + Repository repo = seededRepo(owner, "editable");
147 + Release release = releases.create(owner, repo, "v1.0.0", "main", "1.0.0", "old notes", false);
148 +
149 + releases.update(owner, release, "1.0.0 final", "new notes", true);
150 + Release reloaded = releases.find(repo, "v1.0.0").orElseThrow();
151 + assertEquals("1.0.0 final", reloaded.title);
152 + assertEquals("new notes", reloaded.body);
153 + assertTrue(reloaded.prerelease);
154 +
155 + releases.delete(owner, reloaded);
156 + assertTrue(releases.find(repo, "v1.0.0").isEmpty());
157 + // deleting the release leaves the git tag alone
158 + assertTrue(browse.tags(service.repositoryPath(repo)).contains("v1.0.0"));
159 + }
160 +
161 + private Repository seededRepo(User owner, String name) throws Exception
162 + {
163 + Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
164 + GitTestSeeder.seed(service.repositoryPath(repo),
165 + Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
166 + return repo;
167 + }
168 +
169 + @Transactional
170 + User persistUser(String name)
171 + {
172 + User existing = users.findByOidcSubOptional(name).orElse(null);
173 + if (existing != null)
174 + {
175 + return existing;
176 + }
177 + User user = new User();
178 + user.oidcSub = name;
179 + user.username = name;
180 + user.persist();
181 + return user;
182 + }
183 +
184 +}
ADD src/test/java/de/workaround/web/ReleaseUiTest.java +208 -0
diff --git a/src/test/java/de/workaround/web/ReleaseUiTest.java b/src/test/java/de/workaround/web/ReleaseUiTest.java
new file mode 100644
index 0000000..2651d95
--- /dev/null
+++ b/src/test/java/de/workaround/web/ReleaseUiTest.java
@@ -0,0 +1,208 @@
1 +package de.workaround.web;
2 +
3 +import java.io.ByteArrayInputStream;
4 +import java.nio.charset.StandardCharsets;
5 +import java.util.ArrayList;
6 +import java.util.List;
7 +import java.util.Map;
8 +import java.util.zip.GZIPInputStream;
9 +import java.util.zip.ZipEntry;
10 +import java.util.zip.ZipInputStream;
11 +
12 +import org.junit.jupiter.api.Test;
13 +
14 +import de.workaround.git.GitRepositoryService;
15 +import de.workaround.git.GitTestSeeder;
16 +import de.workaround.git.ReleaseService;
17 +import de.workaround.model.Repository;
18 +import de.workaround.model.User;
19 +import io.quarkus.test.junit.QuarkusTest;
20 +import io.quarkus.test.security.TestSecurity;
21 +import jakarta.inject.Inject;
22 +import jakarta.transaction.Transactional;
23 +
24 +import static io.restassured.RestAssured.given;
25 +import static org.hamcrest.CoreMatchers.containsString;
26 +import static org.hamcrest.CoreMatchers.not;
27 +import static org.junit.jupiter.api.Assertions.assertEquals;
28 +import static org.junit.jupiter.api.Assertions.assertTrue;
29 +
30 +@QuarkusTest
31 +class ReleaseUiTest
32 +{
33 + @Inject
34 + GitRepositoryService service;
35 +
36 + @Inject
37 + ReleaseService releases;
38 +
39 + @Inject
40 + User.Repo users;
41 +
42 + @Test
43 + @TestSecurity(user = "rel-ui-owner")
44 + void ownerCanPublishBrowseEditAndDeleteARelease() throws Exception
45 + {
46 + User owner = persistUser("rel-ui-owner");
47 + Repository repo = seededRepo(owner, "shipit");
48 + String base = "/repos/" + owner.username + "/shipit/releases";
49 +
50 + // empty state
51 + given().when().get(base).then().statusCode(200).body(containsString("No releases"));
52 +
53 + // publish, creating the tag from the default branch on the way
54 + given().redirects().follow(false)
55 + .contentType("application/x-www-form-urlencoded")
56 + .formParam("tagName", "v1.0.0").formParam("target", "main")
57 + .formParam("title", "1.0.0").formParam("body", "## Highlights\n\n- first cut")
58 + .when().post(base)
59 + .then().statusCode(303)
60 + .header("Location", containsString("/releases/tag/v1.0.0"));
61 +
62 + // the list shows the release, its tag and the Latest badge
63 + given().when().get(base)
64 + .then().statusCode(200)
65 + .body(containsString("1.0.0"))
66 + .body(containsString("v1.0.0"))
67 + .body(containsString("Latest"));
68 +
69 + // the detail page renders the notes as Markdown and offers both source archives
70 + given().when().get(base + "/tag/v1.0.0")
71 + .then().statusCode(200)
72 + .body(containsString("<h2>Highlights</h2>"))
73 + .body(containsString("first cut"))
74 + .body(containsString("/archive/v1.0.0.zip"))
75 + .body(containsString("/archive/v1.0.0.tar.gz"));
76 +
77 + // edit
78 + given().redirects().follow(false)
79 + .contentType("application/x-www-form-urlencoded")
80 + .formParam("title", "1.0.0 final").formParam("body", "shipped").formParam("prerelease", "on")
81 + .when().post(base + "/tag/v1.0.0/edit")
82 + .then().statusCode(303);
83 + given().when().get(base + "/tag/v1.0.0")
84 + .then().statusCode(200)
85 + .body(containsString("1.0.0 final"))
86 + .body(containsString("Pre-release"));
87 +
88 + // delete
89 + given().redirects().follow(false)
90 + .contentType("application/x-www-form-urlencoded")
91 + .when().post(base + "/tag/v1.0.0/delete")
92 + .then().statusCode(303);
93 + given().when().get(base).then().statusCode(200).body(containsString("No releases"));
94 + }
95 +
96 + @Test
97 + @TestSecurity(user = "rel-ui-nav")
98 + void theSidebarLinksReleasesWithTheirCount() throws Exception
99 + {
100 + User owner = persistUser("rel-ui-nav");
101 + Repository repo = seededRepo(owner, "navved");
102 + releases.create(owner, repo, "v0.1.0", "main", "0.1.0", null, false);
103 +
104 + given().when().get("/repos/" + owner.username + "/navved")
105 + .then().statusCode(200)
106 + .body(containsString("/repos/" + owner.username + "/navved/releases"))
107 + .body(containsString("Releases"));
108 + }
109 +
110 + @Test
111 + void sourceArchivesDownloadForAnyoneWhoCanReadTheRepo() throws Exception
112 + {
113 + User owner = persistUser("rel-ui-archive");
114 + Repository repo = seededRepo(owner, "archived");
115 + releases.create(owner, repo, "v1.0.0", "main", "1.0.0", null, false);
116 + String base = "/repos/" + owner.username + "/archived";
117 +
118 + byte[] zip = given().when().get(base + "/archive/v1.0.0.zip")
119 + .then().statusCode(200)
120 + .header("Content-Disposition", containsString("archived-v1.0.0.zip"))
121 + .extract().asByteArray();
122 + assertEquals(List.of("archived-v1.0.0/README.md"), zipEntries(zip));
123 +
124 + byte[] tarGz = given().when().get(base + "/archive/v1.0.0.tar.gz")
125 + .then().statusCode(200)
126 + .header("Content-Disposition", containsString("archived-v1.0.0.tar.gz"))
127 + .extract().asByteArray();
128 + assertTrue(tarNames(tarGz).contains("archived-v1.0.0/README.md"), "tar contains the seeded README");
129 + }
130 +
131 + @Test
132 + @TestSecurity(user = "rel-ui-stranger")
133 + void aStrangerSeesNoPublishButtonAndCannotPost() throws Exception
134 + {
135 + User owner = persistUser("rel-ui-guarded-owner");
136 + persistUser("rel-ui-stranger");
137 + Repository repo = seededRepo(owner, "guarded");
138 + String base = "/repos/" + owner.username + "/guarded/releases";
139 +
140 + given().when().get(base).then().statusCode(200).body(not(containsString("New release")));
141 + given().redirects().follow(false)
142 + .contentType("application/x-www-form-urlencoded")
143 + .formParam("tagName", "v1.0.0").formParam("target", "main").formParam("title", "nope")
144 + .when().post(base)
145 + .then().statusCode(403);
146 + }
147 +
148 + private static List<String> zipEntries(byte[] zip) throws Exception
149 + {
150 + List<String> names = new ArrayList<>();
151 + try (ZipInputStream in = new ZipInputStream(new ByteArrayInputStream(zip)))
152 + {
153 + for (ZipEntry entry = in.getNextEntry(); entry != null; entry = in.getNextEntry())
154 + {
155 + names.add(entry.getName());
156 + }
157 + }
158 + return names;
159 + }
160 +
161 + /** Reads the 100-byte name field out of every 512-byte ustar header block. */
162 + private static List<String> tarNames(byte[] tarGz) throws Exception
163 + {
164 + byte[] tar;
165 + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(tarGz)))
166 + {
167 + tar = in.readAllBytes();
168 + }
169 + List<String> names = new ArrayList<>();
170 + for (int offset = 0; offset + 512 <= tar.length; offset += 512)
171 + {
172 + String name = new String(tar, offset, 100, StandardCharsets.UTF_8).replace("\0", "");
173 + if (name.isEmpty())
174 + {
175 + break;
176 + }
177 + names.add(name);
178 + int size = Integer.parseInt(new String(tar, offset + 124, 12, StandardCharsets.UTF_8)
179 + .replace("\0", "").strip(), 8);
180 + offset += (size + 511) / 512 * 512;
181 + }
182 + return names;
183 + }
184 +
185 + private Repository seededRepo(User owner, String name) throws Exception
186 + {
187 + Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
188 + GitTestSeeder.seed(service.repositoryPath(repo),
189 + Map.of("README.md", "# hi\n".getBytes(StandardCharsets.UTF_8)));
190 + return repo;
191 + }
192 +
193 + @Transactional
194 + User persistUser(String name)
195 + {
196 + User existing = users.findByOidcSubOptional(name).orElse(null);
197 + if (existing != null)
198 + {
199 + return existing;
200 + }
201 + User user = new User();
202 + user.oidcSub = name;
203 + user.username = name;
204 + user.persist();
205 + return user;
206 + }
207 +
208 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog