diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index a1f15cc510f..2b8ae88f285 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "slices" "strings" "time" @@ -20,21 +19,6 @@ import ( var log = logger.New("workflow:compiler") -// heredocDelimiterRE matches randomized heredoc delimiters of the form GH_AW__<16hexchars>_EOF. -// Used to normalize delimiters when comparing compiled output to skip unnecessary writes. -var heredocDelimiterRE = regexp.MustCompile(`GH_AW_([A-Z0-9_]+)_[0-9a-f]{16}_EOF`) - -// normalizeHeredocDelimiters replaces randomized heredoc delimiter tokens with a stable -// placeholder so that two compilations of the same workflow compare as equal even though -// each run embeds different random tokens. -func normalizeHeredocDelimiters(content string) string { - // Fast path: skip regex if content contains no heredoc delimiters - if !strings.Contains(content, "GH_AW_") { - return content - } - return heredocDelimiterRE.ReplaceAllString(content, "GH_AW_${1}_NORM_EOF") -} - const ( // MaxLockFileSize is the maximum allowed size for generated lock workflow files (500KB) MaxLockFileSize = 512000 // 500KB in bytes diff --git a/pkg/workflow/compiler_filters_validation.go b/pkg/workflow/compiler_filters_validation.go index a596a80aad6..858acb581ec 100644 --- a/pkg/workflow/compiler_filters_validation.go +++ b/pkg/workflow/compiler_filters_validation.go @@ -46,7 +46,6 @@ package workflow import ( - "errors" "fmt" "strings" ) @@ -204,25 +203,3 @@ func validateGlobList(eventMap map[string]any, eventName, filterKey string, isPa } return nil } - -// toStringSlice converts an any value to a []string, supporting []string, []any, and string. -func toStringSlice(val any) ([]string, error) { - switch v := val.(type) { - case []string: - return v, nil - case []any: - result := make([]string, 0, len(v)) - for _, item := range v { - s, ok := item.(string) - if !ok { - return nil, errors.New("non-string item in list") - } - result = append(result, s) - } - return result, nil - case string: - return []string{v}, nil - default: - return nil, fmt.Errorf("unsupported type %T", val) - } -} diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index e3354a51cf6..794050f7c0a 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -504,43 +504,6 @@ func (c *Compiler) extractSkipBots(frontmatter map[string]any) []string { return extractSkipField(frontmatter, "skip-bots") } -// extractStringSliceField extracts a string slice from various input formats -// Handles: string, []string, []any (with string elements) -// Returns nil if the input is empty or invalid -func extractStringSliceField(value any, fieldName string) []string { - switch v := value.(type) { - case string: - // Single string - if v == "" { - return nil - } - roleLog.Printf("Extracted single %s: %s", fieldName, v) - return []string{v} - case []string: - // Already a string slice - if len(v) == 0 { - return nil - } - roleLog.Printf("Extracted %d %s: %v", len(v), fieldName, v) - return v - case []any: - // Array of any - extract strings - var result []string - for _, item := range v { - if str, ok := item.(string); ok && str != "" { - result = append(result, str) - } - } - if len(result) == 0 { - return nil - } - roleLog.Printf("Extracted %d %s from array: %v", len(result), fieldName, result) - return result - } - roleLog.Printf("No valid %s found or unsupported type: %T", fieldName, value) - return nil -} - // mergeStringSlicesDedup merges two string slices with deduplication (preserving order of first occurrence). // Logs the merged result under the given logLabel when the result is non-empty. func mergeStringSlicesDedup(top, imported []string, logLabel string) []string { diff --git a/pkg/workflow/strings.go b/pkg/workflow/strings.go index 12462f6b380..bd8907c59e8 100644 --- a/pkg/workflow/strings.go +++ b/pkg/workflow/strings.go @@ -297,6 +297,21 @@ func GenerateHeredocDelimiterFromSeed(name string, seed string) string { return "GH_AW_" + upperName + "_" + tag + "_EOF" } +// heredocDelimiterRE matches randomized heredoc delimiters of the form GH_AW__<16hexchars>_EOF. +// Used to normalize delimiters when comparing compiled output to skip unnecessary writes. +var heredocDelimiterRE = regexp.MustCompile(`GH_AW_([A-Z0-9_]+)_[0-9a-f]{16}_EOF`) + +// normalizeHeredocDelimiters replaces randomized heredoc delimiter tokens with a stable +// placeholder so that two compilations of the same workflow compare as equal even though +// each run embeds different random tokens. +func normalizeHeredocDelimiters(content string) string { + // Fast path: skip regex if content contains no heredoc delimiters + if !strings.Contains(content, "GH_AW_") { + return content + } + return heredocDelimiterRE.ReplaceAllString(content, "GH_AW_${1}_NORM_EOF") +} + // ValidateHeredocContent checks that content does not contain the heredoc delimiter // anywhere (substring match). The check is intentionally stricter than what shell // heredocs require (delimiter on its own line) — rejecting any occurrence eliminates diff --git a/pkg/workflow/validation_helpers.go b/pkg/workflow/validation_helpers.go index f78038a5946..12e374ed681 100644 --- a/pkg/workflow/validation_helpers.go +++ b/pkg/workflow/validation_helpers.go @@ -11,6 +11,12 @@ // - validateMountStringFormat() - Parses and validates a "source:dest:mode" mount string // - containsTrigger() - Reports whether an 'on:' section includes a named trigger // +// # Type Conversion Helpers (any → []string) +// +// - parseStringSliceAny() - Canonical coercion of []string/[]any to []string; skips non-strings +// - toStringSlice() - Strict variant: returns error on non-string elements; also accepts bare string +// - extractStringSliceField() - Accepts string/[]string/[]any; skips empty strings; wraps bare string +// // # Design Rationale // // These helpers consolidate 76+ duplicate validation patterns identified in the @@ -189,6 +195,67 @@ func parseStringSliceAny(raw any, log *logger.Logger) []string { } } +// toStringSlice converts an any value to a []string, supporting []string, []any, and string. +// Unlike parseStringSliceAny, this function returns an error when a []any element is not a string, +// and also accepts a bare string value (wrapping it in a single-element slice). +func toStringSlice(val any) ([]string, error) { + switch v := val.(type) { + case []string: + return v, nil + case []any: + result := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, errors.New("non-string item in list") + } + result = append(result, s) + } + return result, nil + case string: + return []string{v}, nil + default: + return nil, fmt.Errorf("unsupported type %T", val) + } +} + +// extractStringSliceField extracts a string slice from various input formats. +// Handles: string, []string, []any (with string elements). +// Returns nil if the input is empty or invalid. +func extractStringSliceField(value any, fieldName string) []string { + switch v := value.(type) { + case string: + // Single string + if v == "" { + return nil + } + validationHelpersLog.Printf("Extracted single %s: %s", fieldName, v) + return []string{v} + case []string: + // Already a string slice + if len(v) == 0 { + return nil + } + validationHelpersLog.Printf("Extracted %d %s: %v", len(v), fieldName, v) + return v + case []any: + // Array of any - extract strings + var result []string + for _, item := range v { + if str, ok := item.(string); ok && str != "" { + result = append(result, str) + } + } + if len(result) == 0 { + return nil + } + validationHelpersLog.Printf("Extracted %d %s from array: %v", len(result), fieldName, result) + return result + } + validationHelpersLog.Printf("No valid %s found or unsupported type: %T", fieldName, value) + return nil +} + // validateNoDuplicateIDs checks that all items have unique IDs extracted by idFunc. // The onDuplicate callback creates the error to return when a duplicate is found. func validateNoDuplicateIDs[T any](items []T, idFunc func(T) string, onDuplicate func(string) error) error {