fabrica

hanna/fabrica

feat(web): releases — list, detail, drafts, assets, source archives

16a2dd1 · hanna committed on 2026-07-26

Build the releases UI on the existing store foundation:

- A Releases repo tab (behind the per-repo releases_enabled toggle, added
  to repo settings alongside issues/PRs) and /-/releases list + detail,
  GitHub-style: title, Latest/pre-release/draft badges, author, date,
  markdown notes, assets, and source-code zip/tar.gz downloads.
- Draft/create a release from an existing tag or a new tag cut from a
  target branch (git::Repo::create_tag); edit and delete; publish vs save
  draft. Drafts are hidden from non-writers.
- Asset upload (multipart, streamed to disk under data_dir/releases),
  download (with a download counter), and delete.
- Source archives via `git archive` (git::pack::archive), streamed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
14 files changed · +1031 −1UnifiedSplit
assets/base.css +92 −0
@@ -2130,6 +2130,98 @@ pre.code {
21302130 margin: 0.6rem 0 0;
21312131 font-size: 0.9em;
21322132 }
2133+/* Releases. */
2134+.releases-head {
2135+ display: flex;
2136+ align-items: center;
2137+ justify-content: space-between;
2138+ gap: 1rem;
2139+ margin-bottom: 1rem;
2140+}
2141+.back-link {
2142+ font-size: 0.9em;
2143+}
2144+.release {
2145+ margin-bottom: 1.25rem;
2146+}
2147+.release-head {
2148+ display: flex;
2149+ align-items: flex-start;
2150+ justify-content: space-between;
2151+ gap: 1rem;
2152+}
2153+.release-title {
2154+ margin: 0;
2155+ font-size: 1.4rem;
2156+}
2157+.release-title a {
2158+ color: var(--fb-fg);
2159+}
2160+.release-meta {
2161+ display: flex;
2162+ align-items: center;
2163+ gap: 0.35rem;
2164+ flex-wrap: wrap;
2165+ margin-top: 0.35rem;
2166+ font-size: 0.9em;
2167+}
2168+.release-actions {
2169+ display: flex;
2170+ gap: 0.5rem;
2171+ flex: none;
2172+}
2173+.release-notes {
2174+ margin: 1rem 0;
2175+ padding-top: 1rem;
2176+ border-top: 1px solid var(--fb-border);
2177+}
2178+.release-assets h3 {
2179+ margin: 1rem 0 0.5rem;
2180+}
2181+.asset-list {
2182+ list-style: none;
2183+ margin: 0;
2184+ padding: 0;
2185+ border: 1px solid var(--fb-border);
2186+ border-radius: var(--fb-radius);
2187+}
2188+.asset-row {
2189+ display: flex;
2190+ align-items: center;
2191+ gap: 0.6rem;
2192+ padding: 0.55rem 0.85rem;
2193+ border-bottom: 1px solid var(--fb-border);
2194+}
2195+.asset-row:last-child {
2196+ border-bottom: none;
2197+}
2198+.asset-name {
2199+ flex: 1;
2200+ min-width: 0;
2201+ overflow: hidden;
2202+ text-overflow: ellipsis;
2203+ white-space: nowrap;
2204+ font-weight: 600;
2205+}
2206+.asset-row.source .asset-name {
2207+ font-weight: 400;
2208+}
2209+.asset-size {
2210+ flex: none;
2211+ font-size: 0.85em;
2212+}
2213+.asset-upload {
2214+ display: flex;
2215+ align-items: center;
2216+ gap: 0.6rem;
2217+ margin-top: 0.75rem;
2218+ flex-wrap: wrap;
2219+}
2220+.release-form-actions {
2221+ display: flex;
2222+ gap: 0.6rem;
2223+ margin-top: 1rem;
2224+}
21332225 /* Notifications inbox. */
21342226 .notif-head {
21352227 display: flex;
crates/git/src/pack.rs +31 −0
@@ -112,6 +112,37 @@ pub fn spawn(
112112 cmd.spawn()
113113 }
114114
115+/// Spawn `git archive` for a source download, streaming the archive on stdout.
116+///
117+/// `format` is a git archive format (`zip` or `tar.gz`); `prefix` is the top-level
118+/// directory inside the archive (e.g. `repo-v1.0`); `rev` is the tag/branch/commit.
119+///
120+/// # Errors
121+///
122+/// Returns the [`std::io::Error`] from spawning if the `git` binary cannot be run.
123+pub fn archive(
124+ binary: &str,
125+ repo_path: &Path,
126+ format: &str,
127+ rev: &str,
128+ prefix: &str,
129+ home: &Path,
130+) -> std::io::Result<Child> {
131+ let mut cmd = Command::new(binary);
132+ cmd.arg("archive");
133+ cmd.arg(format!("--format={format}"));
134+ cmd.arg(format!("--prefix={prefix}/"));
135+ cmd.arg(rev);
136+ cmd.env("GIT_DIR", repo_path);
137+ cmd.env("GIT_TERMINAL_PROMPT", "0");
138+ cmd.env("GIT_CONFIG_NOSYSTEM", "1");
139+ cmd.env("HOME", home);
140+ cmd.stdout(Stdio::piped());
141+ cmd.stderr(Stdio::null());
142+ cmd.kill_on_drop(true);
143+ cmd.spawn()
144+}
145+
115146 /// Encode `payload` as a single git pkt-line (`{len:04x}{payload}`). Used to frame
116147 /// the smart-HTTP service advertisement.
117148 #[must_use]
crates/git/src/repo.rs +18 −0
@@ -240,6 +240,24 @@ impl Repo {
240240 })
241241 }
242242
243+ /// Create a lightweight tag `name` pointing at `rev`, failing if it already
244+ /// exists (releases must not silently move a tag). For cutting a release from
245+ /// a branch/commit.
246+ ///
247+ /// # Errors
248+ ///
249+ /// Returns [`GitError::Libgit2`] if the tag exists or the ref cannot be written.
250+ pub fn create_tag(&self, name: &str, rev: &Resolved) -> Result<(), GitError> {
251+ let oid = git2::Oid::from_str(&rev.oid.to_string())?;
252+ self.inner.reference(
253+ &format!("refs/tags/{name}"),
254+ oid,
255+ false,
256+ "fabrica release tag",
257+ )?;
258+ Ok(())
259+ }
260+
243261 /// The local branches, each with its position (ahead/behind) relative to the
244262 /// default branch and its tip commit.
245263 ///
crates/store/src/tests.rs +81 −0
@@ -857,6 +857,87 @@ async fn fork_parent_and_count_round_trip() {
857857 }
858858
859859 #[tokio::test]
860+async fn releases_and_assets_round_trip() {
861+ for fx in sqlite_fixtures().await {
862+ let owner = fx
863+ .store
864+ .create_user(sample_user("rel", "rel@example.com"))
865+ .await
866+ .unwrap();
867+ let repo = fx
868+ .store
869+ .create_repo(NewRepo {
870+ owner_id: owner.id.clone(),
871+ group_id: None,
872+ name: "r".to_string(),
873+ path: "r".to_string(),
874+ description: None,
875+ visibility: model::Visibility::Public,
876+ default_branch: "main".to_string(),
877+ })
878+ .await
879+ .unwrap();
880+ let rel = fx
881+ .store
882+ .create_release(crate::NewRelease {
883+ repo_id: repo.id.clone(),
884+ tag: "v1.0".to_string(),
885+ name: "First".to_string(),
886+ body: Some("notes".to_string()),
887+ is_prerelease: false,
888+ is_draft: false,
889+ author_id: owner.id.clone(),
890+ })
891+ .await
892+ .unwrap();
893+ // Duplicate tag conflicts.
894+ assert!(matches!(
895+ fx.store
896+ .create_release(crate::NewRelease {
897+ repo_id: repo.id.clone(),
898+ tag: "v1.0".to_string(),
899+ name: "dup".to_string(),
900+ body: None,
901+ is_prerelease: false,
902+ is_draft: false,
903+ author_id: owner.id.clone(),
904+ })
905+ .await,
906+ Err(StoreError::Conflict { .. })
907+ ));
908+ assert_eq!(fx.store.list_releases(&repo.id).await.unwrap().len(), 1);
909+ assert_eq!(
910+ fx.store
911+ .release_by_tag(&repo.id, "v1.0")
912+ .await
913+ .unwrap()
914+ .unwrap()
915+ .id,
916+ rel.id
917+ );
918+
919+ let asset = fx
920+ .store
921+ .add_release_asset(&rel.id, "bin.tar.gz", 4096, "application/gzip")
922+ .await
923+ .unwrap();
924+ assert_eq!(
925+ fx.store.list_release_assets(&rel.id).await.unwrap().len(),
926+ 1
927+ );
928+ // Deleting the release cascades to its assets.
929+ assert!(fx.store.delete_release(&rel.id).await.unwrap());
930+ assert!(
931+ fx.store
932+ .release_asset_by_id(&asset.id)
933+ .await
934+ .unwrap()
935+ .is_none()
936+ );
937+ }
938+}
939+
940+#[tokio::test]
860941 async fn mirrors_create_list_due_and_delete() {
861942 for fx in sqlite_fixtures().await {
862943 let owner = fx
crates/web/src/lib.rs +10 −0
@@ -27,6 +27,7 @@ mod markdown;
2727 mod notifications;
2828 mod pages;
2929 mod pulls;
30+mod releases;
3031 mod repo;
3132 mod search;
3233 #[path = "serve.rs"]
@@ -339,6 +340,15 @@ pub fn build_router(state: AppState) -> Router {
339340 .route("/comment/{id}/edit", post(issues::comment_edit))
340341 .route("/pull-new/{repo_id}", post(pulls::create))
341342 .route("/pull/{id}/merge", post(pulls::merge))
343+ .route("/release-new/{repo_id}", post(releases::create))
344+ .route(
345+ "/release/{id}/edit",
346+ get(releases::edit_form).post(releases::edit),
347+ )
348+ .route("/release/{id}/delete", post(releases::delete))
349+ .route("/release/{id}/asset", post(releases::asset_upload))
350+ .route("/release-asset/{id}", get(releases::asset_download))
351+ .route("/release-asset/{id}/delete", post(releases::asset_delete))
342352 .route("/healthz", get(serve_assets::healthz))
343353 .route("/version", get(serve_assets::version))
344354 .route("/theme.css", get(serve_assets::theme_default))
crates/web/src/releases.rs +777 −0
@@ -0,0 +1,777 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Releases: tag-based release notes with uploaded assets and source archives.
6+//!
7+//! A release is cut from a git tag (existing, or newly created from a target
8+//! branch), carries markdown notes and an optional pre-release / draft label,
9+//! and any number of uploaded asset files (stored under `data_dir/releases`,
10+//! keyed by asset id). The list and detail views mirror GitHub's releases page.
11+
12+use std::path::{Path as FsPath, PathBuf};
13+use std::sync::atomic::{AtomicU64, Ordering};
14+
15+use axum::body::Body;
16+use axum::extract::{Multipart, Path, State};
17+use axum::http::{HeaderMap, StatusCode, Uri, header};
18+use axum::response::{IntoResponse, Redirect, Response};
19+use axum_extra::extract::cookie::CookieJar;
20+use futures_util::TryStreamExt as _;
21+use maud::{Markup, html};
22+use model::User;
23+use serde::Deserialize;
24+use tokio::io::AsyncWriteExt as _;
25+use tokio_util::io::ReaderStream;
26+
27+use crate::AppState;
28+use crate::error::{AppError, AppResult};
29+use crate::icons::{Icon, icon};
30+use crate::layout::page;
31+use crate::markdown;
32+use crate::pages::build_chrome;
33+use crate::repo::{
34+ Pagination, RepoCtx, Tab, branch_names, git_read, page_slice, pagination_nav, repo_header,
35+ require_readable_repo,
36+};
37+use crate::session::{MaybeUser, verify_csrf};
38+
39+/// The on-disk directory holding release asset files.
40+fn assets_dir(state: &AppState) -> PathBuf {
41+ state.config.storage.data_dir.join("releases")
42+}
43+
44+/// The release that should carry the "Latest" badge: the newest published,
45+/// non-pre-release. `releases` is newest-first.
46+fn latest_release_id(releases: &[store::Release]) -> Option<String> {
47+ releases
48+ .iter()
49+ .find(|r| !r.is_draft && !r.is_prerelease)
50+ .map(|r| r.id.clone())
51+}
52+
53+/// `GET …/-/releases` — the release list.
54+pub(crate) async fn list(
55+ state: &AppState,
56+ viewer: Option<User>,
57+ jar: CookieJar,
58+ uri: &Uri,
59+ ctx: RepoCtx,
60+) -> AppResult<Response> {
61+ if !ctx.repo.releases_enabled {
62+ return Err(AppError::NotFound);
63+ }
64+ let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
65+ let mut releases = state.store.list_releases(&ctx.repo.id).await?;
66+ if !can_write {
67+ releases.retain(|r| !r.is_draft);
68+ }
69+ let latest = latest_release_id(&releases);
70+ let paging = Pagination::from_query(uri.query());
71+ let (shown, has_next) = page_slice(&releases, paging);
72+
73+ let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
74+ let branches = branch_names(state, &ctx.repo.id).await;
75+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
76+ let mut cards = Vec::with_capacity(shown.len());
77+ for r in &shown {
78+ cards.push(
79+ release_card(
80+ state,
81+ &base,
82+ r,
83+ latest.as_deref() == Some(&r.id),
84+ can_write,
85+ &chrome.csrf,
86+ )
87+ .await,
88+ );
89+ }
90+ let body = html! {
91+ (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches))
92+ div class="releases-head" {
93+ h2 { "Releases" }
94+ @if can_write {
95+ a class="btn btn-primary" href=(format!("{base}/-/releases/new")) {
96+ (icon(Icon::Plus)) span { "Draft a new release" }
97+ }
98+ }
99+ }
100+ @if cards.is_empty() {
101+ div class="card" { p class="muted" { "No releases yet." } }
102+ } @else {
103+ @for card in &cards { (card) }
104+ @if has_next || paging.page > 1 {
105+ (pagination_nav(uri.path(), uri.query(), paging, has_next))
106+ }
107+ }
108+ };
109+ let title = format!("{}/{}: releases", ctx.owner.username, ctx.repo.path);
110+ Ok((jar, page(&chrome, &title, body)).into_response())
111+}
112+
113+/// `GET …/-/releases/{tag}` — a single release.
114+pub(crate) async fn view(
115+ state: &AppState,
116+ viewer: Option<User>,
117+ jar: CookieJar,
118+ uri: &Uri,
119+ ctx: RepoCtx,
120+ tag: &str,
121+) -> AppResult<Response> {
122+ if !ctx.repo.releases_enabled {
123+ return Err(AppError::NotFound);
124+ }
125+ let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
126+ let release = state
127+ .store
128+ .release_by_tag(&ctx.repo.id, tag)
129+ .await?
130+ .ok_or(AppError::NotFound)?;
131+ if release.is_draft && !can_write {
132+ return Err(AppError::NotFound);
133+ }
134+ let all = state.store.list_releases(&ctx.repo.id).await?;
135+ let latest = latest_release_id(&all);
136+ let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
137+ let branches = branch_names(state, &ctx.repo.id).await;
138+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
139+ let card = release_card(
140+ state,
141+ &base,
142+ &release,
143+ latest.as_deref() == Some(&release.id),
144+ can_write,
145+ &chrome.csrf,
146+ )
147+ .await;
148+ let body = html! {
149+ (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches))
150+ p { a class="back-link" href=(format!("{base}/-/releases")) { "← Releases" } }
151+ (card)
152+ };
153+ let title = format!(
154+ "{} · {}/{}",
155+ release.name, ctx.owner.username, ctx.repo.path
156+ );
157+ Ok((jar, page(&chrome, &title, body)).into_response())
158+}
159+
160+/// Render one release: header (title, badges, tag/date/author), notes, assets,
161+/// and source archives.
162+async fn release_card(
163+ state: &AppState,
164+ base: &str,
165+ r: &store::Release,
166+ is_latest: bool,
167+ can_write: bool,
168+ csrf: &str,
169+) -> Markup {
170+ let author = match &r.author_id {
171+ Some(id) => crate::issues::username(state, id).await,
172+ None => "unknown".to_string(),
173+ };
174+ let assets = state
175+ .store
176+ .list_release_assets(&r.id)
177+ .await
178+ .unwrap_or_default();
179+ html! {
180+ section class="release card" {
181+ div class="release-head" {
182+ div {
183+ h2 class="release-title" {
184+ a href=(format!("{base}/-/releases/{}", r.tag)) { (r.name) }
185+ @if is_latest { " " span class="badge badge-verified" { "Latest" } }
186+ @if r.is_prerelease { " " span class="badge badge-archived" { "pre-release" } }
187+ @if r.is_draft { " " span class="badge badge-private" { "draft" } }
188+ }
189+ div class="release-meta muted" {
190+ (icon(Icon::Box)) " " (r.tag)
191+ " · " (author) " · " (crate::repo::fmt_date(r.created_at))
192+ }
193+ }
194+ @if can_write {
195+ div class="release-actions" {
196+ a class="btn" href=(format!("/release/{}/edit", r.id)) { "Edit" }
197+ form method="post" action=(format!("/release/{}/delete", r.id)) class="inline-form" {
198+ input type="hidden" name="_csrf" value=(csrf);
199+ button class="btn btn-danger" type="submit" { "Delete" }
200+ }
201+ }
202+ }
203+ }
204+ @if let Some(body) = &r.body {
205+ @if !body.trim().is_empty() {
206+ div class="release-notes markdown" { (markdown::render_with_refs(body, base)) }
207+ }
208+ }
209+ div class="release-assets" {
210+ h3 { "Assets" }
211+ ul class="asset-list" {
212+ @for a in &assets {
213+ li class="asset-row" {
214+ (icon(Icon::File))
215+ a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) }
216+ span class="asset-size muted" { (fmt_bytes(a.size)) }
217+ @if can_write {
218+ form method="post" action=(format!("/release-asset/{}/delete", a.id)) class="inline-form" {
219+ input type="hidden" name="_csrf" value=(csrf);
220+ button class="icon-btn" type="submit" aria-label="Delete asset" title="Delete asset" { (icon(Icon::X)) }
221+ }
222+ }
223+ }
224+ }
225+ li class="asset-row source" {
226+ (icon(Icon::Download))
227+ a class="asset-name" href=(format!("{base}/-/archive/{}.zip", r.tag)) { "Source code (zip)" }
228+ }
229+ li class="asset-row source" {
230+ (icon(Icon::Download))
231+ a class="asset-name" href=(format!("{base}/-/archive/{}.tar.gz", r.tag)) { "Source code (tar.gz)" }
232+ }
233+ }
234+ @if can_write {
235+ form method="post" action=(format!("/release/{}/asset", r.id)) enctype="multipart/form-data" class="asset-upload" {
236+ input type="hidden" name="_csrf" value=(csrf);
237+ input type="file" name="asset" required;
238+ button class="btn inline-btn" type="submit" { "Upload asset" }
239+ }
240+ }
241+ }
242+ }
243+ }
244+}
245+
246+// ---- Draft / create / edit ----
247+
248+/// `GET …/-/releases/new` — the draft form.
249+pub(crate) async fn new_form(
250+ state: &AppState,
251+ viewer: Option<User>,
252+ jar: CookieJar,
253+ uri: &Uri,
254+ ctx: RepoCtx,
255+) -> AppResult<Response> {
256+ if !ctx.repo.releases_enabled || viewer.is_none() || ctx.access < auth::Access::Write {
257+ return Err(AppError::NotFound);
258+ }
259+ let branches = branch_names(state, &ctx.repo.id).await;
260+ let tags: Vec<String> = git_read(state, &ctx.repo.id, git::Repo::tags)
261+ .await
262+ .unwrap_or_default()
263+ .into_iter()
264+ .map(|t| t.name)
265+ .collect();
266+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
267+ let body = html! {
268+ (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches))
269+ (release_form(
270+ &format!("/release-new/{}", ctx.repo.id),
271+ &chrome.csrf,
272+ None,
273+ &branches,
274+ &tags,
275+ ))
276+ };
277+ Ok((jar, page(&chrome, "New release", body)).into_response())
278+}
279+
280+/// `GET /release/{id}/edit` — edit an existing release (a focused form page).
281+pub async fn edit_form(
282+ State(state): State<AppState>,
283+ MaybeUser(viewer): MaybeUser,
284+ jar: CookieJar,
285+ uri: Uri,
286+ Path(id): Path<String>,
287+) -> AppResult<Response> {
288+ let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
289+ let owner = state
290+ .store
291+ .user_by_id(&repo.owner_id)
292+ .await?
293+ .map_or(repo.owner_id.clone(), |u| u.username);
294+ let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag);
295+ let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
296+ let body = html! {
297+ p { a class="back-link" href=(back) { "← Back to release" } }
298+ (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[]))
299+ };
300+ Ok((jar, page(&chrome, "Edit release", body)).into_response())
301+}
302+
303+/// The create/edit form.
304+fn release_form(
305+ action: &str,
306+ csrf: &str,
307+ existing: Option<&store::Release>,
308+ branches: &[String],
309+ tags: &[String],
310+) -> Markup {
311+ html! {
312+ h2 { @if existing.is_some() { "Edit release" } @else { "New release" } }
313+ div class="card" {
314+ form method="post" action=(action) {
315+ input type="hidden" name="_csrf" value=(csrf);
316+ @if let Some(r) = existing {
317+ label { "Tag" }
318+ p class="muted" { (r.tag) }
319+ } @else {
320+ label for="rl-tag" { "Tag" }
321+ input type="text" id="rl-tag" name="tag" list="rl-tags"
322+ placeholder="v1.0.0" required autocomplete="off";
323+ datalist id="rl-tags" { @for t in tags { option value=(t) {} } }
324+ label for="rl-target" { "Target (for a new tag)" }
325+ select id="rl-target" name="target" {
326+ @for b in branches { option value=(b) { (b) } }
327+ }
328+ p class="muted field-hint" { "An existing tag is used as-is; a new tag is created from the target branch." }
329+ }
330+ label for="rl-title" { "Release title" }
331+ input type="text" id="rl-title" name="name" value=(existing.map_or("", |r| r.name.as_str()));
332+ label for="rl-body" { "Release notes" }
333+ textarea id="rl-body" name="body" rows="10" placeholder="Describe this release (markdown supported)" {
334+ (existing.and_then(|r| r.body.as_deref()).unwrap_or(""))
335+ }
336+ label class="checkbox" {
337+ input type="checkbox" name="prerelease" value="1"
338+ checked[existing.is_some_and(|r| r.is_prerelease)];
339+ " This is a pre-release"
340+ }
341+ div class="release-form-actions" {
342+ button class="btn btn-primary inline-btn" type="submit" name="action" value="publish" {
343+ @if existing.is_some() { "Update release" } @else { "Publish release" }
344+ }
345+ @if existing.is_none_or(|r| r.is_draft) {
346+ button class="btn inline-btn" type="submit" name="action" value="draft" { "Save draft" }
347+ }
348+ }
349+ }
350+ }
351+ }
352+}
353+
354+/// Create/edit form body.
355+#[derive(Debug, Deserialize)]
356+pub struct ReleaseForm {
357+ #[serde(default)]
358+ tag: String,
359+ #[serde(default)]
360+ target: String,
361+ #[serde(default)]
362+ name: String,
363+ #[serde(default)]
364+ body: String,
365+ #[serde(default)]
366+ prerelease: Option<String>,
367+ #[serde(default)]
368+ action: String,
369+ #[serde(rename = "_csrf")]
370+ csrf: String,
371+}
372+
373+/// `POST /release-new/{repo_id}` — create a release (and its tag if new).
374+pub async fn create(
375+ State(state): State<AppState>,
376+ MaybeUser(viewer): MaybeUser,
377+ jar: CookieJar,
378+ headers: HeaderMap,
379+ Path(repo_id): Path<String>,
380+ axum::Form(form): axum::Form<ReleaseForm>,
381+) -> Response {
382+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
383+ return AppError::Forbidden.into_response();
384+ }
385+ let result = async {
386+ let (repo, user) = writer_repo(&state, viewer.as_ref(), &repo_id).await?;
387+ if !repo.releases_enabled {
388+ return Err(AppError::NotFound);
389+ }
390+ let tag = form.tag.trim();
391+ if tag.is_empty() {
392+ return Err(AppError::BadRequest("A tag is required.".to_string()));
393+ }
394+ // Use the tag if it exists; otherwise create it from the target branch.
395+ let exists = git_read(&state, &repo.id, {
396+ let tag = tag.to_string();
397+ move |g| Ok(g.resolve_ref(&tag).is_ok())
398+ })
399+ .await
400+ .unwrap_or(false);
401+ if !exists {
402+ let target = if form.target.trim().is_empty() {
403+ repo.default_branch.clone()
404+ } else {
405+ form.target.trim().to_string()
406+ };
407+ let tag_owned = tag.to_string();
408+ git_read(&state, &repo.id, move |g| {
409+ let rev = g.resolve_ref(&target)?;
410+ g.create_tag(&tag_owned, &rev)
411+ })
412+ .await
413+ .map_err(|_| {
414+ AppError::BadRequest("Could not create the tag from that target.".to_string())
415+ })?;
416+ }
417+ let name = if form.name.trim().is_empty() {
418+ tag.to_string()
419+ } else {
420+ form.name.trim().to_string()
421+ };
422+ let body = form.body.trim();
423+ state
424+ .store
425+ .create_release(store::NewRelease {
426+ repo_id: repo.id.clone(),
427+ tag: tag.to_string(),
428+ name,
429+ body: (!body.is_empty()).then(|| body.to_string()),
430+ is_prerelease: form.prerelease.is_some(),
431+ is_draft: form.action == "draft",
432+ author_id: user.id.clone(),
433+ })
434+ .await
435+ .map_err(|e| match e {
436+ store::StoreError::Conflict { .. } => {
437+ AppError::BadRequest("A release already exists for that tag.".to_string())
438+ }
439+ other => AppError::from(other),
440+ })?;
441+ Ok::<String, AppError>(format!("/{}/{}/-/releases/{tag}", user.username, repo.path))
442+ }
443+ .await;
444+ crate::issues::respond_redirect(result)
445+}
446+
447+/// `POST /release/{id}/edit` — update a release's editable fields.
448+pub async fn edit(
449+ State(state): State<AppState>,
450+ MaybeUser(viewer): MaybeUser,
451+ jar: CookieJar,
452+ headers: HeaderMap,
453+ Path(id): Path<String>,
454+ axum::Form(form): axum::Form<ReleaseForm>,
455+) -> Response {
456+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
457+ return AppError::Forbidden.into_response();
458+ }
459+ let result = async {
460+ let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
461+ let name = if form.name.trim().is_empty() {
462+ release.tag.clone()
463+ } else {
464+ form.name.trim().to_string()
465+ };
466+ let body = form.body.trim();
467+ state
468+ .store
469+ .update_release(
470+ &release.id,
471+ &name,
472+ (!body.is_empty()).then_some(body),
473+ form.prerelease.is_some(),
474+ release.is_draft && form.action == "draft",
475+ )
476+ .await?;
477+ Ok::<String, AppError>(format!(
478+ "/{}/{}/-/releases/{}",
479+ user.username, repo.path, release.tag
480+ ))
481+ }
482+ .await;
483+ crate::issues::respond_redirect(result)
484+}
485+
486+/// The CSRF-only form for asset/release deletion.
487+#[derive(Debug, Deserialize)]
488+pub struct BareForm {
489+ #[serde(rename = "_csrf")]
490+ csrf: String,
491+}
492+
493+/// `POST /release/{id}/delete` — delete a release and its asset files.
494+pub async fn delete(
495+ State(state): State<AppState>,
496+ MaybeUser(viewer): MaybeUser,
497+ jar: CookieJar,
498+ headers: HeaderMap,
499+ Path(id): Path<String>,
500+ axum::Form(form): axum::Form<BareForm>,
501+) -> Response {
502+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
503+ return AppError::Forbidden.into_response();
504+ }
505+ let result = async {
506+ let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
507+ for a in state.store.list_release_assets(&release.id).await? {
508+ let _ = tokio::fs::remove_file(assets_dir(&state).join(&a.id)).await;
509+ }
510+ state.store.delete_release(&release.id).await?;
511+ Ok::<String, AppError>(format!("/{}/{}/-/releases", user.username, repo.path))
512+ }
513+ .await;
514+ crate::issues::respond_redirect(result)
515+}
516+
517+// ---- Assets ----
518+
519+/// Per-process counter for unique upload temp-file names.
520+static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
521+
522+/// `POST /release/{id}/asset` — upload one asset (streamed to disk).
523+pub async fn asset_upload(
524+ State(state): State<AppState>,
525+ MaybeUser(viewer): MaybeUser,
526+ jar: CookieJar,
527+ Path(id): Path<String>,
528+ mut multipart: Multipart,
529+) -> Response {
530+ let mut csrf: Option<String> = None;
531+ let mut saved: Option<(String, String, i64, String)> = None; // (tmp_name, name, size, ctype)
532+ while let Ok(Some(mut field)) = multipart.next_field().await {
533+ match field.name() {
534+ Some("_csrf") => csrf = field.text().await.ok(),
535+ Some("asset") => {
536+ let name = field
537+ .file_name()
538+ .map(str::to_string)
539+ .filter(|n| !n.is_empty());
540+ let ctype = field
541+ .content_type()
542+ .map_or_else(|| "application/octet-stream".to_string(), str::to_string);
543+ if let Some(name) = name {
544+ let tmp_name = format!(
545+ "tmp-{}-{}",
546+ std::process::id(),
547+ TMP_SEQ.fetch_add(1, Ordering::Relaxed)
548+ );
549+ let tmp_dir = assets_dir(&state).join("tmp");
550+ if tokio::fs::create_dir_all(&tmp_dir).await.is_err() {
551+ return AppError::internal("storage error").into_response();
552+ }
553+ let tmp_path = tmp_dir.join(&tmp_name);
554+ let Ok(size) = stream_field(&mut field, &tmp_path).await else {
555+ let _ = tokio::fs::remove_file(&tmp_path).await;
556+ return AppError::internal("upload failed").into_response();
557+ };
558+ saved = Some((tmp_name, name, size, ctype));
559+ }
560+ }
561+ _ => {}
562+ }
563+ }
564+ if crate::session::verify_csrf(&jar, &HeaderMap::new(), csrf.as_deref()).is_err() {
565+ return AppError::Forbidden.into_response();
566+ }
567+ let result = async {
568+ let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
569+ if let Some((tmp_name, name, size, ctype)) = saved {
570+ let asset = state
571+ .store
572+ .add_release_asset(&release.id, &name, size, &ctype)
573+ .await?;
574+ let dest = assets_dir(&state).join(&asset.id);
575+ let tmp = assets_dir(&state).join("tmp").join(&tmp_name);
576+ if tokio::fs::rename(&tmp, &dest).await.is_err() {
577+ let _ = tokio::fs::remove_file(&tmp).await;
578+ let _ = state.store.delete_release_asset(&asset.id).await;
579+ return Err(AppError::internal("storage error"));
580+ }
581+ }
582+ Ok::<String, AppError>(format!(
583+ "/{}/{}/-/releases/{}",
584+ user.username, repo.path, release.tag
585+ ))
586+ }
587+ .await;
588+ match result {
589+ Ok(dest) => Redirect::to(&dest).into_response(),
590+ Err(e) => e.into_response(),
591+ }
592+}
593+
594+/// `GET /release-asset/{id}` — download an asset.
595+pub async fn asset_download(
596+ State(state): State<AppState>,
597+ MaybeUser(viewer): MaybeUser,
598+ Path(id): Path<String>,
599+) -> AppResult<Response> {
600+ let asset = state
601+ .store
602+ .release_asset_by_id(&id)
603+ .await?
604+ .ok_or(AppError::NotFound)?;
605+ let release = state
606+ .store
607+ .release_by_id(&asset.release_id)
608+ .await?
609+ .ok_or(AppError::NotFound)?;
610+ // The viewer must be able to read the repo the release belongs to.
611+ let _ = require_readable_repo(&state, viewer.as_ref(), &release.repo_id).await?;
612+ let path = assets_dir(&state).join(&asset.id);
613+ let Ok(file) = tokio::fs::File::open(&path).await else {
614+ return Err(AppError::NotFound);
615+ };
616+ let _ = state.store.bump_asset_download(&asset.id).await;
617+ let disposition = format!("attachment; filename=\"{}\"", asset.name.replace('"', ""));
618+ Ok((
619+ [
620+ (header::CONTENT_TYPE, asset.content_type.clone()),
621+ (header::CONTENT_DISPOSITION, disposition),
622+ ],
623+ Body::from_stream(ReaderStream::new(file)),
624+ )
625+ .into_response())
626+}
627+
628+/// `POST /release-asset/{id}/delete` — delete one asset.
629+pub async fn asset_delete(
630+ State(state): State<AppState>,
631+ MaybeUser(viewer): MaybeUser,
632+ jar: CookieJar,
633+ headers: HeaderMap,
634+ Path(id): Path<String>,
635+ axum::Form(form): axum::Form<BareForm>,
636+) -> Response {
637+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
638+ return AppError::Forbidden.into_response();
639+ }
640+ let result = async {
641+ let asset = state
642+ .store
643+ .release_asset_by_id(&id)
644+ .await?
645+ .ok_or(AppError::NotFound)?;
646+ let (release, repo, user) =
647+ writer_release(&state, viewer.as_ref(), &asset.release_id).await?;
648+ let _ = tokio::fs::remove_file(assets_dir(&state).join(&asset.id)).await;
649+ state.store.delete_release_asset(&asset.id).await?;
650+ Ok::<String, AppError>(format!(
651+ "/{}/{}/-/releases/{}",
652+ user.username, repo.path, release.tag
653+ ))
654+ }
655+ .await;
656+ crate::issues::respond_redirect(result)
657+}
658+
659+// ---- Source archives ----
660+
661+/// `GET …/-/archive/{filename}` — stream a source archive (`{rev}.zip` /
662+/// `{rev}.tar.gz`).
663+pub(crate) async fn archive(state: &AppState, ctx: RepoCtx, filename: &str) -> Response {
664+ let (rev, format) = if let Some(rev) = filename.strip_suffix(".tar.gz") {
665+ (rev, "tar.gz")
666+ } else if let Some(rev) = filename.strip_suffix(".zip") {
667+ (rev, "zip")
668+ } else {
669+ return StatusCode::NOT_FOUND.into_response();
670+ };
671+ let rev_owned = rev.to_string();
672+ if git_read(state, &ctx.repo.id, move |g| g.resolve_ref(&rev_owned))
673+ .await
674+ .is_err()
675+ {
676+ return StatusCode::NOT_FOUND.into_response();
677+ }
678+ let Ok(repo_dir) = git::repo_path(&state.config.storage.repo_dir, &ctx.repo.id) else {
679+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
680+ };
681+ let home = state.config.storage.data_dir.join("tmp");
682+ let leaf = ctx.repo.path.rsplit('/').next().unwrap_or(&ctx.repo.path);
683+ let prefix = format!("{leaf}-{rev}");
684+ let Ok(mut child) = git::pack::archive(
685+ &state.config.git.binary,
686+ &repo_dir,
687+ format,
688+ rev,
689+ &prefix,
690+ &home,
691+ ) else {
692+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
693+ };
694+ let Some(stdout) = child.stdout.take() else {
695+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
696+ };
697+ tokio::spawn(async move {
698+ let _ = child.wait().await;
699+ });
700+ let ctype = if format == "zip" {
701+ "application/zip"
702+ } else {
703+ "application/gzip"
704+ };
705+ let disposition = format!("attachment; filename=\"{prefix}.{format}\"");
706+ (
707+ [
708+ (header::CONTENT_TYPE, ctype.to_string()),
709+ (header::CONTENT_DISPOSITION, disposition),
710+ ],
711+ Body::from_stream(ReaderStream::new(stdout)),
712+ )
713+ .into_response()
714+}
715+
716+// ---- Helpers ----
717+
718+/// Stream a multipart field to `path`, returning the byte count.
719+async fn stream_field(
720+ field: &mut axum::extract::multipart::Field<'_>,
721+ path: &FsPath,
722+) -> std::io::Result<i64> {
723+ let mut file = tokio::fs::File::create(path).await?;
724+ let mut size: i64 = 0;
725+ while let Some(chunk) = field.try_next().await.map_err(std::io::Error::other)? {
726+ file.write_all(&chunk).await?;
727+ size += i64::try_from(chunk.len()).unwrap_or(0);
728+ }
729+ file.flush().await?;
730+ Ok(size)
731+}
732+
733+/// Resolve a repo by id and require the viewer to have write access.
734+async fn writer_repo(
735+ state: &AppState,
736+ viewer: Option<&User>,
737+ repo_id: &str,
738+) -> Result<(model::Repo, User), AppError> {
739+ let user = viewer.cloned().ok_or(AppError::Forbidden)?;
740+ let repo = require_readable_repo(state, Some(&user), repo_id).await?;
741+ if crate::issues::access_for(state, Some(&user), &repo).await? < auth::Access::Write {
742+ return Err(AppError::NotFound);
743+ }
744+ Ok((repo, user))
745+}
746+
747+/// Load a release by id and require write access on its repo.
748+async fn writer_release(
749+ state: &AppState,
750+ viewer: Option<&User>,
751+ release_id: &str,
752+) -> Result<(store::Release, model::Repo, User), AppError> {
753+ let release = state
754+ .store
755+ .release_by_id(release_id)
756+ .await?
757+ .ok_or(AppError::NotFound)?;
758+ let (repo, user) = writer_repo(state, viewer, &release.repo_id).await?;
759+ Ok((release, repo, user))
760+}
761+
762+/// Human-readable byte size.
763+fn fmt_bytes(bytes: i64) -> String {
764+ const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
765+ #[allow(clippy::cast_precision_loss)]
766+ let mut v = bytes.max(0) as f64;
767+ let mut u = 0;
768+ while v >= 1024.0 && u < UNITS.len() - 1 {
769+ v /= 1024.0;
770+ u += 1;
771+ }
772+ if u == 0 {
773+ format!("{bytes} B")
774+ } else {
775+ format!("{v:.1} {}", UNITS[u])
776+ }
777+}
crates/web/src/repo.rs +22 −1
@@ -65,6 +65,7 @@ pub(crate) enum Tab {
6565 Commits,
6666 Branches,
6767 Tags,
68+ Releases,
6869 Search,
6970 Settings,
7071 }
@@ -710,6 +711,12 @@ async fn dispatch_view(
710711 },
711712 }
712713 }
714+ "releases" => match rest {
715+ "" => crate::releases::list(state, viewer, jar, uri, ctx).await,
716+ "new" => crate::releases::new_form(state, viewer, jar, uri, ctx).await,
717+ tag => crate::releases::view(state, viewer, jar, uri, ctx, tag).await,
718+ },
719+ "archive" => Ok(crate::releases::archive(state, ctx, rest).await),
713720 "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await,
714721 // Reserved `runs` slot returns 404 until built.
715722 _ => Err(AppError::NotFound),
@@ -1324,6 +1331,10 @@ async fn repo_settings_view(
13241331 input type="checkbox" name="pulls_enabled" value="1"
13251332 checked[ctx.repo.pulls_enabled]; " Pull requests"
13261333 }
1334+ label class="checkbox" {
1335+ input type="checkbox" name="releases_enabled" value="1"
1336+ checked[ctx.repo.releases_enabled]; " Releases"
1337+ }
13271338 }
13281339 div class="settings-actions" {
13291340 button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" }
@@ -1824,7 +1835,7 @@ pub async fn settings_mirror_sync(
18241835 }
18251836
18261837 /// Load a repo by id and require the viewer to be able to read it, else `404`.
1827-async fn require_readable_repo(
1838+pub(crate) async fn require_readable_repo(
18281839 state: &AppState,
18291840 viewer: Option<&User>,
18301841 repo_id: &str,
@@ -2100,6 +2111,9 @@ pub struct RepoGeneralForm {
21002111 /// Enable pull requests.
21012112 #[serde(default)]
21022113 pulls_enabled: Option<String>,
2114+ /// Enable releases.
2115+ #[serde(default)]
2116+ releases_enabled: Option<String>,
21032117 /// CSRF token.
21042118 #[serde(rename = "_csrf")]
21052119 csrf: String,
@@ -2148,6 +2162,10 @@ pub async fn settings_general(
21482162 .store
21492163 .set_feature_enabled(&repo.id, "pulls", form.pulls_enabled.is_some())
21502164 .await?;
2165+ state
2166+ .store
2167+ .set_feature_enabled(&repo.id, "releases", form.releases_enabled.is_some())
2168+ .await?;
21512169 let owner = state
21522170 .store
21532171 .user_by_id(&repo.owner_id)
@@ -3160,6 +3178,9 @@ pub(crate) fn repo_header(
31603178 (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}")))
31613179 (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
31623180 (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
3181+ @if ctx.repo.releases_enabled {
3182+ (tab(Tab::Releases, "Releases", Icon::Download, format!("{base}/-/releases")))
3183+ }
31633184 @if ctx.access == auth::Access::Admin {
31643185 (tab(Tab::Settings, "Settings", Icon::Settings, format!("{base}/-/settings")))
31653186 }
data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/HEAD +0 −0

No textual changes.

data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/config +0 −0

No textual changes.

data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/description +0 −0

No textual changes.

data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/hooks/README.sample +0 −0

No textual changes.

data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/hooks/post-receive +0 −0

No textual changes.

data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/hooks/pre-receive +0 −0

No textual changes.

data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/info/exclude +0 −0

No textual changes.