Commit 060109696d
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Users.org +4 −1
| @@ -193,6 +193,7 @@ gitbay repo access grant you/project alice write # read | write | admin | ||
| 193 | 193 | gitbay repo access revoke you/project alice |
| 194 | 194 | gitbay repo settings protect you/project main # no force-push, no delete |
| 195 | 195 | gitbay repo settings require-mr you/project on # protected branches: merge requests only |
| 196 | gitbay repo settings protect-tag you/project 'v*' # matching tags: created once, never moved or deleted | |
| 196 | 197 | gitbay repo settings default-branch you/project trunk # HEAD, and what the web shows |
| 197 | 198 | gitbay repo settings require-signed you/project on # every commit must verify |
| 198 | 199 | gitbay repo settings git-daemon you/project on # expose over git:// |
| @@ -310,7 +311,9 @@ git add .gitbay/wiki && git commit -m "wiki" && git push | ||
| 310 | 311 | #+end_src |
| 311 | 312 | |
| 312 | 313 | Releases anchor notes and binary assets to a pushed tag (write access; |
| 313 | assets stream over SSH, capped by the instance's =max_asset_bytes=): | |
| 314 | assets stream over SSH, capped by the instance's =max_asset_bytes=). | |
| 315 | While a release exists its tag can be neither deleted nor moved, whether | |
| 316 | or not the tag is protected: delete the release first. | |
| 314 | 317 | |
| 315 | 318 | #+begin_src sh |
| 316 | 319 | gitbay release create v1.0 --title "First light" [--notes|--file -|$EDITOR] |
cmd/gitbay/main.go +2
| @@ -472,6 +472,8 @@ func repoCmd() *cobra.Command { | ||
| 472 | 472 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), |
| 473 | 473 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 474 | 474 | 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}), | |
| 475 | 477 | pass("default-branch", "set the default branch: <branch>", passOpts{server: []string{"repo", "settings", "default-branch"}, needsRepo: true}), |
| 476 | 478 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), |
| 477 | 479 | 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 12 | func 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 ( | ||
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | 7 | "os" |
| 8 | "path" | |
| 8 | 9 | "path/filepath" |
| 9 | 10 | "slices" |
| 10 | 11 | "strings" |
| @@ -60,6 +61,12 @@ func init() { | ||
| 60 | 61 | register(Command{Path: []string{"repo", "settings", "unprotect"}, |
| 61 | 62 | Summary: "unprotect a branch", |
| 62 | 63 | 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}) | |
| 63 | 70 | register(Command{Path: []string{"repo", "settings", "description"}, |
| 64 | 71 | Summary: "set the repository description", |
| 65 | 72 | Usage: "repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription}) |
| @@ -606,8 +613,8 @@ func runSettingsShow(c *Ctx, args []string) int { | ||
| 606 | 613 | return code |
| 607 | 614 | } |
| 608 | 615 | 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) | |
| 611 | 618 | }) |
| 612 | 619 | } |
| 613 | 620 | |
| @@ -1062,6 +1069,43 @@ func runRepoBookmarks(c *Ctx, args []string) int { | ||
| 1062 | 1069 | }) |
| 1063 | 1070 | } |
| 1064 | 1071 | |
| 1072 | func runProtectTag(c *Ctx, args []string) int { return setProtectTag(c, args, true) } | |
| 1073 | func runUnprotectTag(c *Ctx, args []string) int { return setProtectTag(c, args, false) } | |
| 1074 | ||
| 1075 | func 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 | ||
| 1065 | 1109 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 1066 | 1110 | func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } |
| 1067 | 1111 | |
internal/hookd/hookd.go +25
| @@ -132,6 +132,10 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { | ||
| 132 | 132 | enc.Encode(Response{Allow: false, Message: msg}) |
| 133 | 133 | return |
| 134 | 134 | } |
| 135 | if msg := s.releaseAnchors(repo, req.Updates); msg != "" { | |
| 136 | enc.Encode(Response{Allow: false, Message: msg}) | |
| 137 | return | |
| 138 | } | |
| 135 | 139 | if !repo.Settings.RequireSignedCommits { |
| 136 | 140 | enc.Encode(Response{Allow: true}) |
| 137 | 141 | return |
| @@ -181,6 +185,27 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { | ||
| 181 | 185 | enc.Encode(Response{Allow: true}) |
| 182 | 186 | } |
| 183 | 187 | |
| 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. | |
| 191 | func (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 | ||
| 184 | 209 | // postReceive applies the cross-repo MR effect: a push to a source branch |
| 185 | 210 | // refreshes refs/merge-requests/N/head in every target repo, by fetching — |
| 186 | 211 | // 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. | ||
| 88 | 88 | argv = []string{"repo", "settings", "protect", repo, v("branch")} |
| 89 | 89 | case "unprotect": |
| 90 | 90 | 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")} | |
| 91 | 95 | case "deps": |
| 92 | 96 | verb := "disable" |
| 93 | 97 | if v("deps") == "on" { |
internal/policy/access.go +21
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package policy |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "path" | |
| 4 | 5 | "strconv" |
| 5 | 6 | "strings" |
| 6 | 7 | |
| @@ -98,6 +99,15 @@ func CheckPush(repo store.Repo, updates []RefUpdate) string { | ||
| 98 | 99 | if strings.HasPrefix(u.Ref, "refs/merge-requests/") { |
| 99 | 100 | return "refs/merge-requests/* is server-owned and cannot be pushed" |
| 100 | 101 | } |
| 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 | } | |
| 101 | 111 | if protected[u.Ref] { |
| 102 | 112 | branch := strings.TrimPrefix(u.Ref, "refs/heads/") |
| 103 | 113 | if u.IsDelete { |
| @@ -117,4 +127,15 @@ func CheckPush(repo store.Repo, updates []RefUpdate) string { | ||
| 117 | 127 | return "" |
| 118 | 128 | } |
| 119 | 129 | |
| 130 | // TagProtected reports whether a tag name matches one of the repository's | |
| 131 | // protected-tag globs. | |
| 132 | func 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 | ||
| 120 | 141 | func 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) { | ||
| 112 | 112 | } |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | // A protected tag (glob) can be created once and neither moved nor | |
| 116 | // deleted (#201). | |
| 117 | func 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 | ||
| 115 | 140 | // With require_mr, a protected branch takes no direct push once it |
| 116 | 141 | // exists; creating it and pushing elsewhere are unaffected (#197). |
| 117 | 142 | func TestCheckPushRequireMR(t *testing.T) { |
internal/store/repos.go +1
| @@ -23,6 +23,7 @@ type Repo struct { | ||
| 23 | 23 | |
| 24 | 24 | type RepoSettings struct { |
| 25 | 25 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 26 | ProtectedTags []string `json:"protected_tags,omitempty"` // path.Match globs | |
| 26 | 27 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` |
| 27 | 28 | RequireChecks bool `json:"require_checks,omitempty"` |
| 28 | 29 | RequireApprovals int `json:"require_approvals,omitempty"` |
internal/web/templates/settings.html +19
| @@ -113,6 +113,25 @@ | ||
| 113 | 113 | </form> |
| 114 | 114 | <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> |
| 115 | 115 | |
| 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 | ||
| 116 | 135 | <h2>Dependencies</h2> |
| 117 | 136 | <form method="post" action="{{$base}}" class="setform"> |
| 118 | 137 | <input type="hidden" name="field" value="deps"> |