| | | 1 | | package config |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "context" |
| | | 5 | | "fmt" |
| | | 6 | | "io" |
| | | 7 | | "net/http" |
| | | 8 | | "os" |
| | | 9 | | "path/filepath" |
| | | 10 | | "strings" |
| | | 11 | | "time" |
| | | 12 | | |
| | | 13 | | "github.com/go-viper/mapstructure/v2" |
| | | 14 | | "github.com/spf13/viper" |
| | | 15 | | |
| | | 16 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 17 | | ) |
| | | 18 | | |
| | | 19 | | const maxExtendsDepth = 10 |
| | | 20 | | |
| | | 21 | | // extendsHTTPClient is used for fetching remote extends configs. It has an |
| | | 22 | | // explicit timeout so that a slow or unresponsive URL does not block the CLI. |
| | | 23 | | var extendsHTTPClient = &http.Client{Timeout: 30 * time.Second} |
| | | 24 | | |
| | | 25 | | // ResolveExtends loads and merges all extended configurations into the base config. |
| | | 26 | | // Supports local file paths and HTTP(S) URLs. Detects cycles. |
| | 8 | 27 | | func ResolveExtends(base domain.Config) (domain.Config, error) { |
| | 8 | 28 | | return resolveExtendsRecursive(base, make(map[string]bool), 0) |
| | 8 | 29 | | } |
| | | 30 | | |
| | 25 | 31 | | func resolveExtendsRecursive(cfg domain.Config, seen map[string]bool, depth int) (domain.Config, error) { |
| | 1 | 32 | | if depth > maxExtendsDepth { |
| | 1 | 33 | | return cfg, fmt.Errorf("extends chain exceeds maximum depth of %d", maxExtendsDepth) |
| | 1 | 34 | | } |
| | | 35 | | |
| | 4 | 36 | | if len(cfg.Extends) == 0 { |
| | 4 | 37 | | return cfg, nil |
| | 4 | 38 | | } |
| | | 39 | | |
| | | 40 | | // Process extends in order; later entries override earlier ones. |
| | 20 | 41 | | for _, ref := range cfg.Extends { |
| | 1 | 42 | | if seen[ref] { |
| | 1 | 43 | | return cfg, fmt.Errorf("circular extends detected: %q", ref) |
| | 1 | 44 | | } |
| | | 45 | | |
| | | 46 | | // Use a per-branch copy of the seen set so siblings that share a common |
| | | 47 | | // ancestor (diamond-shaped extends) are not falsely flagged as cycles. |
| | 19 | 48 | | branchSeen := make(map[string]bool, len(seen)+1) |
| | 19 | 49 | | for k, v := range seen { |
| | 57 | 50 | | branchSeen[k] = v |
| | 57 | 51 | | } |
| | 19 | 52 | | branchSeen[ref] = true |
| | 19 | 53 | | |
| | 19 | 54 | | parent, err := loadExtendsRef(ref) |
| | 2 | 55 | | if err != nil { |
| | 2 | 56 | | return cfg, fmt.Errorf("loading extends %q: %w", ref, err) |
| | 2 | 57 | | } |
| | | 58 | | |
| | | 59 | | // Recursively resolve the parent's own extends. |
| | 17 | 60 | | parent, err = resolveExtendsRecursive(parent, branchSeen, depth+1) |
| | 13 | 61 | | if err != nil { |
| | 13 | 62 | | return cfg, err |
| | 13 | 63 | | } |
| | | 64 | | |
| | | 65 | | // Merge: base values take precedence over parent. |
| | 4 | 66 | | cfg = MergeConfigs(cfg, parent) |
| | | 67 | | } |
| | | 68 | | |
| | 4 | 69 | | return cfg, nil |
| | | 70 | | } |
| | | 71 | | |
| | 19 | 72 | | func loadExtendsRef(ref string) (domain.Config, error) { |
| | 2 | 73 | | if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { |
| | 2 | 74 | | return loadExtendsFromURL(ref) |
| | 2 | 75 | | } |
| | 17 | 76 | | return loadExtendsFromFile(ref) |
| | | 77 | | } |
| | | 78 | | |
| | 18 | 79 | | func loadExtendsFromFile(path string) (domain.Config, error) { |
| | 18 | 80 | | absPath, err := filepath.Abs(path) |
| | 0 | 81 | | if err != nil { |
| | 0 | 82 | | return domain.Config{}, fmt.Errorf("resolving path: %w", err) |
| | 0 | 83 | | } |
| | | 84 | | |
| | 18 | 85 | | v := viper.New() |
| | 18 | 86 | | v.SetConfigFile(absPath) |
| | 18 | 87 | | |
| | 1 | 88 | | if err := v.ReadInConfig(); err != nil { |
| | 1 | 89 | | return domain.Config{}, fmt.Errorf("reading config file: %w", err) |
| | 1 | 90 | | } |
| | | 91 | | |
| | 17 | 92 | | var cfg domain.Config |
| | 17 | 93 | | if err := v.Unmarshal(&cfg, viper.DecodeHook( |
| | 17 | 94 | | mapstructure.ComposeDecodeHookFunc( |
| | 17 | 95 | | StringToGitHubAssetHookFunc(), |
| | 17 | 96 | | mapstructure.StringToTimeDurationHookFunc(), |
| | 17 | 97 | | mapstructure.StringToSliceHookFunc(","), |
| | 17 | 98 | | ), |
| | 0 | 99 | | )); err != nil { |
| | 0 | 100 | | return domain.Config{}, fmt.Errorf("unmarshaling config: %w", err) |
| | 0 | 101 | | } |
| | | 102 | | |
| | 17 | 103 | | return cfg, nil |
| | | 104 | | } |
| | | 105 | | |
| | | 106 | | // loadExtendsFromURL fetches a remote extends config over HTTP. |
| | | 107 | | // It uses context.Background() because ResolveExtends does not yet accept a |
| | | 108 | | // context.Context. When ConfigProvider.Load gains a context parameter this |
| | | 109 | | // function should be updated to accept and forward it. |
| | 2 | 110 | | func loadExtendsFromURL(rawURL string) (domain.Config, error) { |
| | 2 | 111 | | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, http.NoBody) |
| | 0 | 112 | | if err != nil { |
| | 0 | 113 | | return domain.Config{}, fmt.Errorf("creating request: %w", err) |
| | 0 | 114 | | } |
| | 2 | 115 | | resp, err := extendsHTTPClient.Do(req) |
| | 0 | 116 | | if err != nil { |
| | 0 | 117 | | return domain.Config{}, fmt.Errorf("fetching URL: %w", err) |
| | 0 | 118 | | } |
| | 2 | 119 | | defer func() { _ = resp.Body.Close() }() |
| | | 120 | | |
| | 1 | 121 | | if resp.StatusCode != http.StatusOK { |
| | 1 | 122 | | return domain.Config{}, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, rawURL) |
| | 1 | 123 | | } |
| | | 124 | | |
| | 1 | 125 | | data, err := io.ReadAll(resp.Body) |
| | 0 | 126 | | if err != nil { |
| | 0 | 127 | | return domain.Config{}, fmt.Errorf("reading response: %w", err) |
| | 0 | 128 | | } |
| | | 129 | | |
| | 1 | 130 | | tmpFile, err := os.CreateTemp("", "semantic-release-extends-*.yaml") |
| | 0 | 131 | | if err != nil { |
| | 0 | 132 | | return domain.Config{}, fmt.Errorf("creating temp file: %w", err) |
| | 0 | 133 | | } |
| | 1 | 134 | | defer func() { _ = os.Remove(tmpFile.Name()) }() |
| | | 135 | | |
| | 0 | 136 | | if _, err := tmpFile.Write(data); err != nil { |
| | 0 | 137 | | return domain.Config{}, fmt.Errorf("writing temp file: %w", err) |
| | 0 | 138 | | } |
| | | 139 | | // Close explicitly before passing the path to loadExtendsFromFile, which |
| | | 140 | | // opens the same file. No deferred close — the explicit close below is sufficient. |
| | 0 | 141 | | if err := tmpFile.Close(); err != nil { |
| | 0 | 142 | | return domain.Config{}, fmt.Errorf("closing temp file: %w", err) |
| | 0 | 143 | | } |
| | | 144 | | |
| | 1 | 145 | | return loadExtendsFromFile(tmpFile.Name()) |
| | | 146 | | } |