Commit 060109696d

060109696d0bb87d89790f986d682c447249f802

parent: c1d0a3afb0

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-07 22:29 UTC

policy, hookd, control, web, wiki: protected tags, and a release keeps its tag

Protection covered refs/heads/ only. repo settings protect-tag
<owner/name> <glob> (and unprotect-tag) protects matching tags: created
once, refused on move and delete in pre-receive. Settings page section,
settings show field.

Separately, a tag a release is anchored to can be neither deleted nor
moved while the release exists, protected or not: the release outlived
its tag and served assets for a commit nobody could reach. The refusal
names the release as what to delete first.

Closes #201
.gitbay/wiki/Users.org +4 −1
@@ -193,6 +193,7 @@ gitbay repo access grant you/project alice write # read | write | admin
193193gitbay repo access revoke you/project alice
194194gitbay repo settings protect you/project main # no force-push, no delete
195195gitbay repo settings require-mr you/project on # protected branches: merge requests only
196gitbay repo settings protect-tag you/project 'v*' # matching tags: created once, never moved or deleted
196197gitbay repo settings default-branch you/project trunk # HEAD, and what the web shows
197198gitbay repo settings require-signed you/project on # every commit must verify
198199gitbay repo settings git-daemon you/project on # expose over git://
@@ -310,7 +311,9 @@ git add .gitbay/wiki && git commit -m "wiki" && git push
310311#+end_src
311312
312313Releases anchor notes and binary assets to a pushed tag (write access;
313assets stream over SSH, capped by the instance's =max_asset_bytes=):
314assets stream over SSH, capped by the instance's =max_asset_bytes=).
315While a release exists its tag can be neither deleted nor moved, whether
316or not the tag is protected: delete the release first.
314317
315318#+begin_src sh
316319gitbay release create v1.0 --title "First light" [--notes|--file -|$EDITOR]
cmd/gitbay/main.go +2
@@ -472,6 +472,8 @@ func repoCmd() *cobra.Command {
472472 pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}),
473473 pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}),
474474 pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}),
475 pass("protect-tag", "protect tags matching a glob: <glob>", passOpts{server: []string{"repo", "settings", "protect-tag"}, needsRepo: true}),
476 pass("unprotect-tag", "drop a protected-tag glob: <glob>", passOpts{server: []string{"repo", "settings", "unprotect-tag"}, needsRepo: true}),
475477 pass("default-branch", "set the default branch: <branch>", passOpts{server: []string{"repo", "settings", "default-branch"}, needsRepo: true}),
476478 pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}),
477479 pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}),
e2e/tagprotect_test.go added +77
@@ -0,0 +1,77 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// Protected-tag globs refuse moving and deleting matching tags; a tag a
11// release is anchored to refuses both on its own (#201).
12func TestTagProtection(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 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
17 t.Fatalf("repo create: %s", errOut)
18 }
19 work := t.TempDir()
20 env := inst.gitEnv(aliceKey)
21 mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/app"), "w")
22 dir := filepath.Join(work, "w")
23 if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644); err != nil {
24 t.Fatal(err)
25 }
26 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
27 mustGit(t, dir, env, "add", ".")
28 mustGit(t, dir, env, "commit", "-q", "-m", "base")
29 mustGit(t, dir, env, "tag", "v1.0")
30 mustGit(t, dir, env, "tag", "nightly")
31 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0", "nightly")
32
33 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "protect-tag", "alice/app", "'['"); code != 2 {
34 t.Fatalf("bad glob accepted: %d %s", code, errOut)
35 }
36 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "protect-tag", "alice/app", "'v*'"); code != 0 {
37 t.Fatalf("protect-tag: %s", errOut)
38 }
39 out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json")
40 if !strings.Contains(out, `"protected_tags":["v*"]`) {
41 t.Fatalf("settings show: %s", out)
42 }
43
44 // Delete and move are refused for a matching tag; an unmatched tag is
45 // free, and a new matching tag can still be created.
46 if out, code := gitRun(t, dir, env, "push", "origin", ":v1.0"); code == 0 || !strings.Contains(out, "protected") {
47 t.Fatalf("protected tag deleted: %d\n%s", code, out)
48 }
49 mustGit(t, dir, env, "commit", "-q", "--allow-empty", "-m", "second")
50 mustGit(t, dir, env, "tag", "-f", "v1.0")
51 if out, code := gitRun(t, dir, env, "push", "--force", "origin", "v1.0"); code == 0 || !strings.Contains(out, "protected") {
52 t.Fatalf("protected tag moved: %d\n%s", code, out)
53 }
54 mustGit(t, dir, env, "push", "-q", "origin", ":nightly")
55 mustGit(t, dir, env, "tag", "v1.1")
56 mustGit(t, dir, env, "push", "-q", "origin", "v1.1")
57
58 // Unprotected again, the tag can go — unless a release anchors it.
59 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "unprotect-tag", "alice/app", "'v*'"); code != 0 {
60 t.Fatalf("unprotect-tag: %s", errOut)
61 }
62 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/app", "v1.1", "--title", "'one one'"); code != 0 {
63 t.Fatalf("release create: %s", errOut)
64 }
65 if out, code := gitRun(t, dir, env, "push", "origin", ":v1.1"); code == 0 || !strings.Contains(out, "anchors a release") {
66 t.Fatalf("release tag deleted: %d\n%s", code, out)
67 }
68 mustGit(t, dir, env, "commit", "-q", "--allow-empty", "-m", "third")
69 mustGit(t, dir, env, "tag", "-f", "v1.1")
70 if out, code := gitRun(t, dir, env, "push", "--force", "origin", "v1.1"); code == 0 || !strings.Contains(out, "anchors a release") {
71 t.Fatalf("release tag moved: %d\n%s", code, out)
72 }
73 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "delete", "alice/app", "v1.1", "--yes"); code != 0 {
74 t.Fatalf("release delete: %s", errOut)
75 }
76 mustGit(t, dir, env, "push", "-q", "origin", ":v1.1")
77}
internal/control/repo.go +46 −2
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "io"
77 "os"
8 "path"
89 "path/filepath"
910 "slices"
1011 "strings"
@@ -60,6 +61,12 @@ func init() {
6061 register(Command{Path: []string{"repo", "settings", "unprotect"},
6162 Summary: "unprotect a branch",
6263 Usage: "repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
64 register(Command{Path: []string{"repo", "settings", "protect-tag"},
65 Summary: "protect tags matching a glob (created once, never moved or deleted)",
66 Usage: "repo settings protect-tag <owner/name> <glob>", Run: runProtectTag})
67 register(Command{Path: []string{"repo", "settings", "unprotect-tag"},
68 Summary: "drop a protected-tag glob",
69 Usage: "repo settings unprotect-tag <owner/name> <glob>", Run: runUnprotectTag})
6370 register(Command{Path: []string{"repo", "settings", "description"},
6471 Summary: "set the repository description",
6572 Usage: "repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
@@ -606,8 +613,8 @@ func runSettingsShow(c *Ctx, args []string) int {
606613 return code
607614 }
608615 return c.emit(repo.Settings, func(w io.Writer) {
609 fmt.Fprintf(w, "protected_branches: %s\nrequire_mr: %v\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
610 strings.Join(repo.Settings.ProtectedBranches, ", "), repo.Settings.RequireMR, repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
616 fmt.Fprintf(w, "protected_branches: %s\nprotected_tags: %s\nrequire_mr: %v\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n",
617 strings.Join(repo.Settings.ProtectedBranches, ", "), strings.Join(repo.Settings.ProtectedTags, ", "), repo.Settings.RequireMR, repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived)
611618 })
612619}
613620
@@ -1062,6 +1069,43 @@ func runRepoBookmarks(c *Ctx, args []string) int {
10621069 })
10631070}
10641071
1072func runProtectTag(c *Ctx, args []string) int { return setProtectTag(c, args, true) }
1073func runUnprotectTag(c *Ctx, args []string) int { return setProtectTag(c, args, false) }
1074
1075func setProtectTag(c *Ctx, args []string, protect bool) int {
1076 if len(args) != 2 {
1077 return c.fail(protocol.ExitUsage, "usage: repo settings protect-tag|unprotect-tag <owner/name> <glob>")
1078 }
1079 glob := args[1]
1080 if _, err := path.Match(glob, "x"); err != nil || glob == "" {
1081 return c.fail(protocol.ExitUsage, "bad glob %q", glob)
1082 }
1083 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1084 if code >= 0 {
1085 return code
1086 }
1087 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1088 has := slices.Contains(s.ProtectedTags, glob)
1089 if protect && !has {
1090 s.ProtectedTags = append(s.ProtectedTags, glob)
1091 slices.Sort(s.ProtectedTags)
1092 }
1093 if !protect && has {
1094 s.ProtectedTags = slices.DeleteFunc(s.ProtectedTags, func(g string) bool { return g == glob })
1095 }
1096 })
1097 if err != nil {
1098 return c.fail(protocol.ExitFailure, "%v", err)
1099 }
1100 verb := "protected"
1101 if !protect {
1102 verb = "unprotected"
1103 }
1104 return c.emit(s, func(w io.Writer) {
1105 fmt.Fprintf(w, "tags %s %s on %s\n", glob, verb, repo.Path())
1106 })
1107}
1108
10651109func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
10661110func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
10671111
internal/hookd/hookd.go +25
@@ -132,6 +132,10 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
132132 enc.Encode(Response{Allow: false, Message: msg})
133133 return
134134 }
135 if msg := s.releaseAnchors(repo, req.Updates); msg != "" {
136 enc.Encode(Response{Allow: false, Message: msg})
137 return
138 }
135139 if !repo.Settings.RequireSignedCommits {
136140 enc.Encode(Response{Allow: true})
137141 return
@@ -181,6 +185,27 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
181185 enc.Encode(Response{Allow: true})
182186}
183187
188// releaseAnchors refuses deleting or moving a tag that a release is
189// anchored to. A release outliving its tag served assets for a commit
190// nobody could reach (#201); the release goes first, then the tag.
191func (s *Server) releaseAnchors(repo store.Repo, updates []policy.RefUpdate) string {
192 for _, u := range updates {
193 tag, ok := strings.CutPrefix(u.Ref, "refs/tags/")
194 if !ok || gitutil.ZeroSHA(u.Old) {
195 continue
196 }
197 if _, err := s.st.ReleaseByTag(repo.ID, tag); err != nil {
198 continue
199 }
200 verb := "moved"
201 if u.IsDelete {
202 verb = "deleted"
203 }
204 return fmt.Sprintf("tag %s anchors a release and cannot be %s: delete the release first", tag, verb)
205 }
206 return ""
207}
208
184209// postReceive applies the cross-repo MR effect: a push to a source branch
185210// refreshes refs/merge-requests/N/head in every target repo, by fetching —
186211// the target owns the objects, so the MR outlives the fork. This is the only
internal/httpd/settings.go +4
@@ -88,6 +88,10 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
8888 argv = []string{"repo", "settings", "protect", repo, v("branch")}
8989 case "unprotect":
9090 argv = []string{"repo", "settings", "unprotect", repo, v("branch")}
91 case "protect-tag":
92 argv = []string{"repo", "settings", "protect-tag", repo, v("glob")}
93 case "unprotect-tag":
94 argv = []string{"repo", "settings", "unprotect-tag", repo, v("glob")}
9195 case "deps":
9296 verb := "disable"
9397 if v("deps") == "on" {
internal/policy/access.go +21
@@ -1,6 +1,7 @@
11package policy
22
33import (
4 "path"
45 "strconv"
56 "strings"
67
@@ -98,6 +99,15 @@ func CheckPush(repo store.Repo, updates []RefUpdate) string {
9899 if strings.HasPrefix(u.Ref, "refs/merge-requests/") {
99100 return "refs/merge-requests/* is server-owned and cannot be pushed"
100101 }
102 // A protected tag is created once. Its globs match the tag name.
103 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && TagProtected(repo, tag) {
104 if u.IsDelete {
105 return "tag " + tag + " is protected: deletion refused"
106 }
107 if !isZeroSHA(u.Old) {
108 return "tag " + tag + " is protected: update refused"
109 }
110 }
101111 if protected[u.Ref] {
102112 branch := strings.TrimPrefix(u.Ref, "refs/heads/")
103113 if u.IsDelete {
@@ -117,4 +127,15 @@ func CheckPush(repo store.Repo, updates []RefUpdate) string {
117127 return ""
118128}
119129
130// TagProtected reports whether a tag name matches one of the repository's
131// protected-tag globs.
132func TagProtected(repo store.Repo, tag string) bool {
133 for _, g := range repo.Settings.ProtectedTags {
134 if ok, _ := path.Match(g, tag); ok {
135 return true
136 }
137 }
138 return false
139}
140
120141func isZeroSHA(sha string) bool { return sha != "" && strings.Trim(sha, "0") == "" }
internal/policy/access_test.go +25
@@ -112,6 +112,31 @@ func TestCheckPush(t *testing.T) {
112112 }
113113}
114114
115// A protected tag (glob) can be created once and neither moved nor
116// deleted (#201).
117func TestCheckPushProtectedTags(t *testing.T) {
118 repo := store.Repo{Settings: store.RepoSettings{ProtectedTags: []string{"v*"}}}
119 const zero = "0000000000000000000000000000000000000000"
120 cases := []struct {
121 name string
122 updates []RefUpdate
123 denied bool
124 }{
125 {"create protected", []RefUpdate{{Ref: "refs/tags/v1.0", Old: zero, New: "abc"}}, false},
126 {"delete protected", []RefUpdate{{Ref: "refs/tags/v1.0", Old: "abc", New: zero, IsDelete: true}}, true},
127 {"move protected", []RefUpdate{{Ref: "refs/tags/v1.0", Old: "abc", New: "def", IsForce: true}}, true},
128 {"delete unmatched", []RefUpdate{{Ref: "refs/tags/nightly", Old: "abc", New: zero, IsDelete: true}}, false},
129 }
130 for _, tc := range cases {
131 t.Run(tc.name, func(t *testing.T) {
132 msg := CheckPush(repo, tc.updates)
133 if (msg != "") != tc.denied {
134 t.Errorf("CheckPush = %q, denied should be %v", msg, tc.denied)
135 }
136 })
137 }
138}
139
115140// With require_mr, a protected branch takes no direct push once it
116141// exists; creating it and pushing elsewhere are unaffected (#197).
117142func TestCheckPushRequireMR(t *testing.T) {
internal/store/repos.go +1
@@ -23,6 +23,7 @@ type Repo struct {
2323
2424type RepoSettings struct {
2525 ProtectedBranches []string `json:"protected_branches,omitempty"`
26 ProtectedTags []string `json:"protected_tags,omitempty"` // path.Match globs
2627 RequireSignedCommits bool `json:"require_signed_commits,omitempty"`
2728 RequireChecks bool `json:"require_checks,omitempty"`
2829 RequireApprovals int `json:"require_approvals,omitempty"`
internal/web/templates/settings.html +19
@@ -113,6 +113,25 @@
113113</form>
114114<p class="meta">With merge requests only, a protected branch refuses every direct push once it exists; the merge gates above are then what a change has to pass.</p>
115115
116<h2>Protected tags</h2>
117{{if .Repo.Settings.ProtectedTags}}
118<ul class="protlist">
119{{range .Repo.Settings.ProtectedTags}}<li><code>{{.}}</code>
120 <form method="post" action="{{$base}}" class="inline">
121 <input type="hidden" name="field" value="unprotect-tag">
122 <input type="hidden" name="glob" value="{{.}}">
123 <button type="submit" class="linklike">Unprotect</button>
124 </form></li>
125{{end}}
126</ul>
127{{else}}<p class="meta">No protected tags. A tag matching a protected glob is created once and refuses moves and deletion. A tag a release is anchored to refuses both regardless.</p>{{end}}
128<form method="post" action="{{$base}}" class="setform">
129 <input type="hidden" name="field" value="protect-tag">
130 <label for="glob">Protect tags matching</label>
131 <input type="text" id="glob" name="glob" placeholder="v*">
132 <button type="submit">Protect</button>
133</form>
134
116135<h2>Dependencies</h2>
117136<form method="post" action="{{$base}}" class="setform">
118137 <input type="hidden" name="field" value="deps">