package cli import ( "bytes" "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" ) func TestParse(t *testing.T) { invocation, err := Parse([]string{"version"}) if err != nil { t.Fatal(err) } if invocation.Command != VersionCommand { t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand) } check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"}) if err != nil { t.Fatal(err) } if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" { t.Fatalf("Parse(check) = %#v", check) } publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "v1.2.4"}) if err != nil { t.Fatal(err) } if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" { t.Fatalf("Parse(publish) = %#v", publish) } publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"}) if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" { t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err) } build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"}) if err != nil { t.Fatal(err) } if build.Command != BuildAction || build.Repository != "candidate" || build.ConfigPath != "releasekit.json" || build.OutputDir != "output with spaces" || build.Version != "v1.2.5" { t.Fatalf("Parse(build) = %#v", build) } deliver, err := Parse([]string{"deliver", "--repo", "candidate", "--config", "releasekit.json", "--assets", "assets with spaces", "v1.2.6"}) if err != nil { t.Fatal(err) } if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" { t.Fatalf("Parse(deliver) = %#v", deliver) } ciDelivery, err := Parse([]string{"ci-deliver", "--repo", "candidate", "--config", "profiles/release config.json", "--remote", "upstream", "--prepared", "--expected-module", "integrations/ckconfig", "--commit", "0123456789abcdef0123456789abcdef01234567", "integrations/ckconfig/v1.2.7"}) if err != nil { t.Fatal(err) } if ciDelivery.Command != CIDeliveryAction || ciDelivery.Repository != "candidate" || ciDelivery.ConfigPath != "profiles/release config.json" || ciDelivery.Remote != "upstream" || ciDelivery.Commit != "0123456789abcdef0123456789abcdef01234567" || ciDelivery.Version != "v1.2.7" || ciDelivery.ExpectedTag != "integrations/ckconfig/v1.2.7" || ciDelivery.ExpectedModule != "integrations/ckconfig" || !ciDelivery.Prepared { t.Fatalf("Parse(ci-deliver) = %#v", ciDelivery) } ciDefault, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.8"}) if err != nil || ciDefault.Remote != "origin" { t.Fatalf("Parse(ci-deliver defaults) = %#v, error = %v", ciDefault, err) } if _, err := Parse([]string{"ci-deliver", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "--commit") { t.Fatalf("Parse(ci-deliver without commit) error = %v, want required commit", err) } if _, err := Parse([]string{"ci-deliver", "--commit", "abc123", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "full Git object ID") { t.Fatalf("Parse(ci-deliver with abbreviated commit) error = %v, want full object ID error", err) } if _, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "integrations/ckconfig/latest"}); err == nil || !strings.Contains(err.Error(), "stable vMAJOR.MINOR.PATCH") { t.Fatalf("Parse(ci-deliver unstable terminal tag) error = %v", err) } if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil { t.Fatal("Parse accepted a flag after the version") } if _, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.3", "--repo", "repository"}); err == nil { t.Fatal("Parse accepted a ci-deliver flag after the version") } if _, err := Parse(nil); err == nil || !strings.Contains(err.Error(), "command is required") { t.Fatalf("Parse(nil) error = %v, want missing-command error", err) } if _, err := Parse([]string{"unknown"}); err == nil || !strings.Contains(err.Error(), "unknown command") { t.Fatalf("Parse(unknown) error = %v, want unknown-command error", err) } } func TestLoadSettingsResolvesPathsAndPreservesArguments(t *testing.T) { repoDir := filepath.Join(t.TempDir(), "repository with spaces") profileDir := filepath.Join(repoDir, "profiles with spaces") if err := os.MkdirAll(profileDir, 0o700); err != nil { t.Fatal(err) } want := Settings{ Product: "Example Product", Remote: "upstream", Checks: []CheckCommand{{ Args: []string{"sh", "scripts/check release.sh", "two words"}, Env: map[string]string{"RELEASE_MODE": "candidate check"}, }}, } contents, err := json.Marshal(want) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(profileDir, "release profile.json"), contents, 0o600); err != nil { t.Fatal(err) } got, err := LoadSettings(repoDir, filepath.Join("profiles with spaces", "release profile.json")) if err != nil { t.Fatal(err) } if got.Product != want.Product || got.Remote != want.Remote || len(got.Checks) != 1 { t.Fatalf("LoadSettings() = %#v, want product, remote, and one check", got) } if got.Checks[0].Args[1] != "scripts/check release.sh" || got.Checks[0].Args[2] != "two words" { t.Fatalf("check arguments were changed: %#v", got.Checks[0].Args) } if got.Checks[0].Env["RELEASE_MODE"] != "candidate check" { t.Fatalf("check environment = %#v", got.Checks[0].Env) } } func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) { repoDir := t.TempDir() contents := `{"product":"Example"}` profilePath := filepath.Join(repoDir, "releasekit.json") if err := os.WriteFile(profilePath, []byte(contents), 0o600); err != nil { t.Fatal(err) } for _, test := range []struct { name string path string }{ {name: "default path"}, {name: "absolute path", path: profilePath}, } { t.Run(test.name, func(t *testing.T) { settings, err := LoadSettings(repoDir, test.path) if err != nil { t.Fatal(err) } if settings.Remote != "origin" { t.Fatalf("default remote = %q, want origin", settings.Remote) } }) } } func TestLoadSettingsReadsGoValidation(t *testing.T) { repoDir := t.TempDir() profilePath := filepath.Join(repoDir, "releasekit.json") if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go_format":true,"markdown":{"enforce_checkout_boundary":true},"module_distribution":{"workspace_files":true,"local_replacements":true},"file_size":{"limit_bytes":0,"exclusions":["assets"]},"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil { t.Fatal(err) } settings, err := LoadSettings(repoDir, "") if err != nil { t.Fatal(err) } if settings.Validation == nil || !settings.Validation.GoFormat || settings.Validation.Markdown == nil || !settings.Validation.Markdown.EnforceCheckoutBoundary || !settings.Validation.ModuleDistribution.WorkspaceFiles || !settings.Validation.ModuleDistribution.LocalReplacements || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet { t.Fatalf("validation settings = %#v, want Go formatting, workspace inspection, race, and full vet enabled", settings.Validation) } if settings.Validation.FileSize == nil || settings.Validation.FileSize.LimitBytes == nil || *settings.Validation.FileSize.LimitBytes != 0 || len(settings.Validation.FileSize.Exclusions) != 1 || settings.Validation.FileSize.Exclusions[0] != "assets" { t.Fatalf("file size settings = %#v, want explicit zero limit and exclusions", settings.Validation.FileSize) } if got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want { t.Fatalf("compile targets = %q, want %q", got, want) } if settings.ExpectedPushURL != "ssh://git.example/owner/repo.git" { t.Fatalf("expected push URL = %q", settings.ExpectedPushURL) } } func TestLoadSettingsPreservesSharedFilesSelection(t *testing.T) { repoDir := t.TempDir() profileDir := filepath.Join(repoDir, "profiles") if err := os.MkdirAll(profileDir, 0o700); err != nil { t.Fatal(err) } for _, test := range []struct { name string profile string wantNil bool wantFile string }{ {name: "omitted", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{}}`, wantNil: true}, {name: "null", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":null}}`, wantNil: true}, {name: "empty", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":[]}}`}, {name: "selected", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":["../README.md"]}}`, wantFile: "../README.md"}, } { t.Run(test.name, func(t *testing.T) { path := filepath.Join(profileDir, "release.json") if err := os.WriteFile(path, []byte(test.profile), 0o600); err != nil { t.Fatal(err) } settings, err := LoadSettings(repoDir, filepath.Join("profiles", "release.json")) if err != nil { t.Fatal(err) } if settings.ModuleDir != "integrations/ckconfig" { t.Fatalf("module_dir = %q", settings.ModuleDir) } if settings.Validation == nil || (settings.Validation.SharedFiles == nil) != test.wantNil { t.Fatalf("shared_files = %#v, want nil=%t", settings.Validation, test.wantNil) } if test.wantFile != "" && (len(settings.Validation.SharedFiles) != 1 || settings.Validation.SharedFiles[0] != test.wantFile) { t.Fatalf("shared_files = %#v, want %q", settings.Validation.SharedFiles, test.wantFile) } encoded, err := json.Marshal(settings) if err != nil { t.Fatal(err) } var roundTrip Settings if err := json.Unmarshal(encoded, &roundTrip); err != nil { t.Fatal(err) } if (roundTrip.Validation.SharedFiles == nil) != test.wantNil { t.Fatalf("serialized shared_files = %#v, want nil=%t; JSON=%s", roundTrip.Validation.SharedFiles, test.wantNil, encoded) } }) } } func TestLoadSettingsPreservesMarkdownObjectSelection(t *testing.T) { repoDir := t.TempDir() profilePath := filepath.Join(repoDir, "releasekit.json") for _, test := range []struct { name string profile string want bool }{ {name: "omitted", profile: `{"product":"Example","validation":{}}`}, {name: "null", profile: `{"product":"Example","validation":{"markdown":null}}`}, {name: "empty object", profile: `{"product":"Example","validation":{"markdown":{}}}`, want: true}, {name: "boundary enabled", profile: `{"product":"Example","validation":{"markdown":{"enforce_checkout_boundary":true}}}`, want: true}, } { t.Run(test.name, func(t *testing.T) { if err := os.WriteFile(profilePath, []byte(test.profile), 0o600); err != nil { t.Fatal(err) } settings, err := LoadSettings(repoDir, "") if err != nil { t.Fatal(err) } got := settings.Validation != nil && settings.Validation.Markdown != nil if got != test.want { t.Fatalf("Markdown selected = %t, want %t", got, test.want) } }) } } func TestLoadSettingsPreservesEmptyAndNullFileSize(t *testing.T) { for _, test := range []struct { name string profile string enabled bool }{ {name: "empty object", profile: `{"product":"Example","validation":{"file_size":{}}}`, enabled: true}, {name: "default limit", profile: `{"product":"Example","validation":{"file_size":{"limit_bytes":null}}}`, enabled: true}, {name: "null object", profile: `{"product":"Example","validation":{"file_size":null}}`}, {name: "omitted object", profile: `{"product":"Example","validation":{}}`}, } { t.Run(test.name, func(t *testing.T) { repoDir := t.TempDir() profilePath := filepath.Join(repoDir, "releasekit.json") if err := os.WriteFile(profilePath, []byte(test.profile), 0o600); err != nil { t.Fatal(err) } settings, err := LoadSettings(repoDir, profilePath) if err != nil { t.Fatal(err) } got := settings.Validation != nil && settings.Validation.FileSize != nil if got != test.enabled { t.Fatalf("FileSize enabled = %t, want %t", got, test.enabled) } }) } } func TestLoadSettingsRejectsNegativeFileSizeLimit(t *testing.T) { repoDir := t.TempDir() profilePath := filepath.Join(repoDir, "releasekit.json") profile := `{"product":"Example","validation":{"file_size":{"limit_bytes":-1}}}` if err := os.WriteFile(profilePath, []byte(profile), 0o600); err != nil { t.Fatal(err) } if _, err := LoadSettings(repoDir, profilePath); err == nil || !strings.Contains(err.Error(), "validation.file_size.limit_bytes") { t.Fatalf("LoadSettings() error = %v, want invalid negative file-size limit", err) } } func TestExampleProfileLoads(t *testing.T) { repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) if err != nil { t.Fatal(err) } settings, err := LoadSettings(repoDir, filepath.Join("examples", "releasekit.json")) if err != nil { t.Fatal(err) } if settings.Product != "Example" || settings.Remote != "origin" || len(settings.Checks) != 0 { t.Fatalf("example settings = %#v", settings) } } func TestBuildProfileLoads(t *testing.T) { repoDir := t.TempDir() path := filepath.Join(repoDir, "profile.json") if err := os.WriteFile(path, []byte(`{"product":"Example","build":{"main_package":"./cmd/app","binary_name":"example","targets":["linux/amd64","darwin/arm64"]}}`), 0o600); err != nil { t.Fatal(err) } settings, err := LoadSettings(repoDir, path) if err != nil { t.Fatal(err) } if settings.Build == nil || settings.Build.MainPackage != "./cmd/app" || settings.Build.BinaryName != "example" || len(settings.Build.Targets) != 2 { t.Fatalf("build settings = %#v", settings.Build) } repoDir, err = filepath.Abs(filepath.Join("..", "..", "..", "..")) if err != nil { t.Fatal(err) } example, err := LoadSettings(repoDir, filepath.Join("examples", "releasekit-binary.json")) if err != nil { t.Fatal(err) } if example.Build == nil || len(example.Build.Targets) != 3 { t.Fatalf("binary example settings = %#v", example.Build) } } func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) { tests := []struct { name string content string want string }{ {name: "missing product", content: `{}`, want: "product is required"}, {name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"}, {name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"}, {name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"}, {name: "compile target format", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"}, {name: "compile target missing architecture", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"}, {name: "compile target missing operating system", content: `{"product":"Example","validation":{"go":{"compile_targets":["/amd64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"}, {name: "compile target repeated separator", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/amd64/arm64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"}, {name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"}, {name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"}, {name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"}, {name: "multiple JSON values", content: `{"product":"Example"}{"product":"Other"}`, want: "multiple JSON values"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repoDir := t.TempDir() path := filepath.Join(repoDir, "profile.json") if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil { t.Fatal(err) } if _, err := LoadSettings(repoDir, path); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("LoadSettings() error = %v, want text %q", err, test.want) } }) } } func TestRunChecksUsesConfiguredArgumentsEnvironmentAndDirectory(t *testing.T) { workdir := filepath.Join(t.TempDir(), "repo with spaces") if err := os.Mkdir(workdir, 0o700); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer err := RunChecks(context.Background(), workdir, []CheckCommand{{ Args: []string{helperExecutable(t), "-test.run=TestConfiguredCommandHelper", "--", "argument with spaces"}, Env: map[string]string{ "RELEASEKIT_CONFIGURED_HELPER": "1", "RELEASEKIT_CONFIGURED_VALUE": "selected value", "RELEASEKIT_VERSION": "profile value", }, }}, map[string]string{"RELEASEKIT_VERSION": "captured version", "RELEASEKIT_CANDIDATE": "captured commit"}, &stdout, &stderr) if err != nil { t.Fatal(err) } want := fmt.Sprintf("%s|argument with spaces|selected value|captured version|captured commit", workdir) if stdout.String() != want { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } } func TestConfiguredCommandHelper(t *testing.T) { if os.Getenv("RELEASEKIT_CONFIGURED_HELPER") != "1" { return } workdir, err := os.Getwd() if err != nil { os.Exit(9) } fmt.Fprintf(os.Stdout, "%s|%s|%s|%s|%s", workdir, os.Args[len(os.Args)-1], os.Getenv("RELEASEKIT_CONFIGURED_VALUE"), os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE")) os.Exit(0) } func helperExecutable(t *testing.T) string { t.Helper() executable, err := os.Executable() if err != nil { t.Fatal(err) } return executable }