package releasecheck import ( "bytes" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "testing" ) func TestReleaseVersionValidationIsExact(t *testing.T) { repoRoot := releaseCheckRepoRoot(t) library := filepath.Join(repoRoot, "scripts", "release-lib.sh") workingDirectory := t.TempDir() if err := os.WriteFile(filepath.Join(workingDirectory, "2"), nil, 0o644); err != nil { t.Fatal(err) } for _, test := range []struct { version string valid bool }{ {version: "v0.0.0", valid: true}, {version: "v1.2.3", valid: true}, {version: "v10.200.3000", valid: true}, {version: "1.2.3"}, {version: "v01.2.3"}, {version: "v1.02.3"}, {version: "v1.2.03"}, {version: "v1.2.3."}, {version: "v1.2.3-rc.1"}, {version: "v1.2.3+build"}, {version: "v1.*.3"}, {version: "v1.2.3\nv4.5.6"}, } { t.Run(test.version, func(t *testing.T) { command := exec.Command("sh", "-c", `. "$1"; narratio_release_validate_version "$2"`, "release-version-test", library, test.version) command.Dir = workingDirectory err := command.Run() if test.valid && err != nil { t.Fatalf("valid version %q rejected: %v", test.version, err) } if !test.valid && err == nil { t.Fatalf("invalid version %q accepted", test.version) } }) } } func TestAssetBuilderRejectsUnsafeDestinations(t *testing.T) { repoRoot := releaseCheckRepoRoot(t) builder := filepath.Join(repoRoot, "scripts", "build-release-assets.sh") nonempty := t.TempDir() if err := os.WriteFile(filepath.Join(nonempty, "existing"), []byte("x"), 0o644); err != nil { t.Fatal(err) } notDirectory := filepath.Join(t.TempDir(), "not-a-directory") if err := os.WriteFile(notDirectory, []byte("x"), 0o644); err != nil { t.Fatal(err) } linkTarget := t.TempDir() symlink := filepath.Join(t.TempDir(), "linked-output") if err := os.Symlink(linkTarget, symlink); err != nil { t.Fatal(err) } for _, args := range [][]string{ nil, {"v1.2", t.TempDir()}, {"1.2.3", t.TempDir()}, {"v01.2.3", t.TempDir()}, {"v1.02.3", t.TempDir()}, {"v1.2.03", t.TempDir()}, {"v1.2.3-rc.1", t.TempDir()}, {"v1.2.3+build", t.TempDir()}, {"v1.2.3", "relative-output"}, {"v1.2.3", "/"}, {"v1.2.3", repoRoot}, {"v1.2.3", nonempty}, {"v1.2.3", notDirectory}, {"v1.2.3", symlink}, } { command := exec.Command("sh", append([]string{builder}, args...)...) command.Dir = t.TempDir() output, err := command.CombinedOutput() if err == nil { t.Fatalf("asset builder %q succeeded", args) } if !strings.Contains(string(output), "build-release-assets:") { t.Fatalf("asset builder %q output = %q", args, output) } } } func TestAssetBuilderBuildsNamedAssetsAndChecksEmbeddedVersion(t *testing.T) { repoRoot := releaseCheckRepoRoot(t) builder := filepath.Join(repoRoot, "scripts", "build-release-assets.sh") binDir := t.TempDir() writeFakeGo(t, filepath.Join(binDir, "go")) outputDir := filepath.Join(t.TempDir(), "assets") goLog := filepath.Join(t.TempDir(), "go.log") command := exec.Command("sh", builder, "v1.2.3", outputDir) command.Dir = t.TempDir() command.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), "GOWORK=/caller/controlled/go.work", "NARRATIO_RELEASE_ASSET_GO_LOG="+goLog, ) output, err := command.CombinedOutput() if err != nil { t.Fatalf("asset builder error = %v\n%s", err, output) } entries, err := os.ReadDir(outputDir) if err != nil { t.Fatal(err) } got := make([]string, 0, len(entries)) for _, entry := range entries { got = append(got, entry.Name()) } sort.Strings(got) want := []string{ "narratio-v1.2.3-darwin-amd64", "narratio-v1.2.3-darwin-arm64", "narratio-v1.2.3-linux-amd64", "narratio-v1.2.3-linux-arm64", "narratio-v1.2.3-windows-amd64.exe", "narratio-v1.2.3-windows-arm64.exe", } if !slicesEqual(got, want) { t.Fatalf("asset names = %#v, want %#v", got, want) } version := exec.Command(filepath.Join(outputDir, "narratio-v1.2.3-linux-amd64"), "version") var versionOutput bytes.Buffer version.Stdout = &versionOutput if err := version.Run(); err != nil { t.Fatal(err) } if got := versionOutput.String(); got != "narratio v1.2.3\n" { t.Fatalf("embedded version output = %q", got) } goCommands, err := os.ReadFile(goLog) if err != nil { t.Fatal(err) } for _, line := range strings.Split(strings.TrimSpace(string(goCommands)), "\n") { if !strings.HasPrefix(line, "off ") { t.Fatalf("asset builder Go command did not receive GOWORK=off: %q", line) } } } func TestCandidateCheckerRejectsMalformedVersionAndReleaseNotes(t *testing.T) { fixture := newCandidateFixture(t) if output, err := fixture.run("v1.2"); err == nil || !strings.Contains(output, "invalid stable version") { t.Fatalf("malformed version error = %v\n%s", err, output) } for _, note := range []string{ "# Narratio v1.2.3\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n", "# Narratio v1.2.4\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n\n## Changes\n", } { fixture := newCandidateFixture(t) if err := os.WriteFile(fixture.notePath, []byte(note), 0o644); err != nil { t.Fatal(err) } if output, err := fixture.run("v1.2.3"); err == nil || !strings.Contains(output, "release note") { t.Fatalf("invalid note error = %v\n%s", err, output) } } } func TestCandidateCheckerRejectsRepositoryHygieneViolations(t *testing.T) { for _, test := range []struct { name string prepare func(*testing.T, *candidateFixture) want string }{ { name: "wrong module", want: "go.mod must declare module", prepare: func(t *testing.T, fixture *candidateFixture) { if err := os.WriteFile(filepath.Join(fixture.root, "go.mod"), []byte("module example.invalid/release\n\ngo 1.25.0\n"), 0o644); err != nil { t.Fatal(err) } }, }, { name: "tracked workspace", want: "tracked workspace file is not allowed", prepare: func(t *testing.T, fixture *candidateFixture) { path := filepath.Join(fixture.root, "go.work") if err := os.WriteFile(path, []byte("go 1.25.0\n"), 0o644); err != nil { t.Fatal(err) } fixture.git("add", "go.work") }, }, { name: "vendor directory", want: "vendor directory is not allowed", prepare: func(t *testing.T, fixture *candidateFixture) { if err := os.Mkdir(filepath.Join(fixture.root, "vendor"), 0o755); err != nil { t.Fatal(err) } }, }, { name: "replace directive", want: "replace directives are not allowed", prepare: func(t *testing.T, fixture *candidateFixture) { data, err := os.ReadFile(filepath.Join(fixture.root, "go.mod")) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(fixture.root, "go.mod"), append(data, []byte("replace example.invalid/module => ./module\n")...), 0o644); err != nil { t.Fatal(err) } }, }, } { t.Run(test.name, func(t *testing.T) { fixture := newCandidateFixture(t) test.prepare(t, fixture) if output, err := fixture.run("v1.2.3"); err == nil || !strings.Contains(output, test.want) { t.Fatalf("hygiene error = %v\n%s", err, output) } }) } } func TestCandidateCheckerPropagatesOwnedValidationFailure(t *testing.T) { fixture := newCandidateFixture(t) fixture.extraEnv = append(fixture.extraEnv, "NARRATIO_RELEASE_FAIL_GO=test -race -count=1 ./...") if output, err := fixture.run("v1.2.3"); err == nil || !strings.Contains(output, "failed: go test -race -count=1 ./...") { t.Fatalf("validation failure = %v\n%s", err, output) } } func TestCandidateCheckerUsesGoWorkOffForOwnedCommands(t *testing.T) { fixture := newCandidateFixture(t) if output, err := fixture.run("v1.2.3"); err != nil { t.Fatalf("candidate checker error = %v\n%s", err, output) } data, err := os.ReadFile(fixture.logPath) if err != nil { t.Fatal(err) } lines := strings.FieldsFunc(strings.TrimSpace(string(data)), func(r rune) bool { return r == '\n' }) if len(lines) < 9 { t.Fatalf("owned command log = %q", data) } for _, line := range lines { if !strings.HasPrefix(line, "off ") { t.Fatalf("owned command did not receive GOWORK=off: %q", line) } } for _, want := range []string{ "off mod tidy -diff", "off test -count=1 ./...", "off test -race -count=1 ./...", "off vet ./...", "off build ./...", "off test -count=1 ./internal/doccheck", "off test -count=1 ./internal/config -run ^TestExamplesLoadAndValidate$", "off gofmt -l", "off build-release-assets v1.2.3", } { if !containsLogLine(lines, want) { t.Fatalf("owned command log = %q, missing %q", data, want) } } } func releaseCheckRepoRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("runtime.Caller() failed") } return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) } func writeFakeGo(t *testing.T, path string) { t.Helper() const fake = `#!/bin/sh set -eu if [ -n "${NARRATIO_RELEASE_ASSET_GO_LOG:-}" ]; then printf '%s %s\n' "${GOWORK:-}" "$*" >> "$NARRATIO_RELEASE_ASSET_GO_LOG" fi if [ "$1" = env ]; then case $2 in GOOS) printf '%s\n' linux ;; GOARCH) printf '%s\n' amd64 ;; *) exit 1 ;; esac exit 0 fi output= version= while [ "$#" -gt 0 ]; do case $1 in -o) shift output=$1 ;; -ldflags) shift case $1 in *gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=*) version=${1##*=} ;; esac ;; esac shift done [ -n "$output" ] [ -n "$version" ] printf '#!/bin/sh\nprintf "narratio %%s\\n" "%s"\n' "$version" > "$output" chmod +x "$output" ` if err := os.WriteFile(path, []byte(fake), 0o755); err != nil { t.Fatal(err) } } func slicesEqual(left, right []string) bool { if len(left) != len(right) { return false } for index := range left { if left[index] != right[index] { return false } } return true } type candidateFixture struct { root string notePath string binDir string logPath string extraEnv []string } func newCandidateFixture(t *testing.T) *candidateFixture { t.Helper() root := t.TempDir() for _, dir := range []string{"scripts", "docs/releases"} { if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { t.Fatal(err) } } repoRoot := releaseCheckRepoRoot(t) copyFixtureFile(t, filepath.Join(repoRoot, "scripts", "release-lib.sh"), filepath.Join(root, "scripts", "release-lib.sh"), 0o755) copyFixtureFile(t, filepath.Join(repoRoot, "scripts", "check-release-candidate.sh"), filepath.Join(root, "scripts", "check-release-candidate.sh"), 0o755) writeFixtureFile(t, filepath.Join(root, "scripts", "build-release-assets.sh"), `#!/bin/sh set -eu printf '%s build-release-assets %s\n' "${GOWORK:-}" "$1" >> "$NARRATIO_RELEASE_CHECK_LOG" `, 0o755) writeFixtureFile(t, filepath.Join(root, "go.mod"), "module gitea.maximumdirect.net/eric/narratio\n\ngo 1.25.0\n", 0o644) writeFixtureFile(t, filepath.Join(root, "main.go"), "package narratio\n", 0o644) notePath := filepath.Join(root, "docs", "releases", "v1.2.3.md") writeFixtureFile(t, notePath, "# Narratio v1.2.3\n\n## Summary\n\n## Compatibility\n\n## Upgrade\n\n## Changes\n", 0o644) fixture := &candidateFixture{root: root, notePath: notePath, binDir: t.TempDir(), logPath: filepath.Join(t.TempDir(), "commands.log")} writeFixtureFile(t, filepath.Join(fixture.binDir, "go"), fixtureGoShim, 0o755) writeFixtureFile(t, filepath.Join(fixture.binDir, "gofmt"), fixtureGofmtShim, 0o755) fixture.git("init", "-q") fixture.git("config", "user.email", "releasecheck@example.test") fixture.git("config", "user.name", "Release Check") fixture.git("add", ".") fixture.git("commit", "-qm", "fixture") return fixture } func (fixture *candidateFixture) run(version string) (string, error) { command := exec.Command("sh", filepath.Join(fixture.root, "scripts", "check-release-candidate.sh"), version) command.Dir = fixture.binDir command.Env = append(os.Environ(), "PATH="+fixture.binDir+string(os.PathListSeparator)+os.Getenv("PATH"), "NARRATIO_RELEASE_CHECK_LOG="+fixture.logPath, ) command.Env = append(command.Env, fixture.extraEnv...) output, err := command.CombinedOutput() return string(output), err } func (fixture *candidateFixture) git(args ...string) { command := exec.Command("git", args...) command.Dir = fixture.root output, err := command.CombinedOutput() if err != nil { panic("fixture git command failed: " + string(output)) } } func copyFixtureFile(t *testing.T, source, destination string, mode os.FileMode) { t.Helper() data, err := os.ReadFile(source) if err != nil { t.Fatal(err) } writeFixtureFile(t, destination, string(data), mode) } func writeFixtureFile(t *testing.T, path, contents string, mode os.FileMode) { t.Helper() if err := os.WriteFile(path, []byte(contents), mode); err != nil { t.Fatal(err) } } func containsLogLine(lines []string, want string) bool { for _, line := range lines { if strings.HasPrefix(line, want) { return true } } return false } const fixtureGoShim = `#!/bin/sh set -eu printf '%s %s\n' "${GOWORK:-}" "$*" >> "$NARRATIO_RELEASE_CHECK_LOG" if [ "${NARRATIO_RELEASE_FAIL_GO:-}" = "$*" ]; then exit 1 fi ` const fixtureGofmtShim = `#!/bin/sh set -eu printf '%s gofmt %s\n' "${GOWORK:-}" "$*" >> "$NARRATIO_RELEASE_CHECK_LOG" `