A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 13f864d416

13f864d416c4ab328bbb07bdc53867656d3ab678

parent: 0657f63f68

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T20:54:55Z

Add releases: tag-anchored notes and binary assets

Closes #8

release create/list/show/delete over SSH (create requires the tag
pushed; write access; archived repos refuse) with notes via
--notes/--file -/$EDITOR. Assets stream over SSH stdin/stdout
(release asset add/get/remove), stored under the bare repo dir in
gitbay-releases/<id>/ so backup, transfer, and delete carry them;
sha256 and size recorded, names validated, capped by the new
limits.max_asset_bytes (512MB default). Web: releases tab with
rendered notes, source tarball link, and streamed asset downloads
(migration 0015).
cmd/gitbay/main.go +22 −5
@@ -34,6 +34,7 @@ func main() {
3434 issueCmd(),
3535 milestoneCmd(),
3636 mrCmd(),
37 releaseCmd(),
3738 webCmd(),
3839 orgCmd(),
3940 group("profile", "user and org profiles",
@@ -56,10 +57,11 @@ func main() {
5657
5758 // passOpts describes how one CLI command maps onto the server command.
5859 type passOpts struct {
59 server []string // server-side command path
60 needsRepo bool // prepend inferred owner/name unless given
61 stdinOK bool // wire local stdin through (keys add, --file -)
62 editor string // open $EDITOR for a body when none given
60 server []string // server-side command path
61 needsRepo bool // prepend inferred owner/name unless given
62 stdinOK bool // wire local stdin through (keys add, --file -)
63 alwaysStdin bool // stdin is the payload (release asset add)
64 editor string // open $EDITOR for a body when none given
6365 }
6466
6567 // pass builds a passthrough command. Flags are parsed by the server, which
@@ -118,7 +120,7 @@ func runPass(o passOpts, args []string) int {
118120 }
119121 }
120122 if stdin == nil || isEmptyReader(stdin) {
121 if o.stdinOK && usesStdin(args) {
123 if o.alwaysStdin || (o.stdinOK && usesStdin(args)) {
122124 stdin = os.Stdin
123125 }
124126 }
@@ -277,6 +279,21 @@ func issueCmd() *cobra.Command {
277279 )
278280 }
279281
282func releaseCmd() *cobra.Command {
283 return group("release", "tag-anchored releases with notes and assets",
284 pass("create", "create a release on a pushed tag: <tag> [--title <t>] [--notes|--file -|$EDITOR]",
285 passOpts{server: []string{"release", "create"}, needsRepo: true, stdinOK: true, editor: "release"}),
286 pass("list", "list releases", passOpts{server: []string{"release", "list"}, needsRepo: true}),
287 pass("show", "show a release with assets: <tag>", passOpts{server: []string{"release", "show"}, needsRepo: true}),
288 pass("delete", "delete a release and its assets: <tag> --yes", passOpts{server: []string{"release", "delete"}, needsRepo: true}),
289 group("asset", "binary assets on a release",
290 pass("add", "upload from stdin: <tag> <filename> < file", passOpts{server: []string{"release", "asset", "add"}, needsRepo: true, alwaysStdin: true}),
291 pass("get", "download to stdout: <tag> <filename> > file", passOpts{server: []string{"release", "asset", "get"}, needsRepo: true}),
292 pass("remove", "remove an asset: <tag> <filename>", passOpts{server: []string{"release", "asset", "remove"}, needsRepo: true}),
293 ),
294 )
295}
296
280297 func milestoneCmd() *cobra.Command {
281298 return group("milestone", "group issues and MRs toward a release",
282299 pass("create", "create a milestone: <title> [--description <d>] [--due YYYY-MM-DD]",
docs/admin.org +1
@@ -95,6 +95,7 @@ contradiction; =--no-host-checks= skips port/path probes.
9595 ** [limits]
9696 - =clone_timeout= (3600s) — cap on =repo import= fetches.
9797 - =max_blob_bytes= (100MB) — cap on raw file serving over the web.
98 =max_asset_bytes= (512MB) — cap per uploaded release asset.
9899 - =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced.
99100
100101 ** [git_daemon]
docs/users.org +13
@@ -158,6 +158,19 @@ that is why no =owner/name= appears above. Anywhere else, pass it as the
158158 first argument. Long text: =--body= inline, =--file -= from stdin, or
159159 neither on a terminal and =$EDITOR= opens.
160160
161Releases anchor notes and binary assets to a pushed tag (write access;
162assets stream over SSH, capped by the instance's =max_asset_bytes=):
163
164#+begin_src sh
165gitbay release create v1.0 --title "First light" [--notes|--file -|$EDITOR]
166gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64
167gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link
168gitbay release list / show v1.0 / delete v1.0 --yes
169#+end_src
170
171The web shows them under the repository's =releases= tab with rendered
172notes, sha256 sums, and download links.
173
161174 Commit messages act on issues when the commits land on the default
162175 branch (direct push or MR merge): =closes/fixes/resolves #4= closes the
163176 issue with a linking comment, and a bare =#4= leaves a reference
e2e/release_test.go added +121
@@ -0,0 +1,121 @@
1package e2e
2
3import (
4 "fmt"
5 "net/http"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12func TestReleases(t *testing.T) {
13 inst := startInstance(t)
14 aliceKey := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
16
17 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
18 t.Fatalf("repo create: %s", errOut)
19 }
20 work := t.TempDir()
21 env := inst.gitEnv(aliceKey)
22 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
23 dir := filepath.Join(work, "w")
24 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
25 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
26 mustGit(t, dir, env, "add", ".")
27 mustGit(t, dir, env, "commit", "-q", "-m", "base")
28 mustGit(t, dir, env, "tag", "-a", "v1.0", "-m", "first")
29 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0")
30
31 // Create: tag must exist; duplicates refused.
32 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/app", "v9.9"); code != 3 || !strings.Contains(errOut, "push the tag first") {
33 t.Fatalf("missing tag: exit %d, %s", code, errOut)
34 }
35 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/app", "v1.0",
36 "--title", "'First light'", "--notes", "'the **first** release'"); code != 0 {
37 t.Fatalf("release create: %s", errOut)
38 }
39 if _, _, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/app", "v1.0"); code != 2 {
40 t.Fatal("duplicate release accepted")
41 }
42
43 // Assets: upload from stdin, validation, dedup, round-trip.
44 payload := "BINARY\x01\x02payload for the release asset\n"
45 if _, errOut, code := inst.ssh(t, aliceKey, payload, "release", "asset", "add", "alice/app", "v1.0", "tool-linux-amd64"); code != 0 {
46 t.Fatalf("asset add: %s", errOut)
47 }
48 if _, _, code := inst.ssh(t, aliceKey, payload, "release", "asset", "add", "alice/app", "v1.0", "tool-linux-amd64"); code != 2 {
49 t.Fatal("duplicate asset accepted")
50 }
51 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "asset", "add", "alice/app", "v1.0", "empty-file"); code != 2 || !strings.Contains(errOut, "empty asset") {
52 t.Fatalf("empty asset: exit %d, %s", code, errOut)
53 }
54 if _, _, code := inst.ssh(t, aliceKey, "x", "release", "asset", "add", "alice/app", "v1.0", "../evil"); code != 2 {
55 t.Fatal("bad asset name accepted")
56 }
57 got, _, code := inst.ssh(t, aliceKey, "", "release", "asset", "get", "alice/app", "v1.0", "tool-linux-amd64")
58 if code != 0 || got != payload {
59 t.Fatalf("asset round-trip: exit %d, %q", code, got)
60 }
61 out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json")
62 if !strings.Contains(out, `"name":"tool-linux-amd64"`) ||
63 !strings.Contains(out, fmt.Sprintf(`"size":%d`, len(payload))) ||
64 !strings.Contains(out, `"sha256":"`) || !strings.Contains(out, "First light") {
65 t.Fatalf("release show: %s", out)
66 }
67
68 // Web: page renders notes and assets; download streams exact bytes.
69 status, body := inst.get(t, "/alice/app/releases")
70 if status != 200 || !strings.Contains(body, "First light") ||
71 !strings.Contains(body, "<strong>first</strong>") ||
72 !strings.Contains(body, "tool-linux-amd64") {
73 t.Fatalf("releases page: %d\n%s", status, body)
74 }
75 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/app/releases/download/v1.0/tool-linux-amd64", inst.httpPort))
76 if err != nil {
77 t.Fatal(err)
78 }
79 dl := make([]byte, len(payload)+10)
80 n, _ := resp.Body.Read(dl)
81 resp.Body.Close()
82 if resp.StatusCode != 200 || string(dl[:n]) != payload ||
83 !strings.Contains(resp.Header.Get("Content-Disposition"), "tool-linux-amd64") {
84 t.Fatalf("web download: %d, %q", resp.StatusCode, dl[:n])
85 }
86 if resp, _ := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/app/releases/download/v1.0/nope", inst.httpPort)); resp.StatusCode != 404 {
87 t.Fatalf("missing asset download: %d", resp.StatusCode)
88 }
89
90 // Remove, then delete: DB rows and disk both go.
91 if _, _, code := inst.ssh(t, aliceKey, "", "release", "asset", "remove", "alice/app", "v1.0", "tool-linux-amd64"); code != 0 {
92 t.Fatal("asset remove failed")
93 }
94 if _, _, code := inst.ssh(t, aliceKey, "", "release", "asset", "get", "alice/app", "v1.0", "tool-linux-amd64"); code != 3 {
95 t.Fatal("removed asset still served")
96 }
97 if _, _, code := inst.ssh(t, aliceKey, payload, "release", "asset", "add", "alice/app", "v1.0", "again"); code != 0 {
98 t.Fatal("re-add failed")
99 }
100 assetRoot := filepath.Join(inst.root, "repos", "alice", "app.git", "gitbay-releases")
101 if fis, err := os.ReadDir(assetRoot); err != nil || len(fis) == 0 {
102 t.Fatal("asset dir missing on disk")
103 }
104 if _, _, code := inst.ssh(t, aliceKey, "", "release", "delete", "alice/app", "v1.0", "--yes"); code != 0 {
105 t.Fatal("release delete failed")
106 }
107 if fis, _ := os.ReadDir(assetRoot); len(fis) != 0 {
108 t.Fatal("asset files survived release delete")
109 }
110 if out, _, _ := inst.ssh(t, aliceKey, "", "release", "list", "alice/app", "--json"); strings.Contains(out, "v1.0") {
111 t.Fatalf("deleted release listed: %s", out)
112 }
113
114 // Archived repos refuse release writes.
115 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "archive", "alice/app"); code != 0 {
116 t.Fatal("archive failed")
117 }
118 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/app", "v1.0"); code != 4 || !strings.Contains(errOut, "archived") {
119 t.Fatalf("archived release create: exit %d, %s", code, errOut)
120 }
121}
internal/config/config.go +2
@@ -83,6 +83,7 @@ type Webhooks struct {
8383 type Limits struct {
8484 MaxPackBytes int64 `toml:"max_pack_bytes"`
8585 MaxBlobBytes int64 `toml:"max_blob_bytes"`
86 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset
8687 CloneTimeoutSec int `toml:"clone_timeout"`
8788 SSHAuthRate int `toml:"ssh_auth_rate"`
8889 }
@@ -108,6 +109,7 @@ func Default() Config {
108109 Limits: Limits{
109110 MaxPackBytes: 2 << 30, // 2 GiB
110111 MaxBlobBytes: 100 << 20,
112 MaxAssetBytes: 512 << 20,
111113 CloneTimeoutSec: 3600,
112114 SSHAuthRate: 10,
113115 },
internal/control/release.go added +315
@@ -0,0 +1,315 @@
1package control
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "regexp"
12 "strconv"
13
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/policy"
16 "gitbay.org/gitbay/internal/protocol"
17 "gitbay.org/gitbay/internal/store"
18)
19
20func init() {
21 register(Command{Path: []string{"release", "create"},
22 Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]",
23 ReadsStdin: true, Run: runReleaseCreate})
24 register(Command{Path: []string{"release", "list"},
25 Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList})
26 register(Command{Path: []string{"release", "show"},
27 Summary: "show a release with assets: release show <owner/name> <tag>", ReadOnly: true, Run: runReleaseShow})
28 register(Command{Path: []string{"release", "delete"},
29 Summary: "delete a release and its assets: release delete <owner/name> <tag> --yes", Run: runReleaseDelete})
30 register(Command{Path: []string{"release", "asset", "add"},
31 Summary: "upload an asset from stdin: release asset add <owner/name> <tag> <filename> < file",
32 ReadsStdin: true, Run: runAssetAdd})
33 register(Command{Path: []string{"release", "asset", "get"},
34 Summary: "write an asset to stdout: release asset get <owner/name> <tag> <filename> > file",
35 ReadOnly: true, Run: runAssetGet})
36 register(Command{Path: []string{"release", "asset", "remove"},
37 Summary: "remove an asset: release asset remove <owner/name> <tag> <filename>", Run: runAssetRemove})
38}
39
40var assetNamePat = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$`)
41
42// assetDir holds a release's uploaded files inside the bare repo directory,
43// so backup, transfer, and delete all carry them automatically.
44func assetDir(root string, repo store.Repo, releaseID int64) string {
45 return filepath.Join(RepoDir(root, repo.OwnerName, repo.Name), "gitbay-releases", strconv.FormatInt(releaseID, 10))
46}
47
48// releaseRef loads a release for "<owner/name> <tag>" with the permission.
49func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Release, int) {
50 if len(args) < 2 {
51 return store.Repo{}, store.Release{}, c.fail(protocol.ExitUsage, "expected <owner/name> <tag>")
52 }
53 repo, code := resolveRepo(c, args[0], perm)
54 if code >= 0 {
55 return repo, store.Release{}, code
56 }
57 rel, err := c.Store.ReleaseByTag(repo.ID, args[1])
58 if errors.Is(err, store.ErrNotFound) {
59 return repo, rel, c.fail(protocol.ExitNotFound, "no release for tag %q in %s", args[1], repo.Path())
60 }
61 if err != nil {
62 return repo, rel, c.fail(protocol.ExitFailure, "%v", err)
63 }
64 return repo, rel, -1
65}
66
67func runReleaseCreate(c *Ctx, args []string) int {
68 var path, tag, title, notes, file string
69 for i := 0; i < len(args); i++ {
70 switch args[i] {
71 case "--title", "--notes", "--file":
72 if i+1 >= len(args) {
73 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
74 }
75 switch args[i] {
76 case "--title":
77 title = args[i+1]
78 case "--notes":
79 notes = args[i+1]
80 case "--file":
81 file = args[i+1]
82 }
83 i++
84 default:
85 if path == "" {
86 path = args[i]
87 } else if tag == "" {
88 tag = args[i]
89 } else {
90 return c.fail(protocol.ExitUsage, "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]")
91 }
92 }
93 }
94 if path == "" || tag == "" {
95 return c.fail(protocol.ExitUsage, "usage: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]")
96 }
97 repo, code := resolveRepo(c, path, policy.CanWrite)
98 if code >= 0 {
99 return code
100 }
101 if code := refuseArchived(c, repo); code >= 0 {
102 return code
103 }
104 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
105 if _, err := gitutil.ResolveRef(dir, "refs/tags/"+tag); err != nil {
106 return c.fail(protocol.ExitNotFound, "no tag %q in %s — push the tag first", tag, repo.Path())
107 }
108 body, err := bodyFrom(c, notes, file)
109 if err != nil {
110 return c.fail(protocol.ExitUsage, "%v", err)
111 }
112 if title == "" {
113 title = tag
114 }
115 if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID); err != nil {
116 return c.fail(protocol.ExitUsage, "%v", err)
117 }
118 c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
119 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
120 fmt.Fprintf(w, "created release %s on %s\n", tag, repo.Path())
121 })
122}
123
124type assetOut struct {
125 Name string `json:"name"`
126 Size int64 `json:"size"`
127 SHA256 string `json:"sha256"`
128}
129
130type releaseOut struct {
131 Tag string `json:"tag"`
132 Title string `json:"title"`
133 Notes string `json:"notes,omitempty"`
134 Author string `json:"author,omitempty"`
135 CreatedAt string `json:"created_at"`
136 Assets []assetOut `json:"assets,omitempty"`
137}
138
139func releaseToOut(r store.Release, withNotes bool) releaseOut {
140 o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt}
141 if withNotes {
142 o.Notes = r.Notes
143 }
144 for _, a := range r.Assets {
145 o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256})
146 }
147 return o
148}
149
150func runReleaseList(c *Ctx, args []string) int {
151 if len(args) != 1 {
152 return c.fail(protocol.ExitUsage, "usage: release list <owner/name>")
153 }
154 repo, code := resolveRepo(c, args[0], policy.CanRead)
155 if code >= 0 {
156 return code
157 }
158 rels, err := c.Store.ListReleases(repo.ID)
159 if err != nil {
160 return c.fail(protocol.ExitFailure, "%v", err)
161 }
162 var ds []releaseOut
163 for _, r := range rels {
164 ds = append(ds, releaseToOut(r, false))
165 }
166 return c.emit(ds, func(w io.Writer) {
167 for _, d := range ds {
168 fmt.Fprintf(w, "%s\t%s\t%d asset(s)\n", d.Tag, d.Title, len(d.Assets))
169 }
170 })
171}
172
173func runReleaseShow(c *Ctx, args []string) int {
174 _, rel, code := releaseRef(c, args, policy.CanRead)
175 if code >= 0 {
176 return code
177 }
178 d := releaseToOut(rel, true)
179 return c.emit(d, func(w io.Writer) {
180 fmt.Fprintf(w, "%s\t%s\tby %s on %s\n", d.Tag, d.Title, d.Author, d.CreatedAt)
181 if d.Notes != "" {
182 fmt.Fprintf(w, "\n%s\n", d.Notes)
183 }
184 for _, a := range d.Assets {
185 fmt.Fprintf(w, "%s\t%d\t%s\n", a.Name, a.Size, a.SHA256)
186 }
187 })
188}
189
190func runReleaseDelete(c *Ctx, args []string) int {
191 var rest []string
192 var yes bool
193 for _, a := range args {
194 if a == "--yes" {
195 yes = true
196 } else {
197 rest = append(rest, a)
198 }
199 }
200 repo, rel, code := releaseRef(c, rest, policy.CanAdmin)
201 if code >= 0 {
202 return code
203 }
204 if !yes {
205 return c.fail(protocol.ExitUsage, "release delete is permanent (assets included); re-run with --yes")
206 }
207 if err := c.Store.DeleteRelease(rel.ID); err != nil {
208 return c.fail(protocol.ExitFailure, "%v", err)
209 }
210 os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
211 return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
212 fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
213 })
214}
215
216func runAssetAdd(c *Ctx, args []string) int {
217 if len(args) != 3 {
218 return c.fail(protocol.ExitUsage, "usage: release asset add <owner/name> <tag> <filename> < file")
219 }
220 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
221 if code >= 0 {
222 return code
223 }
224 if code := refuseArchived(c, repo); code >= 0 {
225 return code
226 }
227 name := args[2]
228 if !assetNamePat.MatchString(name) {
229 return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
230 }
231 dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
232 if err := os.MkdirAll(dir, 0o750); err != nil {
233 return c.fail(protocol.ExitFailure, "%v", err)
234 }
235 tmp, err := os.CreateTemp(dir, ".upload-*")
236 if err != nil {
237 return c.fail(protocol.ExitFailure, "%v", err)
238 }
239 defer os.Remove(tmp.Name())
240 h := sha256.New()
241 limit := c.Cfg.Limits.MaxAssetBytes
242 n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
243 if err != nil {
244 return c.fail(protocol.ExitFailure, "reading asset: %v", err)
245 }
246 if n > limit {
247 return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
248 }
249 if n == 0 {
250 return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
251 }
252 if err := tmp.Close(); err != nil {
253 return c.fail(protocol.ExitFailure, "%v", err)
254 }
255 sum := hex.EncodeToString(h.Sum(nil))
256 if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
257 return c.fail(protocol.ExitUsage, "%v", err)
258 }
259 if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
260 c.Store.RemoveReleaseAsset(rel.ID, name)
261 return c.fail(protocol.ExitFailure, "%v", err)
262 }
263 return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
264 fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
265 })
266}
267
268func runAssetGet(c *Ctx, args []string) int {
269 if len(args) != 3 {
270 return c.fail(protocol.ExitUsage, "usage: release asset get <owner/name> <tag> <filename> > file")
271 }
272 repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
273 if code >= 0 {
274 return code
275 }
276 name := args[2]
277 if !assetNamePat.MatchString(name) {
278 return c.fail(protocol.ExitNotFound, "no asset %q", name)
279 }
280 f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
281 if err != nil {
282 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
283 }
284 defer f.Close()
285 if _, err := io.Copy(c.Stdout, f); err != nil {
286 return protocol.ExitFailure
287 }
288 return protocol.ExitOK
289}
290
291func runAssetRemove(c *Ctx, args []string) int {
292 if len(args) != 3 {
293 return c.fail(protocol.ExitUsage, "usage: release asset remove <owner/name> <tag> <filename>")
294 }
295 repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
296 if code >= 0 {
297 return code
298 }
299 if code := refuseArchived(c, repo); code >= 0 {
300 return code
301 }
302 name := args[2]
303 if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
304 if errors.Is(err, store.ErrNotFound) {
305 return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
306 }
307 return c.fail(protocol.ExitFailure, "%v", err)
308 }
309 if assetNamePat.MatchString(name) {
310 os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
311 }
312 return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
313 fmt.Fprintf(w, "removed %s\n", name)
314 })
315}
internal/httpd/routes.go +2
@@ -42,6 +42,8 @@ func (s *Server) Routes() []Route {
4242 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
4343 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search},
4444 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones},
45 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
46 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset},
4547 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
4648 Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log},
4749 Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log},
internal/httpd/web.go +69
@@ -4,6 +4,9 @@ import (
44 "bytes"
55 "fmt"
66 "hash/fnv"
7 "io"
8 "os"
9 "path/filepath"
710
811 "gitbay.org/gitbay/internal/policy"
912 "html/template"
@@ -383,6 +386,72 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
383386 }{p, cs, base, filePath, binary, len(data), codeHTML})
384387 }
385388
389// releases lists tag-anchored releases with notes and assets.
390func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
391 p, ok := s.repoFor(w, r, "")
392 if !ok {
393 return
394 }
395 p.Tab = "releases"
396 rels, err := s.st.ListReleases(p.Repo.ID)
397 if err != nil {
398 http.Error(w, "internal error", http.StatusInternalServerError)
399 return
400 }
401 md := s.ugcFor(r, p.Repo)
402 type relView struct {
403 store.Release
404 NotesHTML template.HTML
405 }
406 var views []relView
407 for _, rel := range rels {
408 views = append(views, relView{rel, md(rel.Notes)})
409 }
410 s.render(w, "releases.html", struct {
411 repoPage
412 Releases []relView
413 }{p, views})
414}
415
416// releaseAsset streams one uploaded asset. Tags containing '/' are not
417// reachable here (single path segment); SSH download always works.
418func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
419 p, ok := s.repoFor(w, r, "")
420 if !ok {
421 return
422 }
423 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
424 if err != nil {
425 s.notFound(w, r)
426 return
427 }
428 name := r.PathValue("name")
429 found := false
430 for _, a := range rel.Assets {
431 if a.Name == name {
432 found = true
433 }
434 }
435 if !found {
436 s.notFound(w, r)
437 return
438 }
439 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
440 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
441 if err != nil {
442 s.notFound(w, r)
443 return
444 }
445 defer f.Close()
446 w.Header().Set("Content-Type", "application/octet-stream")
447 w.Header().Set("X-Content-Type-Options", "nosniff")
448 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
449 if fi, err := f.Stat(); err == nil {
450 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
451 }
452 io.Copy(w, f)
453}
454
386455 // milestones lists a repo's milestones with progress.
387456 func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
388457 p, ok := s.repoFor(w, r, "")
internal/store/migrations/0015_releases.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TABLE release_assets;
2DROP TABLE releases;
internal/store/migrations/0015_releases.up.sql added +19
@@ -0,0 +1,19 @@
1CREATE TABLE releases (
2 id INTEGER PRIMARY KEY,
3 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
4 tag TEXT NOT NULL,
5 title TEXT NOT NULL DEFAULT '',
6 notes TEXT NOT NULL DEFAULT '',
7 author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
8 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
9 UNIQUE (repo_id, tag)
10);
11CREATE TABLE release_assets (
12 id INTEGER PRIMARY KEY,
13 release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
14 name TEXT NOT NULL,
15 size INTEGER NOT NULL,
16 sha256 TEXT NOT NULL,
17 uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
18 UNIQUE (release_id, name)
19);
internal/store/releases.go added +133
@@ -0,0 +1,133 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7)
8
9type Release struct {
10 ID int64
11 RepoID int64
12 Tag string
13 Title string
14 Notes string
15 Author string
16 CreatedAt string
17 Assets []ReleaseAsset
18}
19
20type ReleaseAsset struct {
21 ID int64
22 Name string
23 Size int64
24 SHA256 string
25 UploadedAt string
26}
27
28func (s *Store) CreateRelease(repoID int64, tag, title, notes string, authorID int64) (int64, error) {
29 res, err := s.DB.Exec(
30 "INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)",
31 repoID, tag, title, notes, authorID)
32 if err != nil {
33 if isUniqueErr(err) {
34 return 0, fmt.Errorf("a release for tag %q already exists", tag)
35 }
36 return 0, err
37 }
38 return res.LastInsertId()
39}
40
41const releaseSelect = `
42 SELECT r.id, r.repo_id, r.tag, r.title, r.notes, COALESCE(u.username, ''), r.created_at
43 FROM releases r LEFT JOIN users u ON u.id = r.author_id`
44
45func (s *Store) releaseAssets(rel *Release) error {
46 rows, err := s.DB.Query(
47 "SELECT id, name, size, sha256, uploaded_at FROM release_assets WHERE release_id = ? ORDER BY name",
48 rel.ID)
49 if err != nil {
50 return err
51 }
52 defer rows.Close()
53 for rows.Next() {
54 var a ReleaseAsset
55 if err := rows.Scan(&a.ID, &a.Name, &a.Size, &a.SHA256, &a.UploadedAt); err != nil {
56 return err
57 }
58 rel.Assets = append(rel.Assets, a)
59 }
60 return rows.Err()
61}
62
63func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) {
64 var r Release
65 err := s.DB.QueryRow(releaseSelect+" WHERE r.repo_id = ? AND r.tag = ?", repoID, tag).
66 Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.Author, &r.CreatedAt)
67 if errors.Is(err, sql.ErrNoRows) {
68 return r, ErrNotFound
69 }
70 if err != nil {
71 return r, err
72 }
73 return r, s.releaseAssets(&r)
74}
75
76// ListReleases returns releases newest-first, assets included.
77func (s *Store) ListReleases(repoID int64) ([]Release, error) {
78 rows, err := s.DB.Query(releaseSelect+" WHERE r.repo_id = ? ORDER BY r.created_at DESC", repoID)
79 if err != nil {
80 return nil, err
81 }
82 defer rows.Close()
83 var out []Release
84 for rows.Next() {
85 var r Release
86 if err := rows.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.Author, &r.CreatedAt); err != nil {
87 return nil, err
88 }
89 out = append(out, r)
90 }
91 if err := rows.Err(); err != nil {
92 return nil, err
93 }
94 for i := range out {
95 if err := s.releaseAssets(&out[i]); err != nil {
96 return nil, err
97 }
98 }
99 return out, nil
100}
101
102func (s *Store) DeleteRelease(id int64) error {
103 res, err := s.DB.Exec("DELETE FROM releases WHERE id = ?", id)
104 if err != nil {
105 return err
106 }
107 if n, _ := res.RowsAffected(); n == 0 {
108 return ErrNotFound
109 }
110 return nil
111}
112
113func (s *Store) AddReleaseAsset(releaseID int64, name string, size int64, sha256 string) error {
114 _, err := s.DB.Exec(
115 "INSERT INTO release_assets (release_id, name, size, sha256) VALUES (?, ?, ?, ?)",
116 releaseID, name, size, sha256)
117 if isUniqueErr(err) {
118 return fmt.Errorf("asset %q already exists on this release", name)
119 }
120 return err
121}
122
123func (s *Store) RemoveReleaseAsset(releaseID int64, name string) error {
124 res, err := s.DB.Exec(
125 "DELETE FROM release_assets WHERE release_id = ? AND name = ?", releaseID, name)
126 if err != nil {
127 return err
128 }
129 if n, _ := res.RowsAffected(); n == 0 {
130 return ErrNotFound
131 }
132 return nil
133}
internal/web/static/style.css +13
@@ -514,6 +514,19 @@ ul.issuelist p { margin: 0; }
514514 ul.issuelist .title a { color: var(--fg); font-weight: 550; }
515515 ul.issuelist .title a:hover { color: var(--accent); }
516516
517/* releases */
518article.release {
519 border: 1px solid var(--line);
520 border-radius: var(--r-lg);
521 padding: var(--sp-3) var(--sp-4);
522 margin: var(--sp-4) 0;
523}
524.releasehead h3 { margin: 0 0 var(--sp-1); }
525.releasehead .meta { margin: 0 0 var(--sp-2); }
526table.assets { margin-top: var(--sp-2); }
527table.assets td.size { color: var(--muted); white-space: nowrap; text-align: right; }
528table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); }
529
517530 /* milestones */
518531 ul.milestonelist {
519532 list-style: none;
internal/web/templates/layout.html +1
@@ -29,6 +29,7 @@
2929 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
3030 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
3131 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
32 <a {{if eq .Tab "releases"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases">releases</a>
3233 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
3334 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
3435 <a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a>
internal/web/templates/releases.html added +21
@@ -0,0 +1,21 @@
1{{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4<h2>releases</h2>
5{{range $rel := .Releases}}
6<article class="release">
7 <header class="releasehead">
8 <h3>{{$rel.Title}}</h3>
9 <p class="meta"><span class="refchip">{{$rel.Tag}}</span> {{if $rel.Author}}{{$rel.Author}} · {{end}}{{when $rel.CreatedAt}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/archive/{{$rel.Tag}}.tar.gz">source tar.gz</a></p>
10 </header>
11 {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}}
12 {{if $rel.Assets}}<table class="assets">
13 {{range $rel.Assets}}<tr>
14 <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td>
15 <td class="size">{{.Size}}</td>
16 <td class="sha"><code title="{{.SHA256}}">{{short .SHA256}}</code></td>
17 </tr>{{end}}
18 </table>{{end}}
19</article>
20{{else}}<p class="empty-note">no releases yet — tag a commit, push the tag, then <code>gitbay release create {{.Repo.OwnerName}}/{{.Repo.Name}} v1.0</code></p>{{end}}
21{{end}}