A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 14a47d9502

14a47d95025c9e0bb245b229bf0eaf598efeebbc

parent: 6b01ef3788

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T20:02:29Z

release edit: update a release's title and notes

Absent flags keep their field; --notes/--file replace the notes with
the create command's stdin form; missing releases 404; archived repos
refuse. CLI passthrough included.

Closes #34
cmd/gitbay/main.go +2
@@ -317,6 +317,8 @@ func releaseCmd() *cobra.Command {
317317 return group("release", "tag-anchored releases with notes and assets",
318318 pass("create", "create a release on a pushed tag: <tag> [--title <t>] [--notes|--file -|$EDITOR]",
319319 passOpts{server: []string{"release", "create"}, needsRepo: true, stdinOK: true, editor: "release"}),
320 pass("edit", "update title and notes: <tag> [--title <t>] [--notes|--file -]",
321 passOpts{server: []string{"release", "edit"}, needsRepo: true, stdinOK: true}),
320322 pass("list", "list releases", passOpts{server: []string{"release", "list"}, needsRepo: true}),
321323 pass("show", "show a release with assets: <tag>", passOpts{server: []string{"release", "show"}, needsRepo: true}),
322324 pass("delete", "delete a release and its assets: <tag> --yes", passOpts{server: []string{"release", "delete"}, needsRepo: true}),
e2e/release_test.go +26 −4
@@ -40,6 +40,28 @@ func TestReleases(t *testing.T) {
4040 t.Fatal("duplicate release accepted")
4141 }
4242
43 // Edit: notes replace from stdin, absent flags keep their field.
44 if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v1.0"); code != 2 {
45 t.Fatal("edit with nothing to change accepted")
46 }
47 if _, errOut, code := inst.ssh(t, aliceKey, "the **rebuilt** notes\n", "release", "edit", "alice/app", "v1.0", "--file", "-"); code != 0 {
48 t.Fatalf("release edit: %s", errOut)
49 }
50 out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0")
51 if !strings.Contains(out, "the **rebuilt** notes") || !strings.Contains(out, "First light") {
52 t.Fatalf("edit lost a field:\n%s", out)
53 }
54 if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v1.0", "--title", "'Second light'"); code != 0 {
55 t.Fatal("title edit failed")
56 }
57 out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0")
58 if !strings.Contains(out, "Second light") || !strings.Contains(out, "the **rebuilt** notes") {
59 t.Fatalf("title edit lost notes:\n%s", out)
60 }
61 if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v9.9", "--title", "x"); code != 3 {
62 t.Fatal("edit of missing release accepted")
63 }
64
4365 // Assets: upload from stdin, validation, dedup, round-trip.
4466 payload := "BINARY\x01\x02payload for the release asset\n"
4567 if _, errOut, code := inst.ssh(t, aliceKey, payload, "release", "asset", "add", "alice/app", "v1.0", "tool-linux-amd64"); code != 0 {
@@ -58,17 +80,17 @@ func TestReleases(t *testing.T) {
5880 if code != 0 || got != payload {
5981 t.Fatalf("asset round-trip: exit %d, %q", code, got)
6082 }
61 out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json")
83 out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json")
6284 if !strings.Contains(out, `"name":"tool-linux-amd64"`) ||
6385 !strings.Contains(out, fmt.Sprintf(`"size":%d`, len(payload))) ||
64 !strings.Contains(out, `"sha256":"`) || !strings.Contains(out, "First light") {
86 !strings.Contains(out, `"sha256":"`) || !strings.Contains(out, "Second light") {
6587 t.Fatalf("release show: %s", out)
6688 }
6789
6890 // Web: page renders notes and assets; download streams exact bytes.
6991 status, body := inst.get(t, "/alice/app/releases")
70 if status != 200 || !strings.Contains(body, "First light") ||
71 !strings.Contains(body, "<strong>first</strong>") ||
92 if status != 200 || !strings.Contains(body, "Second light") ||
93 !strings.Contains(body, "<strong>rebuilt</strong>") ||
7294 !strings.Contains(body, "tool-linux-amd64") {
7395 t.Fatalf("releases page: %d\n%s", status, body)
7496 }
internal/control/release.go +66
@@ -21,6 +21,9 @@ func init() {
2121 register(Command{Path: []string{"release", "create"},
2222 Summary: "create a release on a tag: release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -]",
2323 ReadsStdin: true, Run: runReleaseCreate})
24 register(Command{Path: []string{"release", "edit"},
25 Summary: "update a release's title and notes: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]",
26 ReadsStdin: true, Run: runReleaseEdit})
2427 register(Command{Path: []string{"release", "list"},
2528 Summary: "list releases: release list <owner/name>", ReadOnly: true, Run: runReleaseList})
2629 register(Command{Path: []string{"release", "show"},
@@ -147,6 +150,69 @@ func releaseToOut(r store.Release, withNotes bool) releaseOut {
147150 return o
148151 }
149152
153func runReleaseEdit(c *Ctx, args []string) int {
154 const usage = "usage: release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -]"
155 var path, tag, title, notes, file string
156 var setTitle, setNotes bool
157 for i := 0; i < len(args); i++ {
158 switch args[i] {
159 case "--title", "--notes", "--file":
160 if i+1 >= len(args) {
161 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
162 }
163 switch args[i] {
164 case "--title":
165 title, setTitle = args[i+1], true
166 case "--notes":
167 notes, setNotes = args[i+1], true
168 case "--file":
169 file, setNotes = args[i+1], true
170 }
171 i++
172 default:
173 if path == "" {
174 path = args[i]
175 } else if tag == "" {
176 tag = args[i]
177 } else {
178 return c.fail(protocol.ExitUsage, usage)
179 }
180 }
181 }
182 if path == "" || tag == "" || (!setTitle && !setNotes) {
183 return c.fail(protocol.ExitUsage, usage)
184 }
185 repo, code := resolveRepo(c, path, policy.CanWrite)
186 if code >= 0 {
187 return code
188 }
189 if code := refuseArchived(c, repo); code >= 0 {
190 return code
191 }
192 rel, err := c.Store.ReleaseByTag(repo.ID, tag)
193 if err != nil {
194 return c.fail(protocol.ExitNotFound, "no release %q in %s", tag, repo.Path())
195 }
196 // Absent flags keep what the release already says.
197 if !setTitle {
198 title = rel.Title
199 } else if title == "" {
200 title = tag
201 }
202 body := rel.Notes
203 if setNotes {
204 if body, err = bodyFrom(c, notes, file); err != nil {
205 return c.fail(protocol.ExitUsage, "%v", err)
206 }
207 }
208 if err := c.Store.UpdateRelease(repo.ID, tag, title, body); err != nil {
209 return c.fail(protocol.ExitFailure, "%v", err)
210 }
211 return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
212 fmt.Fprintf(w, "updated release %s\n", tag)
213 })
214}
215
150216 func runReleaseList(c *Ctx, args []string) int {
151217 if len(args) != 1 {
152218 return c.fail(protocol.ExitUsage, "usage: release list <owner/name>")
internal/store/releases.go +14
@@ -60,6 +60,20 @@ func (s *Store) releaseAssets(rel *Release) error {
6060 return rows.Err()
6161 }
6262
63// UpdateRelease replaces a release's title and notes.
64func (s *Store) UpdateRelease(repoID int64, tag, title, notes string) error {
65 res, err := s.DB.Exec(
66 "UPDATE releases SET title = ?, notes = ? WHERE repo_id = ? AND tag = ?",
67 title, notes, repoID, tag)
68 if err != nil {
69 return err
70 }
71 if n, _ := res.RowsAffected(); n == 0 {
72 return ErrNotFound
73 }
74 return nil
75}
76
6377 func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) {
6478 var r Release
6579 err := s.DB.QueryRow(releaseSelect+" WHERE r.repo_id = ? AND r.tag = ?", repoID, tag).