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.
Require an image for every CI job
70b702649b875d841e294f5a085ca784d43e5bc4 · Keenan Nemetz · 15d ago · parent 7da9111e
Require an image for every CI job Runners now require an OCI runtime, and each workflow job must name either a registry image or an image-file definition under .vole/images. Definitions are validated at push time, pinned with the run, built locally by the runner, and cached by definition digest. Records and the run page carry the image and definition digest. Split the CI guide into workflows and runners, and add reference pages for workflow and image definition fields.
ci/image_test.go +166
| @@ -0,0 +1,166 @@ | |||
| 1 | package ci | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func TestImageDefinitionRendersDockerfile(t *testing.T) { | ||
| 11 | t.Parallel() | ||
| 12 | content := `version: 1 | ||
| 13 | from: golang:1.27-bookworm@sha256:abc | ||
| 14 | env: | ||
| 15 | CGO_ENABLED: "0" | ||
| 16 | MESSAGE: "hello world" | ||
| 17 | run: | ||
| 18 | - apt-get update && apt-get install -y --no-install-recommends jq | ||
| 19 | - go install example.com/tool@v1.2.3 | ||
| 20 | ` | ||
| 21 | def, problems := ParseImage(".vole/images/go.yaml", content) | ||
| 22 | if len(problems) > 0 { | ||
| 23 | t.Fatalf("valid image rejected: %v", problems) | ||
| 24 | } | ||
| 25 | if def.From != "golang:1.27-bookworm@sha256:abc" || len(def.Run) != 2 { | ||
| 26 | t.Fatalf("definition = %+v", def) | ||
| 27 | } | ||
| 28 | dockerfile := def.Dockerfile() | ||
| 29 | for _, want := range []string{ | ||
| 30 | "FROM golang:1.27-bookworm@sha256:abc", | ||
| 31 | `ENV CGO_ENABLED="0"`, | ||
| 32 | `ENV MESSAGE="hello world"`, | ||
| 33 | `RUN ["/bin/sh", "-c", "apt-get update && apt-get install -y --no-install-recommends jq"]`, | ||
| 34 | `RUN ["/bin/sh", "-c", "go install example.com/tool@v1.2.3"]`, | ||
| 35 | } { | ||
| 36 | if !strings.Contains(dockerfile, want) { | ||
| 37 | t.Fatalf("Dockerfile does not contain %q:\n%s", want, dockerfile) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | if ImageDigest(content) == "" || ImageDigest(content) != ImageDigest(content) { | ||
| 41 | t.Fatal("definition digest is not stable") | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | func TestImageDefinitionProblems(t *testing.T) { | ||
| 46 | t.Parallel() | ||
| 47 | cases := []struct { | ||
| 48 | name, body, want string | ||
| 49 | }{ | ||
| 50 | {"no version", "from: alpine:3.22\n", "version is required"}, | ||
| 51 | {"bad version", "version: 2\nfrom: alpine:3.22\n", "not supported"}, | ||
| 52 | {"no from", "version: 1\n", "image reference is required"}, | ||
| 53 | {"bad from", "version: 1\nfrom: \"./local\"\n", "not an image reference"}, | ||
| 54 | {"reserved from", "version: 1\nfrom: file:go\n", "starts with"}, | ||
| 55 | {"bad env", "version: 1\nfrom: alpine:3.22\nenv:\n bad-name: x\n", "environment variable name"}, | ||
| 56 | {"multiline env", "version: 1\nfrom: alpine:3.22\nenv:\n A: \"x\\ny\"\n", "one line"}, | ||
| 57 | {"empty run", "version: 1\nfrom: alpine:3.22\nrun:\n - \"\"\n", "command is empty"}, | ||
| 58 | {"multiline run", "version: 1\nfrom: alpine:3.22\nrun:\n - \"echo one\\necho two\"\n", "one line"}, | ||
| 59 | } | ||
| 60 | for _, tc := range cases { | ||
| 61 | t.Run(tc.name, func(t *testing.T) { | ||
| 62 | t.Parallel() | ||
| 63 | if _, problems := ParseImage(".vole/images/x.yaml", tc.body); !strings.Contains(strings.Join(problems, " "), tc.want) { | ||
| 64 | t.Fatalf("problems = %v, want %q", problems, tc.want) | ||
| 65 | } | ||
| 66 | }) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | func TestWorkflowRequiresExactlyOneImage(t *testing.T) { | ||
| 71 | t.Parallel() | ||
| 72 | base := "version: 1\njobs:\n build:\n %s\n steps:\n - run: true\n" | ||
| 73 | cases := []struct { | ||
| 74 | name, field, want string | ||
| 75 | }{ | ||
| 76 | {"missing", "", "image or image-file is required"}, | ||
| 77 | {"both", "image: alpine:3.22\n image-file: go", "mutually exclusive"}, | ||
| 78 | {"bad ref", "image: \"has space\"", "not an image reference"}, | ||
| 79 | {"reserved ref", "image: file:go", "starts with"}, | ||
| 80 | {"bad file name", "image-file: Go", "not an image name"}, | ||
| 81 | } | ||
| 82 | for _, tc := range cases { | ||
| 83 | t.Run(tc.name, func(t *testing.T) { | ||
| 84 | t.Parallel() | ||
| 85 | if _, problems := Parse("ci.yaml", strings.Replace(base, "%s", tc.field, 1)); !strings.Contains(strings.Join(problems, " "), tc.want) { | ||
| 86 | t.Fatalf("problems = %v, want %q", problems, tc.want) | ||
| 87 | } | ||
| 88 | }) | ||
| 89 | } | ||
| 90 | |||
| 91 | wf, problems := Parse("ci.yaml", strings.Replace(base, "%s", "image: golang:1.27", 1)) | ||
| 92 | if len(problems) > 0 { | ||
| 93 | t.Fatalf("direct image rejected: %v", problems) | ||
| 94 | } | ||
| 95 | if wf.Jobs[0].Image != "golang:1.27" || wf.Jobs[0].ImageFile != "" { | ||
| 96 | t.Fatalf("job = %+v", wf.Jobs[0]) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | func TestCheckResolvesImageFile(t *testing.T) { | ||
| 101 | t.Parallel() | ||
| 102 | content := "version: 1\nfrom: golang:1.27-bookworm\nrun:\n - go version\n" | ||
| 103 | files := map[string]string{ | ||
| 104 | ".vole/workflows/ci.yaml": "version: 1\njobs:\n build:\n image-file: go\n steps:\n - run: go test ./...\n", | ||
| 105 | } | ||
| 106 | images := map[string]string{".vole/images/go.yaml": content} | ||
| 107 | wfs, problems := CheckWithImages(files, images) | ||
| 108 | if len(problems) > 0 { | ||
| 109 | t.Fatalf("valid image-file rejected: %v", problems) | ||
| 110 | } | ||
| 111 | job := wfs[0].Jobs[0] | ||
| 112 | if job.Image != "file:go" || job.ImageFile != "go" || job.ImageDefinition != content { | ||
| 113 | t.Fatalf("job = %+v", job) | ||
| 114 | } | ||
| 115 | if job.ImageSHA != ImageDigest(content) { | ||
| 116 | t.Fatalf("ImageSHA = %q, want %q", job.ImageSHA, ImageDigest(content)) | ||
| 117 | } | ||
| 118 | |||
| 119 | // A reference with no definition is a push-time error, not a mystery build. | ||
| 120 | _, problems = CheckWithImages(files, map[string]string{}) | ||
| 121 | if !strings.Contains(strings.Join(problems, " "), ImagePath("go")) { | ||
| 122 | t.Fatalf("missing definition problems = %v", problems) | ||
| 123 | } | ||
| 124 | |||
| 125 | // Unreferenced definitions are still validated. | ||
| 126 | _, problems = CheckWithImages(nil, map[string]string{".vole/images/bad.yaml": "version: 1\n"}) | ||
| 127 | if !strings.Contains(strings.Join(problems, " "), "image reference is required") { | ||
| 128 | t.Fatalf("unreferenced bad definition problems = %v", problems) | ||
| 129 | } | ||
| 130 | |||
| 131 | // Two extensions for one name are ambiguous. | ||
| 132 | _, problems = CheckWithImages(files, map[string]string{ | ||
| 133 | ".vole/images/go.yaml": content, | ||
| 134 | ".vole/images/go.yml": content, | ||
| 135 | }) | ||
| 136 | if !strings.Contains(strings.Join(problems, " "), "already defined") { | ||
| 137 | t.Fatalf("duplicate definition problems = %v", problems) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | func TestCheckDirReadsImages(t *testing.T) { | ||
| 142 | t.Parallel() | ||
| 143 | root := t.TempDir() | ||
| 144 | write := func(path, content string) { | ||
| 145 | t.Helper() | ||
| 146 | p := filepath.Join(root, filepath.FromSlash(path)) | ||
| 147 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | ||
| 148 | t.Fatal(err) | ||
| 149 | } | ||
| 150 | if err := os.WriteFile(p, []byte(content), 0o644); err != nil { | ||
| 151 | t.Fatal(err) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | write(".vole/workflows/ci.yaml", "version: 1\njobs:\n build:\n image-file: go\n steps:\n - run: true\n") | ||
| 155 | write(".vole/images/go.yaml", "version: 1\nfrom: golang:1.27\n") | ||
| 156 | wfs, problems, err := CheckDir(root) | ||
| 157 | if err != nil { | ||
| 158 | t.Fatal(err) | ||
| 159 | } | ||
| 160 | if len(problems) > 0 { | ||
| 161 | t.Fatalf("CheckDir: %v", problems) | ||
| 162 | } | ||
| 163 | if wfs[0].Jobs[0].ImageDefinition == "" || wfs[0].Jobs[0].ImageSHA == "" { | ||
| 164 | t.Fatalf("image definition was not read: %+v", wfs[0].Jobs[0]) | ||
| 165 | } | ||
| 166 | } | ||