package configkit import ( "bytes" "os" "os/exec" "path/filepath" "strings" "testing" ) // Exercise the copyable runbook against disposable Git repositories, not a // publication service. The checker itself is covered separately. func TestReleaseRunbookGuards(t *testing.T) { document, err := os.ReadFile("docs/release.md") if err != nil { t.Fatal(err) } block := func(heading string) string { t.Helper() _, section, ok := strings.Cut(string(document), heading) if !ok { t.Fatalf("missing section %q", heading) } _, section, ok = strings.Cut(section, "```sh\n") if !ok { t.Fatalf("missing commands in %q", heading) } commands, _, ok := strings.Cut(section, "```") if !ok { t.Fatalf("unterminated commands in %q", heading) } return commands } prepare := block("## 4. Validate the source candidate") publish := block("## 6. Run publication guards") + block("## 7. Tag and publish the exact ref") for _, scenario := range []string{"success", "dirty", "changed-candidate", "remote-tag-exists", "failed-validation"} { t.Run(scenario, func(t *testing.T) { root := t.TempDir() remote := filepath.Join(t.TempDir(), "remote.git") run := func(program string, args ...string) string { t.Helper() cmd := exec.Command(program, args...) cmd.Dir = root out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%s %v: %v\n%s", program, args, err, out) } return strings.TrimSpace(string(out)) } run("git", "init", "-q", "-b", "main") run("git", "init", "-q", "--bare", remote) run("git", "config", "user.name", "Test") run("git", "config", "user.email", "test@example.invalid") run("git", "config", "commit.gpgsign", "false") run("git", "config", "tag.gpgsign", "false") const origin = "https://gitea.maximumdirect.net/eric/ConfigKit.git" run("git", "remote", "add", "origin", remote) // Substitute only the fixture's authoritative URL; execute the guards // themselves unchanged. Disallow network transports as a backstop. localPublish := strings.ReplaceAll(publish, origin, remote) for _, dir := range []string{"docs/releases", "scripts"} { if err := os.MkdirAll(filepath.Join(root, dir), 0700); err != nil { t.Fatal(err) } } write := func(path, content string) { t.Helper() if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0700); err != nil { t.Fatal(err) } } checker := "#!/bin/sh\nexit 0\n" if scenario == "failed-validation" { checker = "#!/bin/sh\nexit 37\n" } write("scripts/release-check.sh", checker) write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.0\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n\n## Changes\n") run("git", "add", ".") bash := func(commands string) ([]byte, error) { cmd := exec.Command("bash", "-c", "set -euo pipefail\nrelease_version=v0.9.0\n"+commands) cmd.Dir = root cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file") return cmd.CombinedOutput() } out, err := bash(prepare) if scenario == "failed-validation" { if err == nil { t.Fatal("failed validation accepted") } if _, err := os.Stat(filepath.Join(root, ".git/configkit-release-candidate")); !os.IsNotExist(err) { t.Fatal("failed validation recorded a candidate") } } else if err != nil { t.Fatalf("prepare: %v\n%s", err, out) } switch scenario { case "dirty": write("unexpected.txt", "uncommitted\n") case "changed-candidate": run("git", "commit", "--allow-empty", "-qm", "Changed candidate") case "remote-tag-exists": run("git", "tag", "v0.9.0") run("git", "push", remote, "refs/tags/v0.9.0") run("git", "tag", "-d", "v0.9.0") } // Seed the remote primary branch so fetch works before first publication. run("git", "push", remote, "HEAD:refs/heads/main") out, err = bash(localPublish) if scenario == "success" { if err != nil { t.Fatalf("publish: %v\n%s", err, out) } if got := run("git", "--git-dir="+remote, "rev-parse", "v0.9.0^{}"); got != run("git", "rev-parse", "HEAD") { t.Fatal("published wrong commit") } } else if err == nil { t.Fatalf("publication accepted %s", scenario) } else if scenario != "remote-tag-exists" && run("git", "--git-dir="+remote, "tag", "--list") != "" { t.Fatal("failed guard published a tag") } }) } } func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) { for _, scriptName := range []string{"release-check.sh", "check.sh"} { t.Run(scriptName, func(t *testing.T) { script, err := os.ReadFile("scripts/" + scriptName) if err != nil { t.Fatal(err) } for _, name := range []string{"success", "failed-go-check", "untracked-whitespace"} { fail := name != "success" t.Run(name, func(t *testing.T) { root := t.TempDir() bin := t.TempDir() log := filepath.Join(bin, "gofmt.args") calls := filepath.Join(bin, "go.calls") for _, directory := range []string{"scripts", "nested"} { if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil { t.Fatal(err) } } write := func(path string, data []byte, mode os.FileMode) { t.Helper() if err := os.WriteFile(path, data, mode); err != nil { t.Fatal(err) } } write(filepath.Join(root, "scripts", scriptName), script, 0700) write(filepath.Join(root, "go.mod"), []byte("module example.invalid/check\n"), 0600) write(filepath.Join(root, "go.sum"), nil, 0600) write(filepath.Join(root, "obsolete.go"), []byte("package obsolete\n"), 0600) files := []string{"nested/a space.go", "nested/a\nnewline.go"} for _, file := range files { write(filepath.Join(root, file), []byte("package example\n"), 0600) } if name == "untracked-whitespace" { write(filepath.Join(root, "notes.md"), []byte("trailing space \n"), 0600) } // Isolate expensive toolchain commands: this test owns shell orchestration, // not Go's compiler or the production tests exercised by the real checker. write(filepath.Join(bin, "go"), []byte("#!/bin/sh\nprintf '%s/%s %s\\n' \"${GOOS-native}\" \"${GOARCH-native}\" \"$*\" >> \"$CONFIGKIT_TEST_GO_LOG\"\nif [ \"${CONFIGKIT_TEST_FAIL-}\" = yes ] && [ \"${1-}\" = test ]; then exit 37; fi\n"), 0700) write(filepath.Join(bin, "gofmt"), []byte("#!/bin/sh\nprintf '%s\\000' \"$@\" > \"$CONFIGKIT_TEST_FMT_LOG\"\n"), 0700) init := exec.Command("git", "init", "-q") init.Dir = root if out, err := init.CombinedOutput(); err != nil { t.Fatalf("git init: %v %s", err, out) } // HEAD is required by the whitespace gate. Commit only the module baseline. for _, args := range [][]string{{"add", "go.mod", "go.sum", "obsolete.go"}, {"-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "-qm", "Test fixture"}} { cmd := exec.Command("git", args...) cmd.Dir = root if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git fixture: %v %s", err, out) } } if err := os.Remove(filepath.Join(root, "obsolete.go")); err != nil { t.Fatal(err) } cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName)) cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls) // Log only targets selected by the script, not inherited host settings. cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=") if name == "failed-go-check" { cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=yes") } out, err := cmd.CombinedOutput() if fail { if err == nil { t.Fatal("checker ignored failed Go test") } } else if err != nil { t.Fatalf("checker: %v %s", err, out) } arguments, err := os.ReadFile(log) if err != nil { t.Fatal(err) } parts := bytes.Split(arguments, []byte{0}) for _, file := range files { found := false for _, arg := range parts { if string(arg) == file { found = true } } if !found { t.Fatalf("filename was split: %q", file) } } invoked, err := os.ReadFile(calls) if err != nil { t.Fatal(err) } if fail && strings.Contains(string(invoked), "vet ./...") { t.Fatal("checker continued after failed test") } if scriptName == "release-check.sh" && !fail { if strings.Count(string(invoked), " test ") != 1 || !strings.Contains(string(invoked), " test -race -v ./...\n") { t.Fatalf("want one complete race-test run, got:\n%s", invoked) } if strings.Count(string(invoked), " vet ./...") != 1 || strings.Count(string(invoked), " build ./...") != 2 { t.Fatalf("want one vet run and only two cross-builds, got:\n%s", invoked) } // These are selected release gates, not incidental loop details. for _, target := range []string{"linux/amd64", "linux/arm64"} { if !strings.Contains(string(invoked), target+" build ./...") { t.Errorf("checker omitted required target %s", target) } } if strings.Contains(string(invoked), "darwin/") { t.Fatal("checker requires best-effort macOS validation") } } }) } }) } }