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") | |