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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Phase 4 slice 3: checks on the gate, and statuses on the page

35eaeccffdab9d07e13fb7b346060e535697fee7 · Keenan Nemetz · 17d ago · parent b44cbfaf

Phase 4 slice 3: checks on the gate, and statuses on the page

A protected branch now requires both an approved change request and passing checks
for the commit being landed. Approvals say a person read the change; checks say it
works. Neither substitutes for the other.

The gate asks about the *approved head*, not about the merge commit somebody may be
pushing: CI ran on the head, and a merge commit is a new object nobody built. That
also keeps the gate consistent with what approvals already name.

Details that decide whether this is usable:

- A repository with no workflows is unaffected — nothing to run means nothing to
  wait for — so existing repositories do not start failing pushes.
- A job with no run is refused with an explanation that names the branch filter,
  because `on: push: branches: [main]` never produces a result for a feature
  branch. That is the one configuration that can deadlock a merge, and the error
  says so instead of leaving somebody guessing.
- Failed, cancelled, still-running and missing are four different messages; a
  reviewer should be able to tell which.
- `vole acl checks <repo> on|off` turns it off, default on.

Statuses are readable: the repository page lists recent runs (job, commit, status,
attempts, age) and a change request shows its head's job statuses next to the
approvals, so the reviewer sees the same facts the gate uses.

Tests cover the three interesting shapes: approved + green lands; approved + red is
refused naming the failed job; approved + no run at all is refused with the
branch-filter hint.

Verified on a live instance: pushing a workflow queues and runs it, the repository
page shows `build · .vole/workflows/ci.yaml · success`, and the change request shows
`wilbur · current` alongside `build · success`.

Phase 4 is now complete except signed run records, which are Phase 7's.

internal/server/integration_test.go +128

@@ -1897,6 +1897,134 @@ jobs:
1897 1897 }
1898 1898 }
1899 1899
1900 // runOnce executes one queued job with a real runner.
1901 func (f *fixture) runOnce(t *testing.T) {
1902 t.Helper()
1903 cmd := exec.Command(binPath, "runner", "--remote", f.sshBase(), "--once", "--dir", t.TempDir())
1904 cmd.Env = f.ciEnv()
1905 if out, err := cmd.CombinedOutput(); err != nil {
1906 t.Fatalf("runner: %v\n%s", err, out)
1907 }
1908 }
1909
1910 // openAndApprove files a request for head and gets it approved.
1911 func (f *fixture) openAndApprove(t *testing.T, head, author string) {
1912 t.Helper()
1913 meta := f.newMetaRepo(t)
1914 if err := meta.apply(map[string]string{
1915 "requests/" + reqID + "/meta.json": requestMeta("main", head, author, f.accountID(author)),
1916 }, "request"); err != nil {
1917 t.Fatalf("request: %v", err)
1918 }
1919 approver := f.newMetaRepo(t)
1920 if err := approver.apply(map[string]string{
1921 "requests/" + reqID + "/approvals/" + f.ownerID + ".json": approvalFile("main", head),
1922 }, "approve"); err != nil {
1923 t.Fatalf("approve: %v", err)
1924 }
1925 }
1926
1927 // TestProtectedBranchRequiresGreenChecks: approvals say a person read the change,
1928 // checks say it works, and a protected branch wants both.
1929 func TestProtectedBranchRequiresGreenChecks(t *testing.T) {
1930 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
1931 writerKey := f.addWriter(t)
1932 f.publish(f.repo)
1933 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
1934 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1935 t.Fatalf("initial main: %s", errs)
1936 }
1937 m := mustRepo(t, f)
1938 a, _ := m.ACL()
1939 a.Protected = []string{"refs/heads/main"}
1940 if err := m.SetACL(a, "test"); err != nil {
1941 t.Fatal(err)
1942 }
1943
1944 feature, head := f.cloneWithChange(t, workflowFile("version: 1\njobs:\n test:\n steps:\n - run: echo ok\n"), "ci")
1945 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1946 t.Fatalf("feature: %s", errs)
1947 }
1948 f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.SHA == head })
1949 f.runOnce(t)
1950 f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
1951 return r.SHA == head && r.Status == runs.StatusSuccess
1952 })
1953
1954 f.openAndApprove(t, head, "wilbur")
1955 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil {
1956 t.Fatalf("approved and green push was refused: %s", errs)
1957 }
1958 }
1959
1960 // TestFailingCheckBlocksTheMerge: a green approval does not outvote a red build.
1961 func TestFailingCheckBlocksTheMerge(t *testing.T) {
1962 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
1963 writerKey := f.addWriter(t)
1964 f.publish(f.repo)
1965 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
1966 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1967 t.Fatalf("initial main: %s", errs)
1968 }
1969 m := mustRepo(t, f)
1970 a, _ := m.ACL()
1971 a.Protected = []string{"refs/heads/main"}
1972 if err := m.SetACL(a, "test"); err != nil {
1973 t.Fatal(err)
1974 }
1975
1976 feature, head := f.cloneWithChange(t, workflowFile("version: 1\njobs:\n test:\n steps:\n - run: exit 3\n"), "ci")
1977 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1978 t.Fatalf("feature: %s", errs)
1979 }
1980 f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.SHA == head })
1981 f.runOnce(t)
1982 f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
1983 return r.SHA == head && r.Status == runs.StatusFailed
1984 })
1985
1986 f.openAndApprove(t, head, "wilbur")
1987 _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main")
1988 if err == nil {
1989 t.Fatal("a commit with a failing check landed on a protected branch")
1990 }
1991 if !strings.Contains(errs, "failed for") {
1992 t.Fatalf("rejection does not say the check failed:\n%s", errs)
1993 }
1994 }
1995
1996 // TestMissingRunBlocksWithAHint: a workflow that only runs on main can never
1997 // produce a result for a feature branch, and the message has to say so.
1998 func TestMissingRunBlocksWithAHint(t *testing.T) {
1999 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 2 })
2000 writerKey := f.addWriter(t)
2001 f.publish(f.repo)
2002 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
2003 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
2004 t.Fatalf("initial main: %s", errs)
2005 }
2006 m := mustRepo(t, f)
2007 a, _ := m.ACL()
2008 a.Protected = []string{"refs/heads/main"}
2009 if err := m.SetACL(a, "test"); err != nil {
2010 t.Fatal(err)
2011 }
2012
2013 // This workflow filters to main, so pushing a feature branch queues nothing.
2014 feature, head := f.cloneWithChange(t, workflowFile("version: 1\non:\n push:\n branches: [main]\njobs:\n test:\n steps:\n - run: echo ok\n"), "ci")
2015 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
2016 t.Fatalf("feature: %s", errs)
2017 }
2018 f.openAndApprove(t, head, "wilbur")
2019 _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main")
2020 if err == nil {
2021 t.Fatal("a commit with no CI result landed on a protected branch")
2022 }
2023 if !strings.Contains(errs, "has no run for") || !strings.Contains(errs, "filters on [main]") {
2024 t.Fatalf("rejection does not explain the branch filter:\n%s", errs)
2025 }
2026 }
2027
1900 2028 func mustSHA(t *testing.T, dir string) string {
1901 2029 t.Helper()
1902 2030 out, _, err := runGit(t, dir, "rev-parse", "HEAD")