A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 97bab07d1a

97bab07d1afcbd32b12480f90171b6ac95b542fe

parent: 8e64f8208b

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-26T03:53:26Z

web: create and edit releases, trigger builds

Releases and builds get the same treatment as settings: the forms dispatch
release create|edit and build trigger through control.Dispatch. The releases
page offers only tags without a release; the builds page reads job names from
.gitbay/ci.yml at the default branch.

Ref #35
e2e/releaseweb_test.go added +94
@@ -0,0 +1,94 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// TestReleaseAndBuildWeb creates and edits a release and triggers a build
12// from the browser, each through the command the CLI runs.
13func TestReleaseAndBuildWeb(t *testing.T) {
14 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
15 inst.runner = buildRunner(t)
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19 runnerKey := inst.newKey(t, "ci")
20 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
21
22 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
23 t.Fatalf("repo create: %s", errOut)
24 }
25 env := inst.gitEnv(aliceKey)
26 work := t.TempDir()
27 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
28 dir := filepath.Join(work, "w")
29 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
30 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"),
31 []byte("jobs:\n smoke:\n steps:\n - echo ok\n"), 0o644)
32 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
33 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
34 mustGit(t, dir, env, "add", ".")
35 mustGit(t, dir, env, "commit", "-q", "-m", "base")
36 mustGit(t, dir, env, "tag", "-a", "v1.0", "-m", "first")
37 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0")
38
39 alice := inst.login(t, aliceKey)
40 base := inst.base() + "/alice/app"
41
42 // The pushed tag is offered, and creating from it works.
43 _, page := browserGet(t, alice, base+"/releases")
44 if !strings.Contains(page, `value="v1.0"`) {
45 t.Fatalf("tag not offered:\n%s", page)
46 }
47 if status, _ := browserPost(t, alice, base+"/releases", url.Values{
48 "tag": {"v1.0"}, "title": {"First light"}, "notes": {"the **first** one"}}); status != 200 {
49 t.Fatal("release create failed")
50 }
51 out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json")
52 if !strings.Contains(out, "First light") || !strings.Contains(out, "the **first** one") {
53 t.Fatalf("release not created:\n%s", out)
54 }
55
56 // Editing keeps the tag and replaces the fields.
57 if status, _ := browserPost(t, alice, base+"/releases", url.Values{
58 "action": {"edit"}, "tag": {"v1.0"}, "title": {"Second light"}, "notes": {"revised"}}); status != 200 {
59 t.Fatal("release edit failed")
60 }
61 out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json")
62 if !strings.Contains(out, "Second light") || !strings.Contains(out, "revised") {
63 t.Fatalf("release not edited:\n%s", out)
64 }
65 // A released tag is no longer offered for creation.
66 if _, page = browserGet(t, alice, base+"/releases"); strings.Contains(page, `<option value="v1.0"`) {
67 t.Fatalf("released tag still offered:\n%s", page)
68 }
69
70 // Builds: the job from the config is offered and runs on demand.
71 _, bpage := browserGet(t, alice, base+"/builds")
72 if !strings.Contains(bpage, `value="smoke"`) {
73 t.Fatalf("job not offered:\n%s", bpage)
74 }
75 if status, _ := browserPost(t, alice, base+"/builds", url.Values{"job": {"smoke"}}); status != 200 {
76 t.Fatal("trigger failed")
77 }
78 inst.runnerOnce(t, runnerKey)
79 out, _, _ = inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
80 if !strings.Contains(out, "smoke\tsuccess") {
81 t.Fatalf("triggered build did not run:\n%s", out)
82 }
83
84 // A reader sees neither control.
85 bobKey := inst.newKey(t, "bob")
86 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
87 bob := inst.login(t, bobKey)
88 if _, p := browserGet(t, bob, base+"/builds"); strings.Contains(p, "Run a job now") {
89 t.Fatal("reader sees the trigger control")
90 }
91 if _, p := browserGet(t, bob, base+"/releases"); strings.Contains(p, "New release") {
92 t.Fatal("reader sees the release form")
93 }
94}
internal/httpd/builds.go +19 −2
@@ -4,6 +4,8 @@ import (
44 "net/http"
55 "strconv"
66
7 "gitbay.org/gitbay/internal/ci"
8 "gitbay.org/gitbay/internal/gitutil"
79 "gitbay.org/gitbay/internal/store"
810 )
911
@@ -14,10 +16,25 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
1416 }
1517 p.Tab = "builds"
1618 builds, _ := s.st.ListBuilds(p.Repo.ID, 50)
19 // The jobs a trigger can name come from the config on the default
20 // branch, the same file the scheduler reads.
21 var jobs []string
22 if sha, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+p.Repo.DefaultBranch); err == nil {
23 if raw, err := gitutil.ReadBlob(p.Dir, sha, ci.ConfigPath, 1<<16); err == nil {
24 if parsed, err := ci.Parse(raw); err == nil {
25 for _, j := range parsed {
26 jobs = append(jobs, j.Name)
27 }
28 }
29 }
30 }
1731 s.render(w, "builds.html", struct {
1832 repoPage
19 Builds []store.Build
20 }{p, builds})
33 Builds []store.Build
34 Jobs []string
35 CanWrite bool
36 Notice string
37 }{p, builds, jobs, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
2138 }
2239
2340 func (s *Server) build(w http.ResponseWriter, r *http.Request) {
internal/httpd/releaseactions.go added +67
@@ -0,0 +1,67 @@
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
7 "strings"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12// Release and build actions. As elsewhere, the browser chooses arguments
13// and the command decides: tag existence, access, and the gates around
14// archived repositories all stay in one implementation.
15
16func (s *Server) backTo(w http.ResponseWriter, r *http.Request, page, msg string) {
17 dest := fmt.Sprintf("/%s/%s/%s", r.PathValue("owner"), r.PathValue("repo"), page)
18 if msg != "" {
19 if len(msg) > 300 {
20 msg = msg[:300]
21 }
22 dest += "?e=" + url.QueryEscape(msg)
23 }
24 http.Redirect(w, r, dest, http.StatusSeeOther)
25}
26
27func (s *Server) releaseSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
28 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
29 tag := strings.TrimSpace(r.FormValue("tag"))
30 title := strings.TrimSpace(r.FormValue("title"))
31 notes := strings.TrimSpace(r.FormValue("notes"))
32 if tag == "" {
33 s.backTo(w, r, "releases", "pick a tag")
34 return
35 }
36 verb := "create"
37 if r.FormValue("action") == "edit" {
38 verb = "edit"
39 }
40 argv := []string{"release", verb, repo, tag}
41 if title != "" {
42 argv = append(argv, "--title", title)
43 }
44 // edit needs at least one field; create takes the tag alone.
45 if notes != "" || verb == "edit" {
46 argv = append(argv, "--notes", notes)
47 }
48 _, msg, ok := s.runControl(u, argv)
49 if ok {
50 msg = ""
51 }
52 s.backTo(w, r, "releases", msg)
53}
54
55func (s *Server) buildTriggerSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
56 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
57 job := strings.TrimSpace(r.FormValue("job"))
58 if job == "" {
59 s.backTo(w, r, "builds", "pick a job")
60 return
61 }
62 _, msg, ok := s.runControl(u, []string{"build", "trigger", repo, job})
63 if ok {
64 msg = ""
65 }
66 s.backTo(w, r, "builds", msg)
67}
internal/httpd/routes.go +4
@@ -120,6 +120,10 @@ func (s *Server) Routes() []Route {
120120 Handler: s.checkOrigin(s.requireUser(s.issueAssignSubmit))},
121121 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/milestone", Mutating: true,
122122 Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))},
123 Route{Method: "POST", Pattern: "/{owner}/{repo}/releases", Mutating: true,
124 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
125 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
126 Handler: s.checkOrigin(s.requireUser(s.buildTriggerSubmit))},
123127 Route{Method: "GET", Pattern: "/{owner}/{repo}/settings",
124128 Handler: s.requireUser(s.settingsForm)},
125129 Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true,
internal/httpd/web.go +18 −2
@@ -136,7 +136,7 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
136136 Host string
137137 Accounts bool
138138 Signup bool
139 }{basePage{Site: s.siteName()}, host, s.cfg.Web.Mode == "accounts",
139 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
140140 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
141141 }
142142
@@ -566,10 +566,26 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
566566 for _, rel := range rels {
567567 views = append(views, relView{rel, md(rel.Notes)})
568568 }
569 // Tags without a release yet are what a create form can offer.
570 released := map[string]bool{}
571 for _, rel := range rels {
572 released[rel.Tag] = true
573 }
574 var freeTags []string
575 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
576 for _, tg := range tags {
577 if !released[tg.Name] {
578 freeTags = append(freeTags, tg.Name)
579 }
580 }
581 }
569582 s.render(w, "releases.html", struct {
570583 repoPage
571584 Releases []relView
572 }{p, views})
585 FreeTags []string
586 CanWrite bool
587 Notice string
588 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
573589 }
574590
575591 // releaseAsset streams one uploaded asset. Tags containing '/' are not
internal/web/templates/builds.html +10
@@ -1,6 +1,16 @@
11 {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 <h1>Builds</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if and .CanWrite .Jobs}}
6<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds" class="setform">
7 <label for="job">Run a job now</label>
8 <select id="job" name="job">
9 {{range .Jobs}}<option value="{{.}}">{{.}}</option>{{end}}
10 </select>
11 <button type="submit" class="primary">Run</button>
12</form>
13{{end}}
414 <details class="editbox"><summary>Status badge</summary>
515 <p class="meta">Paste into a README; it shows the newest build's state.</p>
616 <pre class="code">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre>
internal/web/templates/releases.html +24
@@ -1,6 +1,22 @@
11 {{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
22 {{define "content"}}
33 <h1>Releases</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if and .CanWrite .FreeTags}}
6<details class="editbox"><summary>New release</summary>
7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases" class="commentform">
8 <p class="branchpick">
9 <label for="tag">Tag</label>
10 <select id="tag" name="tag" required>
11 {{range .FreeTags}}<option value="{{.}}">{{.}}</option>{{end}}
12 </select>
13 </p>
14 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)"></p>
15 <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed"></textarea></p>
16 <p><button type="submit" class="primary">Create release</button></p>
17</form>
18</details>
19{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}}
420 {{range $rel := .Releases}}
521 <article class="release">
622 <header class="releasehead">
@@ -8,6 +24,14 @@
824 <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>
925 </header>
1026 {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}}
27 {{if $.CanWrite}}<details class="editbox"><summary>Edit</summary>
28 <form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform">
29 <input type="hidden" name="action" value="edit">
30 <input type="hidden" name="tag" value="{{$rel.Tag}}">
31 <p><input type="text" name="title" aria-label="Title" value="{{$rel.Title}}"></p>
32 <p><textarea name="notes" aria-label="Notes" rows="6">{{$rel.Notes}}</textarea></p>
33 <p><button type="submit">Save</button></p>
34 </form></details>{{end}}
1135 {{if $rel.Assets}}<table class="assets">
1236 {{range $rel.Assets}}<tr>
1337 <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td>