Commit 13f864d416
Verified · cmc
cmd/gitbay/main.go +22 −5
| @@ -34,6 +34,7 @@ func main() { | ||
| 34 | 34 | issueCmd(), |
| 35 | 35 | milestoneCmd(), |
| 36 | 36 | mrCmd(), |
| 37 | releaseCmd(), | |
| 37 | 38 | webCmd(), |
| 38 | 39 | orgCmd(), |
| 39 | 40 | group("profile", "user and org profiles", |
| @@ -56,10 +57,11 @@ func main() { | ||
| 56 | 57 | |
| 57 | 58 | // passOpts describes how one CLI command maps onto the server command. |
| 58 | 59 | 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 | |
| 63 | 65 | } |
| 64 | 66 | |
| 65 | 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 | 122 | if stdin == nil || isEmptyReader(stdin) { |
| 121 | if o.stdinOK && usesStdin(args) { | |
| 123 | if o.alwaysStdin || (o.stdinOK && usesStdin(args)) { | |
| 122 | 124 | stdin = os.Stdin |
| 123 | 125 | } |
| 124 | 126 | } |
| @@ -277,6 +279,21 @@ func issueCmd() *cobra.Command { | ||
| 277 | 279 | ) |
| 278 | 280 | } |
| 279 | 281 | |
| 282 | func 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 | ||
| 280 | 297 | func milestoneCmd() *cobra.Command { |
| 281 | 298 | return group("milestone", "group issues and MRs toward a release", |
| 282 | 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 | 95 | ** [limits] |
| 96 | 96 | - =clone_timeout= (3600s) — cap on =repo import= fetches. |
| 97 | 97 | - =max_blob_bytes= (100MB) — cap on raw file serving over the web. |
| 98 | =max_asset_bytes= (512MB) — cap per uploaded release asset. | |
| 98 | 99 | - =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced. |
| 99 | 100 | |
| 100 | 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 | ||
| 158 | 158 | first argument. Long text: =--body= inline, =--file -= from stdin, or |
| 159 | 159 | neither on a terminal and =$EDITOR= opens. |
| 160 | 160 | |
| 161 | Releases anchor notes and binary assets to a pushed tag (write access; | |
| 162 | assets stream over SSH, capped by the instance's =max_asset_bytes=): | |
| 163 | ||
| 164 | #+begin_src sh | |
| 165 | gitbay release create v1.0 --title "First light" [--notes|--file -|$EDITOR] | |
| 166 | gitbay release asset add v1.0 tool-linux-amd64 < dist/tool-linux-amd64 | |
| 167 | gitbay release asset get v1.0 tool-linux-amd64 > tool # or the web download link | |
| 168 | gitbay release list / show v1.0 / delete v1.0 --yes | |
| 169 | #+end_src | |
| 170 | ||
| 171 | The web shows them under the repository's =releases= tab with rendered | |
| 172 | notes, sha256 sums, and download links. | |
| 173 | ||
| 161 | 174 | Commit messages act on issues when the commits land on the default |
| 162 | 175 | branch (direct push or MR merge): =closes/fixes/resolves #4= closes the |
| 163 | 176 | issue with a linking comment, and a bare =#4= leaves a reference |
e2e/release_test.go added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "net/http" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ) | |
| 11 | ||
| 12 | func 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 { | ||
| 83 | 83 | type Limits struct { |
| 84 | 84 | MaxPackBytes int64 `toml:"max_pack_bytes"` |
| 85 | 85 | MaxBlobBytes int64 `toml:"max_blob_bytes"` |
| 86 | MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset | |
| 86 | 87 | CloneTimeoutSec int `toml:"clone_timeout"` |
| 87 | 88 | SSHAuthRate int `toml:"ssh_auth_rate"` |
| 88 | 89 | } |
| @@ -108,6 +109,7 @@ func Default() Config { | ||
| 108 | 109 | Limits: Limits{ |
| 109 | 110 | MaxPackBytes: 2 << 30, // 2 GiB |
| 110 | 111 | MaxBlobBytes: 100 << 20, |
| 112 | MaxAssetBytes: 512 << 20, | |
| 111 | 113 | CloneTimeoutSec: 3600, |
| 112 | 114 | SSHAuthRate: 10, |
| 113 | 115 | }, |
internal/control/release.go added +315
| @@ -0,0 +1,315 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 20 | func 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 | ||
| 40 | var 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. | |
| 44 | func 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. | |
| 49 | func 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 | ||
| 67 | func 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 | ||
| 124 | type assetOut struct { | |
| 125 | Name string `json:"name"` | |
| 126 | Size int64 `json:"size"` | |
| 127 | SHA256 string `json:"sha256"` | |
| 128 | } | |
| 129 | ||
| 130 | type 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 | ||
| 139 | func 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 | ||
| 150 | func 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 | ||
| 173 | func 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 | ||
| 190 | func 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 | ||
| 216 | func 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 | ||
| 268 | func 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 | ||
| 291 | func 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 | 42 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, |
| 43 | 43 | Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, |
| 44 | 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 | 47 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |
| 46 | 48 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, |
| 47 | 49 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, |
internal/httpd/web.go +69
| @@ -4,6 +4,9 @@ import ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "fmt" |
| 6 | 6 | "hash/fnv" |
| 7 | "io" | |
| 8 | "os" | |
| 9 | "path/filepath" | |
| 7 | 10 | |
| 8 | 11 | "gitbay.org/gitbay/internal/policy" |
| 9 | 12 | "html/template" |
| @@ -383,6 +386,72 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 383 | 386 | }{p, cs, base, filePath, binary, len(data), codeHTML}) |
| 384 | 387 | } |
| 385 | 388 | |
| 389 | // releases lists tag-anchored releases with notes and assets. | |
| 390 | func (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. | |
| 418 | func (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 | 455 | // milestones lists a repo's milestones with progress. |
| 387 | 456 | func (s *Server) milestones(w http.ResponseWriter, r *http.Request) { |
| 388 | 457 | p, ok := s.repoFor(w, r, "") |
internal/store/migrations/0015_releases.down.sql added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | DROP TABLE release_assets; | |
| 2 | DROP TABLE releases; | |
internal/store/migrations/0015_releases.up.sql added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | CREATE 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 | ); | |
| 11 | CREATE 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 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | ) | |
| 8 | ||
| 9 | type 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 | ||
| 20 | type ReleaseAsset struct { | |
| 21 | ID int64 | |
| 22 | Name string | |
| 23 | Size int64 | |
| 24 | SHA256 string | |
| 25 | UploadedAt string | |
| 26 | } | |
| 27 | ||
| 28 | func (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 | ||
| 41 | const 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 | ||
| 45 | func (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 | ||
| 63 | func (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. | |
| 77 | func (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 | ||
| 102 | func (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 | ||
| 113 | func (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 | ||
| 123 | func (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; } | ||
| 514 | 514 | ul.issuelist .title a { color: var(--fg); font-weight: 550; } |
| 515 | 515 | ul.issuelist .title a:hover { color: var(--accent); } |
| 516 | 516 | |
| 517 | /* releases */ | |
| 518 | article.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); } | |
| 526 | table.assets { margin-top: var(--sp-2); } | |
| 527 | table.assets td.size { color: var(--muted); white-space: nowrap; text-align: right; } | |
| 528 | table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); } | |
| 529 | ||
| 517 | 530 | /* milestones */ |
| 518 | 531 | ul.milestonelist { |
| 519 | 532 | list-style: none; |
internal/web/templates/layout.html +1
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> |
| 30 | 30 | <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> |
| 31 | 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 | 33 | <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a> |
| 33 | 34 | <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a> |
| 34 | 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}} | |