Commit 13f864d416
Verified · cmc
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. |
| 58 | type passOpts struct { | 59 | type 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 | ||
| 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 | func milestoneCmd() *cobra.Command { | 297 | func 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 | |||
| 158 | first argument. Long text: =--body= inline, =--file -= from stdin, or | 158 | first argument. Long text: =--body= inline, =--file -= from stdin, or |
| 159 | neither on a terminal and =$EDITOR= opens. | 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 | Commit messages act on issues when the commits land on the default | 174 | Commit messages act on issues when the commits land on the default |
| 162 | branch (direct push or MR merge): =closes/fixes/resolves #4= closes the | 175 | branch (direct push or MR merge): =closes/fixes/resolves #4= closes the |
| 163 | issue with a linking comment, and a bare =#4= leaves a reference | 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 | type Limits struct { | 83 | type 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 @@ | |||
| 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 | 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. | ||
| 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 | // milestones lists a repo's milestones with progress. | 455 | // milestones lists a repo's milestones with progress. |
| 387 | func (s *Server) milestones(w http.ResponseWriter, r *http.Request) { | 456 | func (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 @@ | |||
| 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 | ul.issuelist .title a { color: var(--fg); font-weight: 550; } | 514 | ul.issuelist .title a { color: var(--fg); font-weight: 550; } |
| 515 | ul.issuelist .title a:hover { color: var(--accent); } | 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 | /* milestones */ | 530 | /* milestones */ |
| 518 | ul.milestonelist { | 531 | ul.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}} | ||