policy, hookd, control, web, wiki: protected tags, and a release keeps its tag !340
10 files changed, +224 −3
Layout: unified · split
.gitbay/wiki/Users.org +4 −1
| @@ -193,6 +193,7 @@ gitbay repo access grant you/project alice write # read | write | admin | |||
| 193 | gitbay repo access revoke you/project alice | 193 | gitbay repo access revoke you/project alice |
| 194 | gitbay repo settings protect you/project main # no force-push, no delete | 194 | gitbay repo settings protect you/project main # no force-push, no delete |
| 195 | gitbay repo settings require-mr you/project on # protected branches: merge requests only | 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 | gitbay repo settings default-branch you/project trunk # HEAD, and what the web shows | 197 | gitbay repo settings default-branch you/project trunk # HEAD, and what the web shows |
| 197 | gitbay repo settings require-signed you/project on # every commit must verify | 198 | gitbay repo settings require-signed you/project on # every commit must verify |
| 198 | gitbay repo settings git-daemon you/project on # expose over git:// | 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 | #+end_src | 311 | #+end_src |
| 311 | 312 | ||
| 312 | Releases anchor notes and binary assets to a pushed tag (write access; | 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 | #+begin_src sh | 318 | #+begin_src sh |
| 316 | gitbay release create v1.0 --title "First light" [--notes|--file -|$EDITOR] | 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 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), | 472 | pass("show", "show settings", passOpts{server: []string{"repo", "settings", "show"}, needsRepo: true}), |
| 473 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), | 473 | pass("protect", "protect a branch", passOpts{server: []string{"repo", "settings", "protect"}, needsRepo: true}), |
| 474 | pass("unprotect", "unprotect a branch", passOpts{server: []string{"repo", "settings", "unprotect"}, needsRepo: true}), | 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 | pass("default-branch", "set the default branch: <branch>", passOpts{server: []string{"repo", "settings", "default-branch"}, needsRepo: true}), | 477 | pass("default-branch", "set the default branch: <branch>", passOpts{server: []string{"repo", "settings", "default-branch"}, needsRepo: true}), |
| 476 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), | 478 | pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), |
| 477 | pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), | 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 | "fmt" | 5 | "fmt" |
| 6 | "io" | 6 | "io" |
| 7 | "os" | 7 | "os" |
| 8 | "path" | ||
| 8 | "path/filepath" | 9 | "path/filepath" |
| 9 | "slices" | 10 | "slices" |
| 10 | "strings" | 11 | "strings" |
| @@ -60,6 +61,12 @@ func init() { | |||
| 60 | register(Command{Path: []string{"repo", "settings", "unprotect"}, | 61 | register(Command{Path: []string{"repo", "settings", "unprotect"}, |
| 61 | Summary: "unprotect a branch", | 62 | Summary: "unprotect a branch", |
| 62 | Usage: "repo settings unprotect <owner/name> <branch>", Run: runUnprotect}) | 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 | register(Command{Path: []string{"repo", "settings", "description"}, | 70 | register(Command{Path: []string{"repo", "settings", "description"}, |
| 64 | Summary: "set the repository description", | 71 | Summary: "set the repository description", |
| 65 | Usage: "repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription}) | 72 | Usage: "repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription}) |
| @@ -606,8 +613,8 @@ func runSettingsShow(c *Ctx, args []string) int { | |||
| 606 | return code | 613 | return code |
| 607 | } | 614 | } |
| 608 | return c.emit(repo.Settings, func(w io.Writer) { | 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", | 616 | fmt.Fprintf(w, "protected_branches: %s\nprotected_tags: %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) | 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 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } | 1109 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 1066 | func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } | 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 | enc.Encode(Response{Allow: false, Message: msg}) | 132 | enc.Encode(Response{Allow: false, Message: msg}) |
| 133 | return | 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 | if !repo.Settings.RequireSignedCommits { | 139 | if !repo.Settings.RequireSignedCommits { |
| 136 | enc.Encode(Response{Allow: true}) | 140 | enc.Encode(Response{Allow: true}) |
| 137 | return | 141 | return |
| @@ -181,6 +185,27 @@ func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { | |||
| 181 | enc.Encode(Response{Allow: true}) | 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 | // postReceive applies the cross-repo MR effect: a push to a source branch | 209 | // postReceive applies the cross-repo MR effect: a push to a source branch |
| 185 | // refreshes refs/merge-requests/N/head in every target repo, by fetching — | 210 | // refreshes refs/merge-requests/N/head in every target repo, by fetching — |
| 186 | // the target owns the objects, so the MR outlives the fork. This is the only | 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 | argv = []string{"repo", "settings", "protect", repo, v("branch")} | 88 | argv = []string{"repo", "settings", "protect", repo, v("branch")} |
| 89 | case "unprotect": | 89 | case "unprotect": |
| 90 | argv = []string{"repo", "settings", "unprotect", repo, v("branch")} | 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 | case "deps": | 95 | case "deps": |
| 92 | verb := "disable" | 96 | verb := "disable" |
| 93 | if v("deps") == "on" { | 97 | if v("deps") == "on" { |
internal/policy/access.go +21
| @@ -1,6 +1,7 @@ | |||
| 1 | package policy | 1 | package policy |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "path" | ||
| 4 | "strconv" | 5 | "strconv" |
| 5 | "strings" | 6 | "strings" |
| 6 | 7 | ||
| @@ -98,6 +99,15 @@ func CheckPush(repo store.Repo, updates []RefUpdate) string { | |||
| 98 | if strings.HasPrefix(u.Ref, "refs/merge-requests/") { | 99 | if strings.HasPrefix(u.Ref, "refs/merge-requests/") { |
| 99 | return "refs/merge-requests/* is server-owned and cannot be pushed" | 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 | if protected[u.Ref] { | 111 | if protected[u.Ref] { |
| 102 | branch := strings.TrimPrefix(u.Ref, "refs/heads/") | 112 | branch := strings.TrimPrefix(u.Ref, "refs/heads/") |
| 103 | if u.IsDelete { | 113 | if u.IsDelete { |
| @@ -117,4 +127,15 @@ func CheckPush(repo store.Repo, updates []RefUpdate) string { | |||
| 117 | return "" | 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 | func isZeroSHA(sha string) bool { return sha != "" && strings.Trim(sha, "0") == "" } | 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 | // With require_mr, a protected branch takes no direct push once it | 140 | // With require_mr, a protected branch takes no direct push once it |
| 116 | // exists; creating it and pushing elsewhere are unaffected (#197). | 141 | // exists; creating it and pushing elsewhere are unaffected (#197). |
| 117 | func TestCheckPushRequireMR(t *testing.T) { | 142 | func TestCheckPushRequireMR(t *testing.T) { |
internal/store/repos.go +1
| @@ -23,6 +23,7 @@ type Repo struct { | |||
| 23 | 23 | ||
| 24 | type RepoSettings struct { | 24 | type RepoSettings struct { |
| 25 | ProtectedBranches []string `json:"protected_branches,omitempty"` | 25 | ProtectedBranches []string `json:"protected_branches,omitempty"` |
| 26 | ProtectedTags []string `json:"protected_tags,omitempty"` // path.Match globs | ||
| 26 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` | 27 | RequireSignedCommits bool `json:"require_signed_commits,omitempty"` |
| 27 | RequireChecks bool `json:"require_checks,omitempty"` | 28 | RequireChecks bool `json:"require_checks,omitempty"` |
| 28 | RequireApprovals int `json:"require_approvals,omitempty"` | 29 | RequireApprovals int `json:"require_approvals,omitempty"` |
internal/web/templates/settings.html +19
| @@ -113,6 +113,25 @@ | |||
| 113 | </form> | 113 | </form> |
| 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> | 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 | <h2>Dependencies</h2> | 135 | <h2>Dependencies</h2> |
| 117 | <form method="post" action="{{$base}}" class="setform"> | 136 | <form method="post" action="{{$base}}" class="setform"> |
| 118 | <input type="hidden" name="field" value="deps"> | 137 | <input type="hidden" name="field" value="deps"> |