Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Create and edit releases from the browser

ec34301375902f6b08c0171bf91b98cbcc55c63c · Keenan Nemetz · 15d ago · parent 09b15120

Create and edit releases from the browser

Maintainers get a New release form that writes the same annotated tag the CLI writes: tag name, target commit, and notes become the tag message, with the account as tagger. Edit notes rewrites the tag message only while no artifact is attached, matching the release-immutability rule; lightweight tags and releases with files stay locked. Tag names are validated, existing tags are refused, reserved page names are rejected, and both actions are audited.

server/web/release_actions.go +216

@@ -0,0 +1,216 @@
1 package web
2
3 // Creating a release is creating an annotated tag, so the tag remains the whole
4 // release. The web form exists to make the notes, the target commit, and the
5 // tagger explicit; it does not introduce a second release record. Editing notes
6 // moves the tag as a new tag object, which the release rules allow only until a
7 // file has been attached.
8
9 import (
10 "net/http"
11 "strings"
12
13 "github.com/rodent-software/vole/forge/access"
14 "github.com/rodent-software/vole/forge/repo"
15 "github.com/rodent-software/vole/metadata"
16 )
17
18 const maxReleaseNoteBytes = 64 << 10
19
20 type releaseFormView struct {
21 Page
22 Name string
23 Tag string
24 Target string
25 Notes string
26 Editing bool
27 Locked bool
28 Refs []string
29 Error string
30 }
31
32 func (w *Web) canManageReleases(r *http.Request, m *repo.Meta) bool {
33 pr := w.principal(r)
34 if pr == nil {
35 return false
36 }
37 acl, err := m.ACL()
38 return err == nil && acl.RoleFor(pr.account.ID).AtLeast(access.Maintain)
39 }
40
41 func (w *Web) releaseFormView(r *http.Request, m *repo.Meta, editing bool, tag, target, notes, msg string) releaseFormView {
42 return releaseFormView{
43 Page: w.page(r, m.Name+" · release"), Name: m.Name,
44 Tag: tag, Target: target, Notes: notes, Editing: editing,
45 Refs: compareRefs(m.Git()), Error: msg,
46 }
47 }
48
49 func (w *Web) ReleaseNewPage(rw http.ResponseWriter, r *http.Request) {
50 m, ok := w.open(rw, r)
51 if !ok {
52 return
53 }
54 if _, ok := w.requireHTMLUser(rw, r); !ok {
55 return
56 }
57 if !w.canManageReleases(r, m) {
58 w.notFound(rw, r)
59 return
60 }
61 w.render(rw, "release_form", w.releaseFormView(r, m, false, "", m.DefaultBranch, "", ""), 0)
62 }
63
64 func (w *Web) CreateRelease(rw http.ResponseWriter, r *http.Request) {
65 m, ok := w.open(rw, r)
66 if !ok {
67 return
68 }
69 pr, ok := w.requireHTMLUser(rw, r)
70 if !ok {
71 return
72 }
73 r.Body = http.MaxBytesReader(rw, r.Body, 1<<20)
74 if !w.checkCSRF(r) {
75 http.Error(rw, "bad request", http.StatusForbidden)
76 return
77 }
78 if !w.canManageReleases(r, m) {
79 w.notFound(rw, r)
80 return
81 }
82 if err := r.ParseForm(); err != nil {
83 http.Error(rw, "cannot read the form", http.StatusBadRequest)
84 return
85 }
86 tag := strings.TrimSpace(r.PostFormValue("tag"))
87 target := strings.TrimSpace(r.PostFormValue("target"))
88 notes := strings.TrimRight(r.PostFormValue("notes"), "\n")
89 view := w.releaseFormView(r, m, false, tag, target, notes, "")
90 fail := func(msg string) {
91 view.Error = msg
92 w.render(rw, "release_form", view, http.StatusBadRequest)
93 }
94
95 g := m.Git()
96 if tag == "new" || tag == "edit" {
97 fail("That tag name is reserved by the release pages.")
98 return
99 }
100 if !g.ValidTag(tag) {
101 fail("Choose a valid tag name.")
102 return
103 }
104 if strings.TrimSpace(notes) == "" {
105 fail("A release needs notes; they are the tag message.")
106 return
107 }
108 if len(notes) > maxReleaseNoteBytes {
109 fail("Release notes are limited to 64 KiB.")
110 return
111 }
112 commit, _ := g.RevParse(target)
113 if commit == "" {
114 fail("That target revision does not exist.")
115 return
116 }
117 if refs, err := g.ListRefs("refs/tags/"); err == nil {
118 if _, exists := refs["refs/tags/"+tag]; exists {
119 fail("That tag already exists. A release is immutable; choose a new tag name.")
120 return
121 }
122 }
123 if err := g.WriteAnnotatedTag(tag, commit, notes, pr.account.Name, false); err != nil {
124 fail(err.Error())
125 return
126 }
127 w.audit("web.release.create", pr.account.ID, m.Name+" "+tag)
128 http.Redirect(rw, r, "/r/"+m.Name+"/releases/"+pathURL(tag), http.StatusSeeOther)
129 }
130
131 func (w *Web) ReleaseEditPage(rw http.ResponseWriter, r *http.Request) {
132 m, ok := w.open(rw, r)
133 if !ok {
134 return
135 }
136 if _, ok := w.requireHTMLUser(rw, r); !ok {
137 return
138 }
139 if !w.canManageReleases(r, m) {
140 w.notFound(rw, r)
141 return
142 }
143 tag := strings.TrimSpace(r.URL.Query().Get("tag"))
144 t, err := m.Git().Tag(tag)
145 if err != nil {
146 w.notFound(rw, r)
147 return
148 }
149 view := w.releaseFormView(r, m, true, t.Name, t.Commit, t.Message, "")
150 if !t.Annotated {
151 view.Locked = true
152 view.Error = "Lightweight tags have no notes to edit."
153 } else if metadata.TagWithArtifacts(m.Git(), t.Name) {
154 view.Locked = true
155 view.Error = "This release has files attached, so it cannot be edited. Attach a new file or publish a new release instead."
156 }
157 w.render(rw, "release_form", view, 0)
158 }
159
160 func (w *Web) UpdateRelease(rw http.ResponseWriter, r *http.Request) {
161 m, ok := w.open(rw, r)
162 if !ok {
163 return
164 }
165 pr, ok := w.requireHTMLUser(rw, r)
166 if !ok {
167 return
168 }
169 r.Body = http.MaxBytesReader(rw, r.Body, 1<<20)
170 if !w.checkCSRF(r) {
171 http.Error(rw, "bad request", http.StatusForbidden)
172 return
173 }
174 if !w.canManageReleases(r, m) {
175 w.notFound(rw, r)
176 return
177 }
178 if err := r.ParseForm(); err != nil {
179 http.Error(rw, "cannot read the form", http.StatusBadRequest)
180 return
181 }
182 tag := strings.TrimSpace(r.PostFormValue("tag"))
183 notes := strings.TrimRight(r.PostFormValue("notes"), "\n")
184 t, err := m.Git().Tag(tag)
185 if err != nil {
186 w.notFound(rw, r)
187 return
188 }
189 view := w.releaseFormView(r, m, true, t.Name, t.Commit, notes, "")
190 fail := func(msg string) {
191 view.Error = msg
192 w.render(rw, "release_form", view, http.StatusBadRequest)
193 }
194 if !t.Annotated {
195 fail("Lightweight tags have no notes to edit.")
196 return
197 }
198 if metadata.TagWithArtifacts(m.Git(), t.Name) {
199 fail("This release has files attached, so it cannot be edited.")
200 return
201 }
202 if strings.TrimSpace(notes) == "" {
203 fail("A release needs notes; they are the tag message.")
204 return
205 }
206 if len(notes) > maxReleaseNoteBytes {
207 fail("Release notes are limited to 64 KiB.")
208 return
209 }
210 if err := m.Git().WriteAnnotatedTag(t.Name, t.Commit, notes, pr.account.Name, true); err != nil {
211 fail(err.Error())
212 return
213 }
214 w.audit("web.release.edit", pr.account.ID, m.Name+" "+t.Name)
215 http.Redirect(rw, r, "/r/"+m.Name+"/releases/"+pathURL(t.Name), http.StatusSeeOther)
216 }