web: releases, builds, and a login page that names the host !72

merged merged by cmc on 2026-08-26 03:54 UTC · krz/gitbay:release-build-web into main

11 files changed, +267 −11

Layout: unified · split

e2e/accounts_test.go +21
@@ -217,3 +217,24 @@ func TestViewOnlyHasNoLoginOnTheWire(t *testing.T) {
217217 t.Fatalf("web login in view_only: exit %d, %s", code, errOut)
218218 }
219219}
220
221// TestTitleIsNotAHostname pins the split between the instance's display name
222// and its hostname: the login page prints a command to paste into a terminal,
223// so it must name the host even when the operator has set a display title.
224func TestTitleIsNotAHostname(t *testing.T) {
225 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\ntitle = \"GitBay\"\n")
226
227 status, body := inst.get(t, "/login")
228 if status != 200 {
229 t.Fatalf("/login = %d", status)
230 }
231 if strings.Contains(body, "ssh git@GitBay") {
232 t.Fatal("login page tells you to ssh to the display title")
233 }
234 if !strings.Contains(body, "ssh git@gitbay.test web login") {
235 t.Fatalf("login page does not name the host:\n%s", body)
236 }
237 if !strings.Contains(body, "GitBay") {
238 t.Fatal("login page dropped the display title entirely")
239 }
240}
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/accounts.go +4 −4
@@ -66,7 +66,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
6666 s.render(w, "login.html", struct {
6767 basePage
6868 Error string
69 }{basePage{Site: s.siteName()}, ""})
69 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, ""})
7070 return
7171 }
7272 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
@@ -74,7 +74,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
7474 s.render(w, "login.html", struct {
7575 basePage
7676 Error string
77 }{basePage{Site: s.siteName()},
77 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
7878 "that login link is invalid, expired, or already used — mint a new one"})
7979 return
8080 }
@@ -226,7 +226,7 @@ func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
226226 Mode string // open | invite
227227 Error string
228228 Username string
229 }{basePage{Site: s.siteName()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
229 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
230230}
231231
232232func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
@@ -248,7 +248,7 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
248248 Username string
249249 Message string
250250 Host string
251 }{basePage{Site: s.siteName()}, username, msg, s.cfg.SiteHost()})
251 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
252252}
253253
254254// issueCreateForm renders the new-issue form, prefilled from the repo's
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
2340func (s *Server) build(w http.ResponseWriter, r *http.Request) {
internal/httpd/page.go +5 −2
@@ -36,7 +36,10 @@ func (r rail) Empty() bool { return len(r.Pinned) == 0 && len(r.Reviews) == 0 }
3636// basePage is what the layout needs on every page, repo or not. Page
3737// structs embed it so the rail and the site name are always in scope.
3838type basePage struct {
39 // Site is the instance's display name; Host is the name a command or
40 // URL must use. Anything copy-pasteable takes Host.
3941 Site string
42 Host string
4043 Viewer string
4144 Rail rail
4245}
@@ -45,7 +48,7 @@ type basePage struct {
4548// resolved a viewer.
4649func (s *Server) base(r *http.Request) basePage {
4750 if s.cfg.Web.Mode != "accounts" {
48 return basePage{Site: s.siteName()}
51 return basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}
4952 }
5053 return s.baseFor(s.viewer(r))
5154}
@@ -53,7 +56,7 @@ func (s *Server) base(r *http.Request) basePage {
5356// baseFor is base for a handler that already holds the viewer, so the
5457// session lookup is not repeated.
5558func (s *Server) baseFor(viewer store.User) basePage {
56 b := basePage{Site: s.siteName()}
59 b := basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}
5760 if viewer.ID == 0 {
5861 return b
5962 }
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/login.html +1 −1
@@ -4,6 +4,6 @@
44{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
55<p>Browser sessions are minted over SSH — there is no password. From a machine
66with your registered key:</p>
7<pre class="message">ssh git@{{.Site}} web login</pre>
7<pre class="message">ssh git@{{.Host}} web login</pre>
88<p>then open the printed URL within five minutes.</p>
99{{end}}
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>