Commit 13f864d416

13f864d416c4ab328bbb07bdc53867656d3ab678

parent: 0657f63f68

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 20:54 UTC

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

Layout: unified · split

cmd/gitbay/main.go +22 −5
@@ -34,6 +34,7 @@ func main() {
34 issueCmd(), 34 issueCmd(),
35 milestoneCmd(), 35 milestoneCmd(),
36 mrCmd(), 36 mrCmd(),
37 releaseCmd(),
37 webCmd(), 38 webCmd(),
38 orgCmd(), 39 orgCmd(),
39 group("profile", "user and org profiles", 40 group("profile", "user and org profiles",
@@ -56,10 +57,11 @@ func main() {
56 57
57// passOpts describes how one CLI command maps onto the server command. 58// passOpts describes how one CLI command maps onto the server command.
58type passOpts struct { 59type passOpts struct {
59 server []string // server-side command path 60 server []string // server-side command path
60 needsRepo bool // prepend inferred owner/name unless given 61 needsRepo bool // prepend inferred owner/name unless given
61 stdinOK bool // wire local stdin through (keys add, --file -) 62 stdinOK bool // wire local stdin through (keys add, --file -)
62 editor string // open $EDITOR for a body when none given 63 alwaysStdin bool // stdin is the payload (release asset add)
64 editor string // open $EDITOR for a body when none given
63} 65}
64 66
65// pass builds a passthrough command. Flags are parsed by the server, which 67// pass builds a passthrough command. Flags are parsed by the server, which
@@ -118,7 +120,7 @@ func runPass(o passOpts, args []string) int {
118 } 120 }
119 } 121 }
120 if stdin == nil || isEmptyReader(stdin) { 122 if stdin == nil || isEmptyReader(stdin) {
121 if o.stdinOK && usesStdin(args) { 123 if o.alwaysStdin || (o.stdinOK && usesStdin(args)) {
122 stdin = os.Stdin 124 stdin = os.Stdin
123 } 125 }
124 } 126 }
@@ -277,6 +279,21 @@ func issueCmd() *cobra.Command {
277 ) 279 )
278} 280}
279 281
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
280func milestoneCmd() *cobra.Command { 297func milestoneCmd() *cobra.Command {
281 return group("milestone", "group issues and MRs toward a release", 298 return group("milestone", "group issues and MRs toward a release",
282 pass("create", "create a milestone: <title> [--description <d>] [--due YYYY-MM-DD]", 299 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.
95** [limits] 95** [limits]
96- =clone_timeout= (3600s) — cap on =repo import= fetches. 96- =clone_timeout= (3600s) — cap on =repo import= fetches.
97- =max_blob_bytes= (100MB) — cap on raw file serving over the web. 97- =max_blob_bytes= (100MB) — cap on raw file serving over the web.
98- =max_asset_bytes= (512MB) — cap per uploaded release asset.
98- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced. 99- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced.
99 100
100** [git_daemon] 101** [git_daemon]
docs/users.org +13
@@ -158,6 +158,19 @@ that is why no =owner/name= appears above. Anywhere else, pass it as the
158first argument. Long text: =--body= inline, =--file -= from stdin, or 158first argument. Long text: =--body= inline, =--file -= from stdin, or
159neither on a terminal and =$EDITOR= opens. 159neither on a terminal and =$EDITOR= opens.
160 160
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
161Commit messages act on issues when the commits land on the default 174Commit messages act on issues when the commits land on the default
162branch (direct push or MR merge): =closes/fixes/resolves #4= closes the 175branch (direct push or MR merge): =closes/fixes/resolves #4= closes the
163issue with a linking comment, and a bare =#4= leaves a reference 176issue 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 {
83type Limits struct { 83type Limits struct {
84 MaxPackBytes int64 `toml:"max_pack_bytes"` 84 MaxPackBytes int64 `toml:"max_pack_bytes"`
85 MaxBlobBytes int64 `toml:"max_blob_bytes"` 85 MaxBlobBytes int64 `toml:"max_blob_bytes"`
86 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset
86 CloneTimeoutSec int `toml:"clone_timeout"` 87 CloneTimeoutSec int `toml:"clone_timeout"`
87 SSHAuthRate int `toml:"ssh_auth_rate"` 88 SSHAuthRate int `toml:"ssh_auth_rate"`
88} 89}
@@ -108,6 +109,7 @@ func Default() Config {
108 Limits: Limits{ 109 Limits: Limits{
109 MaxPackBytes: 2 << 30, // 2 GiB 110 MaxPackBytes: 2 << 30, // 2 GiB
110 MaxBlobBytes: 100 << 20, 111 MaxBlobBytes: 100 << 20,
112 MaxAssetBytes: 512 << 20,
111 CloneTimeoutSec: 3600, 113 CloneTimeoutSec: 3600,
112 SSHAuthRate: 10, 114 SSHAuthRate: 10,
113 }, 115 },
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 {
42 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, 42 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
43 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, 43 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search},
44 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones}, 44 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},
45 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, 47 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
46 Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, 48 Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log},
47 Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, 49 Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log},
internal/httpd/web.go +69
@@ -4,6 +4,9 @@ import (
4 "bytes" 4 "bytes"
5 "fmt" 5 "fmt"
6 "hash/fnv" 6 "hash/fnv"
7 "io"
8 "os"
9 "path/filepath"
7 10
8 "gitbay.org/gitbay/internal/policy" 11 "gitbay.org/gitbay/internal/policy"
9 "html/template" 12 "html/template"
@@ -383,6 +386,72 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
383 }{p, cs, base, filePath, binary, len(data), codeHTML}) 386 }{p, cs, base, filePath, binary, len(data), codeHTML})
384} 387}
385 388
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
386// milestones lists a repo's milestones with progress. 455// milestones lists a repo's milestones with progress.
387func (s *Server) milestones(w http.ResponseWriter, r *http.Request) { 456func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
388 p, ok := s.repoFor(w, r, "") 457 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; }
514ul.issuelist .title a { color: var(--fg); font-weight: 550; } 514ul.issuelist .title a { color: var(--fg); font-weight: 550; }
515ul.issuelist .title a:hover { color: var(--accent); } 515ul.issuelist .title a:hover { color: var(--accent); }
516 516
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
517/* milestones */ 530/* milestones */
518ul.milestonelist { 531ul.milestonelist {
519 list-style: none; 532 list-style: none;
internal/web/templates/layout.html +1
@@ -29,6 +29,7 @@
29 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> 29 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
30 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> 30 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
31 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> 31 <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>
32 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a> 33 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
33 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a> 34 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
34 <a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a> 35 <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}}