vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software: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 · 16d 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/agent/build_test.go +100
| @@ -0,0 +1,100 @@ | |||
| 1 | package agent | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/rodent-software/vole/ci" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // A definition is built once, tagged by its digest, and reused by a later job. | ||
| 14 | // The fake runtime records the Dockerfile the agent generated and the build count. | ||
| 15 | func TestResolveImageBuildsAndCachesDefinition(t *testing.T) { | ||
| 16 | dir := t.TempDir() | ||
| 17 | state := filepath.Join(dir, "state") | ||
| 18 | if err := os.MkdirAll(state, 0o755); err != nil { | ||
| 19 | t.Fatal(err) | ||
| 20 | } | ||
| 21 | script := `#!/bin/sh | ||
| 22 | cmd="$1"; shift | ||
| 23 | state="${FAKE_STATE:?}" | ||
| 24 | case "$cmd" in | ||
| 25 | image) | ||
| 26 | tag="$2" | ||
| 27 | m=$(printf '%s' "$tag" | tr '/:' '__') | ||
| 28 | [ -f "$state/$m" ] && exit 0 | ||
| 29 | exit 1 | ||
| 30 | ;; | ||
| 31 | build) | ||
| 32 | tag=; df= | ||
| 33 | while [ $# -gt 0 ]; do | ||
| 34 | case "$1" in | ||
| 35 | -t) tag=$2; shift 2 ;; | ||
| 36 | -f) df=$2; shift 2 ;; | ||
| 37 | *) shift ;; | ||
| 38 | esac | ||
| 39 | done | ||
| 40 | cp "$df" "$state/Dockerfile.captured" | ||
| 41 | m=$(printf '%s' "$tag" | tr '/:' '__') | ||
| 42 | printf 'build\n' >> "$state/builds" | ||
| 43 | : > "$state/$m" | ||
| 44 | echo "fake build" | ||
| 45 | exit 0 | ||
| 46 | ;; | ||
| 47 | *) exit 0 ;; | ||
| 48 | esac | ||
| 49 | ` | ||
| 50 | if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { | ||
| 51 | t.Fatal(err) | ||
| 52 | } | ||
| 53 | t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) | ||
| 54 | t.Setenv("FAKE_STATE", state) | ||
| 55 | |||
| 56 | cfg, err := containerOptions(Options{ContainerRuntime: "docker"}, t.TempDir()) | ||
| 57 | if err != nil { | ||
| 58 | t.Fatal(err) | ||
| 59 | } | ||
| 60 | content := "version: 1\nfrom: alpine:3.22\nenv:\n X: \"1\"\nrun:\n - echo hello\n" | ||
| 61 | job := &runnerJob{ | ||
| 62 | ID: "run1", Attempt: 1, | ||
| 63 | Image: "file:go", ImageFile: "go", ImageDefinition: content, ImageSHA: ci.ImageDigest(content), | ||
| 64 | } | ||
| 65 | log := &strings.Builder{} | ||
| 66 | s := &runnerSession{} | ||
| 67 | tag, err := s.resolveImage(job, cfg, log, time.Hour) | ||
| 68 | if err != nil { | ||
| 69 | t.Fatalf("resolveImage: %v", err) | ||
| 70 | } | ||
| 71 | if !strings.HasPrefix(tag, "vole/image-file:go-") { | ||
| 72 | t.Fatalf("tag = %q", tag) | ||
| 73 | } | ||
| 74 | captured, err := os.ReadFile(filepath.Join(state, "Dockerfile.captured")) | ||
| 75 | if err != nil { | ||
| 76 | t.Fatal(err) | ||
| 77 | } | ||
| 78 | for _, want := range []string{"FROM alpine:3.22", `ENV X="1"`, `RUN ["/bin/sh", "-c", "echo hello"]`} { | ||
| 79 | if !strings.Contains(string(captured), want) { | ||
| 80 | t.Fatalf("Dockerfile does not contain %q:\n%s", want, captured) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | // The second call sees the digest tag and does not build again. | ||
| 85 | log.Reset() | ||
| 86 | again, err := s.resolveImage(job, cfg, log, time.Hour) | ||
| 87 | if err != nil || again != tag { | ||
| 88 | t.Fatalf("cached resolve = %q, %v; want %q", again, err, tag) | ||
| 89 | } | ||
| 90 | if !strings.Contains(log.String(), "using cached") { | ||
| 91 | t.Fatalf("second build was not cached:\n%s", log) | ||
| 92 | } | ||
| 93 | builds, err := os.ReadFile(filepath.Join(state, "builds")) | ||
| 94 | if err != nil { | ||
| 95 | t.Fatal(err) | ||
| 96 | } | ||
| 97 | if strings.Count(string(builds), "build") != 1 { | ||
| 98 | t.Fatalf("build count = %d, want 1:\n%s", strings.Count(string(builds), "build"), builds) | ||
| 99 | } | ||
| 100 | } | ||