diff --git a/action.yml b/action.yml index 0317fcf..4059e5e 100644 --- a/action.yml +++ b/action.yml @@ -60,15 +60,25 @@ runs: env: GITHUB_TOKEN: ${{ inputs.token }} VERSION_FILES_YAML: ${{ inputs.version_files }} + INPUT_BUMP_TYPE: ${{ inputs.bump_type }} + INPUT_VERSION_FILE: ${{ inputs.version_file }} + INPUT_BASE_BRANCH: ${{ inputs.base_branch }} + INPUT_HELM_DOCS_ARGS: ${{ inputs.helm_docs_args }} run: | + # Validate bump_type to prevent injection + case "$INPUT_BUMP_TYPE" in + major|minor|patch) ;; + *) echo "Error: Invalid bump_type. Must be major, minor, or patch." >&2; exit 1 ;; + esac + ARGS=( - --bump-type="${{ inputs.bump_type }}" - --version-file="${{ inputs.version_file }}" - --base-branch="${{ inputs.base_branch }}" + --bump-type="$INPUT_BUMP_TYPE" + --version-file="$INPUT_VERSION_FILE" + --base-branch="$INPUT_BASE_BRANCH" ) - if [ -n "${{ inputs.helm_docs_args }}" ]; then - ARGS+=(--helm-docs-args="${{ inputs.helm_docs_args }}") + if [ -n "$INPUT_HELM_DOCS_ARGS" ]; then + ARGS+=(--helm-docs-args="$INPUT_HELM_DOCS_ARGS") fi if [ -n "$VERSION_FILES_YAML" ]; then diff --git a/internal/files/path.go b/internal/files/path.go new file mode 100644 index 0000000..80e1553 --- /dev/null +++ b/internal/files/path.go @@ -0,0 +1,103 @@ +// Copyright 2025 Stacklok, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package files + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ValidatePath ensures a file path is safe and within the allowed base directory. +// It prevents path traversal attacks by checking that the resolved path stays within bounds. +// If basePath is empty, the current working directory is used. +func ValidatePath(basePath, userPath string) (string, error) { + if userPath == "" { + return "", fmt.Errorf("path cannot be empty") + } + + // Get absolute base path + if basePath == "" { + var err error + basePath, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("getting working directory: %w", err) + } + } + + absBase, err := filepath.Abs(basePath) + if err != nil { + return "", fmt.Errorf("resolving base path: %w", err) + } + + // Clean and resolve the user path + cleanPath := filepath.Clean(userPath) + + // Check for obvious path traversal attempts + if strings.HasPrefix(cleanPath, "..") || strings.Contains(cleanPath, "/../") { + return "", fmt.Errorf("path traversal detected in %q", userPath) + } + + // Resolve the full path + var fullPath string + if filepath.IsAbs(cleanPath) { + fullPath = cleanPath + } else { + fullPath = filepath.Join(absBase, cleanPath) + } + + // Get absolute path to handle any remaining relative components + absPath, err := filepath.Abs(fullPath) + if err != nil { + return "", fmt.Errorf("resolving path: %w", err) + } + + // Ensure the resolved path is within the base directory + if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) && absPath != absBase { + return "", fmt.Errorf("path %q resolves outside allowed directory", userPath) + } + + return absPath, nil +} + +// ValidatePathRelative validates a path and returns it relative to the base directory. +// This is useful when the relative path is needed for display or storage. +func ValidatePathRelative(basePath, userPath string) (string, error) { + absPath, err := ValidatePath(basePath, userPath) + if err != nil { + return "", err + } + + // Get absolute base path for relative calculation + if basePath == "" { + basePath, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("getting working directory: %w", err) + } + } + + absBase, err := filepath.Abs(basePath) + if err != nil { + return "", fmt.Errorf("resolving base path: %w", err) + } + + relPath, err := filepath.Rel(absBase, absPath) + if err != nil { + return "", fmt.Errorf("calculating relative path: %w", err) + } + + return relPath, nil +} diff --git a/internal/files/path_test.go b/internal/files/path_test.go new file mode 100644 index 0000000..bf04b77 --- /dev/null +++ b/internal/files/path_test.go @@ -0,0 +1,203 @@ +// Copyright 2025 Stacklok, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package files + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidatePath(t *testing.T) { + t.Parallel() + + // Create a temp directory for testing + tempDir := t.TempDir() + + tests := []struct { + name string + basePath string + userPath string + wantErr bool + errMsg string + }{ + { + name: "valid relative path", + basePath: tempDir, + userPath: "subdir/file.txt", + wantErr: false, + }, + { + name: "valid simple filename", + basePath: tempDir, + userPath: "file.txt", + wantErr: false, + }, + { + name: "empty path", + basePath: tempDir, + userPath: "", + wantErr: true, + errMsg: "path cannot be empty", + }, + { + name: "path traversal with ..", + basePath: tempDir, + userPath: "../etc/passwd", + wantErr: true, + errMsg: "path traversal detected", + }, + { + name: "path traversal with multiple ..", + basePath: tempDir, + userPath: "../../etc/passwd", + wantErr: true, + errMsg: "path traversal detected", + }, + { + name: "path traversal in middle", + basePath: tempDir, + userPath: "foo/../../../etc/passwd", + wantErr: true, + errMsg: "path traversal detected", + }, + { + name: "absolute path outside base", + basePath: tempDir, + userPath: "/etc/passwd", + wantErr: true, + errMsg: "resolves outside", + }, + { + name: "valid nested path", + basePath: tempDir, + userPath: "deploy/charts/myapp/Chart.yaml", + wantErr: false, + }, + { + name: "path with current dir reference", + basePath: tempDir, + userPath: "./file.txt", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := ValidatePath(tt.basePath, tt.userPath) + + if tt.wantErr { + if err == nil { + t.Errorf("ValidatePath() expected error containing %q, got nil", tt.errMsg) + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("ValidatePath() error = %v, want error containing %q", err, tt.errMsg) + } + return + } + + if err != nil { + t.Errorf("ValidatePath() unexpected error = %v", err) + return + } + + // Verify result is within base directory + absBase, _ := filepath.Abs(tt.basePath) + if !strings.HasPrefix(result, absBase) { + t.Errorf("ValidatePath() result %q not within base %q", result, absBase) + } + }) + } +} + +func TestValidatePath_EmptyBasePath(t *testing.T) { + t.Parallel() + + // Should use current working directory when base is empty + result, err := ValidatePath("", "file.txt") + if err != nil { + t.Errorf("ValidatePath() with empty base unexpected error = %v", err) + return + } + + cwd, _ := os.Getwd() + expected := filepath.Join(cwd, "file.txt") + if result != expected { + t.Errorf("ValidatePath() = %q, want %q", result, expected) + } +} + +func TestValidatePathRelative(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + tests := []struct { + name string + basePath string + userPath string + want string + wantErr bool + }{ + { + name: "simple relative path", + basePath: tempDir, + userPath: "file.txt", + want: "file.txt", + wantErr: false, + }, + { + name: "nested relative path", + basePath: tempDir, + userPath: "subdir/file.txt", + want: filepath.Join("subdir", "file.txt"), + wantErr: false, + }, + { + name: "path traversal rejected", + basePath: tempDir, + userPath: "../file.txt", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := ValidatePathRelative(tt.basePath, tt.userPath) + + if tt.wantErr { + if err == nil { + t.Error("ValidatePathRelative() expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("ValidatePathRelative() unexpected error = %v", err) + return + } + + if result != tt.want { + t.Errorf("ValidatePathRelative() = %q, want %q", result, tt.want) + } + }) + } +} diff --git a/internal/github/interfaces.go b/internal/github/interfaces.go new file mode 100644 index 0000000..7a74e23 --- /dev/null +++ b/internal/github/interfaces.go @@ -0,0 +1,27 @@ +// Copyright 2025 Stacklok, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package github + +import "context" + +// PRCreator defines the interface for creating release pull requests. +// This interface enables mocking for testing purposes. +type PRCreator interface { + // CreateReleasePR creates a new branch with the modified files and opens a PR. + CreateReleasePR(ctx context.Context, req PRRequest) (*PRResult, error) +} + +// Ensure Client implements PRCreator. +var _ PRCreator = (*Client)(nil) diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..e68369b --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,88 @@ +// Copyright 2025 Stacklok, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package logging provides a simple logging abstraction for the releaseo action. +package logging + +import ( + "log/slog" + "os" +) + +// Logger wraps slog.Logger to provide convenience methods for the action. +type Logger struct { + *slog.Logger +} + +// New creates a new Logger with text output to stdout. +// The log level can be set via the LOG_LEVEL environment variable. +func New() *Logger { + level := slog.LevelInfo + + // Allow log level to be configured via environment variable + if levelStr := os.Getenv("LOG_LEVEL"); levelStr != "" { + switch levelStr { + case "debug", "DEBUG": + level = slog.LevelDebug + case "info", "INFO": + level = slog.LevelInfo + case "warn", "WARN", "warning", "WARNING": + level = slog.LevelWarn + case "error", "ERROR": + level = slog.LevelError + } + } + + opts := &slog.HandlerOptions{ + Level: level, + } + + handler := slog.NewTextHandler(os.Stdout, opts) + return &Logger{slog.New(handler)} +} + +// NewWithLevel creates a new Logger with the specified log level. +func NewWithLevel(level slog.Level) *Logger { + opts := &slog.HandlerOptions{ + Level: level, + } + handler := slog.NewTextHandler(os.Stdout, opts) + return &Logger{slog.New(handler)} +} + +// Infof logs an info message with printf-style formatting. +func (l *Logger) Infof(format string, args ...any) { + //nolint:sloglint // using printf-style is intentional for migration simplicity + l.Info(format, args...) +} + +// Warnf logs a warning message with printf-style formatting. +func (l *Logger) Warnf(format string, args ...any) { + //nolint:sloglint // using printf-style is intentional for migration simplicity + l.Warn(format, args...) +} + +// Errorf logs an error message with printf-style formatting. +func (l *Logger) Errorf(format string, args ...any) { + //nolint:sloglint // using printf-style is intentional for migration simplicity + l.Error(format, args...) +} + +// Default returns the default logger instance for package-level logging. +var defaultLogger = New() + +// Default returns the default logger instance. +func Default() *Logger { + return defaultLogger +} diff --git a/main.go b/main.go index 236e7aa..face57d 100644 --- a/main.go +++ b/main.go @@ -170,6 +170,8 @@ func createReleasePR(ctx context.Context, cfg Config, newVersion string, helmDoc return pr, nil } +// parseFlags parses command-line flags and environment variables into a Config. +// It exits the program if required configuration is missing or invalid. func parseFlags() Config { cfg := Config{} var versionFilesJSON string @@ -186,7 +188,11 @@ func parseFlags() Config { cfg.Token = resolveToken(cfg.Token) cfg.RepoOwner, cfg.RepoName = parseRepository() - validateConfig(cfg) + if err := validateConfig(cfg); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + flag.Usage() + os.Exit(1) + } return cfg } @@ -228,25 +234,31 @@ func parseRepository() (owner, repo string) { } // validateConfig ensures all required configuration fields are set. -func validateConfig(cfg Config) { +// Returns an error if any required field is missing. +func validateConfig(cfg Config) error { if cfg.BumpType == "" { - fmt.Fprintln(os.Stderr, "Error: --bump-type is required") - flag.Usage() - os.Exit(1) + return fmt.Errorf("--bump-type is required") + } + + // Validate bump type value + validBumpTypes := map[string]bool{"major": true, "minor": true, "patch": true} + if !validBumpTypes[strings.ToLower(cfg.BumpType)] { + return fmt.Errorf("invalid bump type %q: must be major, minor, or patch", cfg.BumpType) } if cfg.Token == "" { - fmt.Fprintln(os.Stderr, "Error: --token or GITHUB_TOKEN is required") - flag.Usage() - os.Exit(1) + return fmt.Errorf("--token or GITHUB_TOKEN is required") } if cfg.RepoOwner == "" || cfg.RepoName == "" { - fmt.Fprintln(os.Stderr, "Error: GITHUB_REPOSITORY environment variable is required") - os.Exit(1) + return fmt.Errorf("GITHUB_REPOSITORY environment variable is required (format: owner/repo)") } + + return nil } +// generatePRBody creates a markdown-formatted pull request body describing +// the release version, bump type, and files that were updated. func generatePRBody(ver, bumpType string, versionFiles []files.VersionFileConfig, ranHelmDocs bool) string { var sb strings.Builder @@ -275,6 +287,8 @@ func generatePRBody(ver, bumpType string, versionFiles []files.VersionFileConfig return sb.String() } +// getModifiedFiles returns a list of all files that will be modified by the release. +// This includes the VERSION file and any custom version files specified in the config. func getModifiedFiles(cfg Config) []string { modifiedFiles := []string{cfg.VersionFile} for _, vf := range cfg.VersionFiles { @@ -283,10 +297,62 @@ func getModifiedFiles(cfg Config) []string { return modifiedFiles } +// allowedHelmDocsFlags defines the permitted helm-docs flags for security. +// This prevents arbitrary argument injection. +var allowedHelmDocsFlags = map[string]bool{ + "--chart-search-root": true, + "--template-files": true, + "--badge-style": true, + "--document-dependency-values": true, + "--dry-run": true, + "--ignore-file": true, + "--log-level": true, + "--output-file": true, + "--sort-values-order": true, + "--values-file": true, + "-c": true, + "-d": true, + "-g": true, + "-i": true, + "-l": true, + "-o": true, + "-s": true, + "-t": true, + "-u": true, + "-f": true, +} + +// validateHelmDocsArgs validates that all helm-docs arguments are in the allowlist. +func validateHelmDocsArgs(argsStr string) error { + args := strings.Fields(argsStr) + for _, arg := range args { + // Extract flag name (handle --flag=value format) + flagName := arg + if idx := strings.Index(arg, "="); idx > 0 { + flagName = arg[:idx] + } + + // Skip non-flag arguments (values for previous flags) + if !strings.HasPrefix(flagName, "-") { + continue + } + + if !allowedHelmDocsFlags[flagName] { + return fmt.Errorf("helm-docs flag %q is not allowed", flagName) + } + } + return nil +} + // runHelmDocs executes helm-docs with the provided arguments and returns the list of modified files. func runHelmDocs(argsStr string) ([]string, error) { + // Validate arguments against allowlist + if err := validateHelmDocsArgs(argsStr); err != nil { + return nil, fmt.Errorf("invalid helm-docs arguments: %w", err) + } + args := strings.Fields(argsStr) - cmd := exec.Command("helm-docs", args...) //nolint:gosec // args are from trusted input + cmd := exec.Command("helm-docs", args...) //nolint:gosec // args validated against allowlist cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { @@ -323,6 +389,8 @@ func getGitModifiedFiles() ([]string, error) { return result, nil } +// setOutput writes a key-value pair to the GitHub Actions output file. +// If GITHUB_OUTPUT is not set, it prints the output to stdout instead. func setOutput(name, value string) { outputFile := os.Getenv("GITHUB_OUTPUT") if outputFile == "" { diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0619e64 --- /dev/null +++ b/main_test.go @@ -0,0 +1,433 @@ +// Copyright 2025 Stacklok, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" + + "github.com/stacklok/releaseo/internal/files" +) + +func TestValidateConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + wantErr bool + errMsg string + }{ + { + name: "valid config", + cfg: Config{ + BumpType: "minor", + Token: "ghp_test", + RepoOwner: "stacklok", + RepoName: "releaseo", + }, + wantErr: false, + }, + { + name: "missing bump type", + cfg: Config{ + Token: "ghp_test", + RepoOwner: "stacklok", + RepoName: "releaseo", + }, + wantErr: true, + errMsg: "--bump-type is required", + }, + { + name: "invalid bump type", + cfg: Config{ + BumpType: "invalid", + Token: "ghp_test", + RepoOwner: "stacklok", + RepoName: "releaseo", + }, + wantErr: true, + errMsg: "invalid bump type", + }, + { + name: "missing token", + cfg: Config{ + BumpType: "patch", + RepoOwner: "stacklok", + RepoName: "releaseo", + }, + wantErr: true, + errMsg: "--token or GITHUB_TOKEN is required", + }, + { + name: "missing repo owner", + cfg: Config{ + BumpType: "patch", + Token: "ghp_test", + RepoName: "releaseo", + }, + wantErr: true, + errMsg: "GITHUB_REPOSITORY environment variable is required", + }, + { + name: "missing repo name", + cfg: Config{ + BumpType: "patch", + Token: "ghp_test", + RepoOwner: "stacklok", + }, + wantErr: true, + errMsg: "GITHUB_REPOSITORY environment variable is required", + }, + { + name: "all bump types valid - major", + cfg: Config{ + BumpType: "major", + Token: "ghp_test", + RepoOwner: "stacklok", + RepoName: "releaseo", + }, + wantErr: false, + }, + { + name: "all bump types valid - patch", + cfg: Config{ + BumpType: "patch", + Token: "ghp_test", + RepoOwner: "stacklok", + RepoName: "releaseo", + }, + wantErr: false, + }, + { + name: "bump type case insensitive", + cfg: Config{ + BumpType: "MAJOR", + Token: "ghp_test", + RepoOwner: "stacklok", + RepoName: "releaseo", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateConfig(tt.cfg) + + if tt.wantErr { + if err == nil { + t.Errorf("validateConfig() expected error containing %q, got nil", tt.errMsg) + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("validateConfig() error = %v, want error containing %q", err, tt.errMsg) + } + return + } + + if err != nil { + t.Errorf("validateConfig() unexpected error = %v", err) + } + }) + } +} + +func TestParseVersionFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + jsonStr string + want []files.VersionFileConfig + wantLen int + }{ + { + name: "empty string", + jsonStr: "", + want: nil, + wantLen: 0, + }, + { + name: "single file", + jsonStr: `[{"file":"Chart.yaml","path":"version"}]`, + wantLen: 1, + }, + { + name: "multiple files", + jsonStr: `[{"file":"Chart.yaml","path":"version"},{"file":"values.yaml","path":"image.tag","prefix":"v"}]`, + wantLen: 2, + }, + { + name: "with prefix", + jsonStr: `[{"file":"values.yaml","path":"image.tag","prefix":"v"}]`, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := parseVersionFiles(tt.jsonStr) + + if len(got) != tt.wantLen { + t.Errorf("parseVersionFiles() returned %d items, want %d", len(got), tt.wantLen) + } + + if tt.want != nil && got == nil { + t.Errorf("parseVersionFiles() = nil, want non-nil") + } + }) + } +} + +func TestValidateHelmDocsArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args string + wantErr bool + errMsg string + }{ + { + name: "empty args", + args: "", + wantErr: false, + }, + { + name: "valid chart-search-root", + args: "--chart-search-root=./charts", + wantErr: false, + }, + { + name: "valid multiple args", + args: "--chart-search-root=./charts --template-files=README.md.gotmpl", + wantErr: false, + }, + { + name: "valid short flags", + args: "-c ./charts -t README.md.gotmpl", + wantErr: false, + }, + { + name: "invalid flag", + args: "--execute-script=malicious.sh", + wantErr: true, + errMsg: "not allowed", + }, + { + name: "mixed valid and invalid", + args: "--chart-search-root=./charts --invalid-flag=value", + wantErr: true, + errMsg: "not allowed", + }, + { + name: "valid dry-run", + args: "--dry-run", + wantErr: false, + }, + { + name: "valid log-level", + args: "--log-level=debug", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateHelmDocsArgs(tt.args) + + if tt.wantErr { + if err == nil { + t.Errorf("validateHelmDocsArgs() expected error containing %q, got nil", tt.errMsg) + return + } + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("validateHelmDocsArgs() error = %v, want error containing %q", err, tt.errMsg) + } + return + } + + if err != nil { + t.Errorf("validateHelmDocsArgs() unexpected error = %v", err) + } + }) + } +} + +func TestGeneratePRBody(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version string + bumpType string + versionFiles []files.VersionFileConfig + ranHelmDocs bool + wantContains []string + }{ + { + name: "basic PR body", + version: "1.2.3", + bumpType: "minor", + versionFiles: nil, + ranHelmDocs: false, + wantContains: []string{ + "Release v1.2.3", + "**minor** release", + "`VERSION`", + }, + }, + { + name: "with version files", + version: "2.0.0", + bumpType: "major", + versionFiles: []files.VersionFileConfig{ + {File: "Chart.yaml", Path: "version"}, + }, + ranHelmDocs: false, + wantContains: []string{ + "Release v2.0.0", + "**major** release", + "`Chart.yaml`", + "path: `version`", + }, + }, + { + name: "with helm docs", + version: "1.0.1", + bumpType: "patch", + versionFiles: nil, + ranHelmDocs: true, + wantContains: []string{ + "Release v1.0.1", + "**patch** release", + "helm-docs", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := generatePRBody(tt.version, tt.bumpType, tt.versionFiles, tt.ranHelmDocs) + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("generatePRBody() missing %q in body:\n%s", want, got) + } + } + }) + } +} + +func TestGetModifiedFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + want []string + }{ + { + name: "only VERSION file", + cfg: Config{ + VersionFile: "VERSION", + VersionFiles: nil, + }, + want: []string{"VERSION"}, + }, + { + name: "VERSION file with custom files", + cfg: Config{ + VersionFile: "VERSION", + VersionFiles: []files.VersionFileConfig{ + {File: "Chart.yaml", Path: "version"}, + {File: "values.yaml", Path: "image.tag"}, + }, + }, + want: []string{"VERSION", "Chart.yaml", "values.yaml"}, + }, + { + name: "custom VERSION file location", + cfg: Config{ + VersionFile: "deploy/VERSION", + VersionFiles: nil, + }, + want: []string{"deploy/VERSION"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := getModifiedFiles(tt.cfg) + + if len(got) != len(tt.want) { + t.Errorf("getModifiedFiles() returned %d files, want %d", len(got), len(tt.want)) + return + } + + for i, f := range got { + if f != tt.want[i] { + t.Errorf("getModifiedFiles()[%d] = %q, want %q", i, f, tt.want[i]) + } + } + }) + } +} + +func TestResolveToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagToken string + want string + }{ + { + name: "flag token provided", + flagToken: "ghp_flagtoken", + want: "ghp_flagtoken", + }, + { + name: "empty flag token returns empty (env not set in test)", + flagToken: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Note: We don't set GITHUB_TOKEN env var in parallel tests + // to avoid race conditions + got := resolveToken(tt.flagToken) + + if got != tt.want { + t.Errorf("resolveToken() = %q, want %q", got, tt.want) + } + }) + } +}