✨ (releases): Publish tags as releases with source archives
Changes
30 files changed, +2052 -8
MODIFY
README.md
+15 -4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+}