312 lines
11 KiB
Go
312 lines
11 KiB
Go
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 TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
|
|
for _, scriptName := range []string{"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")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckScriptsReleaseGates(t *testing.T) {
|
|
script, err := os.ReadFile("scripts/release-check.sh")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, scenario := range []string{"success", "failed-test", "missing-toolchain"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
bin := t.TempDir()
|
|
for _, directory := range []string{"scripts", "nested"} {
|
|
if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
checker := filepath.Join(root, "scripts", "release-check.sh")
|
|
if err := os.WriteFile(checker, script, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls := filepath.Join(bin, "go.calls")
|
|
goStub := `#!/bin/sh
|
|
printf '%s|%s|%s|%s|%s|%s\n' "$GOTOOLCHAIN" "$GOPROXY" "${CGO_ENABLED-}" "${GOOS-}" "${GOARCH-}" "$*" >> "$CONFIGKIT_TEST_GO_LOG"
|
|
if [ "${CONFIGKIT_TEST_FAIL-}" = toolchain ] && [ "${1-}" = version ]; then exit 37; fi
|
|
if [ "${CONFIGKIT_TEST_FAIL-}" = test ] && [ "${1-}" = test ]; then exit 37; fi
|
|
`
|
|
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(goStub), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd := exec.Command("/bin/bash", checker)
|
|
cmd.Dir = filepath.Join(root, "nested")
|
|
cmd.Env = append(os.Environ(),
|
|
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
|
"CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline",
|
|
"CONFIGKIT_TEST_GO_LOG="+calls,
|
|
"GOOS=", "GOARCH=", "CGO_ENABLED=",
|
|
)
|
|
switch scenario {
|
|
case "failed-test":
|
|
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=test")
|
|
case "missing-toolchain":
|
|
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=toolchain")
|
|
}
|
|
output, err := cmd.CombinedOutput()
|
|
if scenario == "success" && err != nil {
|
|
t.Fatalf("checker: %v\n%s", err, output)
|
|
}
|
|
if scenario != "success" && err == nil {
|
|
t.Fatalf("checker accepted %s", scenario)
|
|
}
|
|
data, err := os.ReadFile(calls)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
for _, line := range lines {
|
|
if !strings.HasPrefix(line, "test-baseline|off|") {
|
|
t.Fatalf("toolchain or offline setting lost: %q", line)
|
|
}
|
|
}
|
|
if scenario == "missing-toolchain" {
|
|
if len(lines) != 1 || !strings.HasSuffix(lines[0], "|version") {
|
|
t.Fatalf("checker ran after unavailable toolchain: %q", lines)
|
|
}
|
|
return
|
|
}
|
|
if len(lines) < 2 || lines[1] != "test-baseline|off||||test -mod=readonly -vet=off -race -v ./..." {
|
|
t.Fatalf("release suite command changed: %q", lines)
|
|
}
|
|
if scenario == "failed-test" {
|
|
if len(lines) != 2 {
|
|
t.Fatalf("checker continued after failure: %q", lines)
|
|
}
|
|
return
|
|
}
|
|
want := []string{
|
|
"test-baseline|off||||version",
|
|
"test-baseline|off||||test -mod=readonly -vet=off -race -v ./...",
|
|
"test-baseline|off||||vet -mod=readonly ./...",
|
|
"test-baseline|off|0|linux|amd64|build -mod=readonly ./...",
|
|
"test-baseline|off|0|linux|arm64|build -mod=readonly ./...",
|
|
}
|
|
if strings.Join(lines, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("unexpected release gates:\n%s", data)
|
|
}
|
|
})
|
|
}
|
|
}
|