| | | 1 | | package domain |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "fmt" |
| | | 5 | | "path/filepath" |
| | | 6 | | "strconv" |
| | | 7 | | "strings" |
| | | 8 | | ) |
| | | 9 | | |
| | | 10 | | // BranchType categorizes how a branch participates in the release process. |
| | | 11 | | type BranchType string |
| | | 12 | | |
| | | 13 | | const ( |
| | | 14 | | BranchTypeRelease BranchType = "release" |
| | | 15 | | BranchTypeMaintenance BranchType = "maintenance" |
| | | 16 | | BranchTypePrerelease BranchType = "prerelease" |
| | | 17 | | ) |
| | | 18 | | |
| | | 19 | | // BranchPolicy defines version behavior for a specific branch or branch pattern. |
| | | 20 | | type BranchPolicy struct { |
| | | 21 | | Name string `mapstructure:"name"` |
| | | 22 | | Channel string `mapstructure:"channel"` |
| | | 23 | | Prerelease bool `mapstructure:"prerelease"` |
| | | 24 | | IsDefault bool `mapstructure:"is_default"` |
| | | 25 | | Range string `mapstructure:"range"` // maintenance range e.g. "1.x", "1.0.x" |
| | | 26 | | Type BranchType `mapstructure:"branch_type"` |
| | | 27 | | } |
| | | 28 | | |
| | | 29 | | // IsMaintenance returns true if this is a maintenance branch with a version range. |
| | 9 | 30 | | func (bp BranchPolicy) IsMaintenance() bool { |
| | 9 | 31 | | return bp.Range != "" || bp.Type == BranchTypeMaintenance |
| | 9 | 32 | | } |
| | | 33 | | |
| | | 34 | | // IsPrerelease returns true if this is a prerelease branch. |
| | 6 | 35 | | func (bp BranchPolicy) IsPrerelease() bool { |
| | 6 | 36 | | return bp.Prerelease || bp.Type == BranchTypePrerelease |
| | 6 | 37 | | } |
| | | 38 | | |
| | | 39 | | // MaintenanceRange parses the Range field into min/max version constraints. |
| | | 40 | | // Range format: "N.x" (major only) or "N.N.x" (major.minor). |
| | 11 | 41 | | func (bp BranchPolicy) MaintenanceRange() (minVer, maxVer Version, err error) { |
| | 11 | 42 | | r := bp.Range |
| | 1 | 43 | | if r == "" { |
| | 1 | 44 | | // Try to infer from branch name (e.g. "1.x", "1.0.x"). |
| | 1 | 45 | | r = bp.Name |
| | 1 | 46 | | } |
| | | 47 | | |
| | 11 | 48 | | return parseMaintenanceRange(r) |
| | | 49 | | } |
| | | 50 | | |
| | | 51 | | func parseMaintenanceRange(r string) (minVer, maxVer Version, err error) { |
| | | 52 | | parts := strings.Split(r, ".") |
| | | 53 | | if len(parts) < 2 || parts[len(parts)-1] != "x" { |
| | | 54 | | return minVer, maxVer, fmt.Errorf("invalid maintenance range %q: expected N.x or N.N.x", r) |
| | | 55 | | } |
| | | 56 | | |
| | | 57 | | major, err := strconv.Atoi(parts[0]) |
| | | 58 | | if err != nil { |
| | | 59 | | return minVer, maxVer, fmt.Errorf("invalid major in range %q: %w", r, err) |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | if len(parts) == 2 { |
| | | 63 | | // "N.x" — allows any minor/patch within this major. |
| | | 64 | | return NewVersion(major, 0, 0), NewVersion(major+1, 0, 0), nil |
| | | 65 | | } |
| | | 66 | | |
| | | 67 | | if len(parts) == 3 { |
| | | 68 | | minor, err := strconv.Atoi(parts[1]) |
| | | 69 | | if err != nil { |
| | | 70 | | return minVer, maxVer, fmt.Errorf("invalid minor in range %q: %w", r, err) |
| | | 71 | | } |
| | | 72 | | // "N.N.x" — allows any patch within this major.minor. |
| | | 73 | | return NewVersion(major, minor, 0), NewVersion(major, minor+1, 0), nil |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | return minVer, maxVer, fmt.Errorf("invalid maintenance range %q", r) |
| | | 77 | | } |
| | | 78 | | |
| | | 79 | | // VersionInRange checks if a version falls within the maintenance branch range. |
| | | 80 | | // minVer <= version < maxVer. |
| | | 81 | | func VersionInRange(version, minVer, maxVer Version) bool { |
| | | 82 | | if version.Equal(minVer) { |
| | | 83 | | return true |
| | | 84 | | } |
| | | 85 | | return (version.GreaterThan(minVer) || version.Equal(minVer)) && maxVer.GreaterThan(version) |
| | | 86 | | } |
| | | 87 | | |
| | | 88 | | // ValidateMaintenanceVersion checks that a proposed version is valid for the maintenance range. |
| | | 89 | | func ValidateMaintenanceVersion(proposed Version, policy BranchPolicy) error { |
| | | 90 | | if !policy.IsMaintenance() { |
| | | 91 | | return nil |
| | | 92 | | } |
| | | 93 | | |
| | | 94 | | minVer, maxVer, err := policy.MaintenanceRange() |
| | | 95 | | if err != nil { |
| | | 96 | | return err |
| | | 97 | | } |
| | | 98 | | |
| | | 99 | | if !VersionInRange(proposed, minVer, maxVer) { |
| | | 100 | | return fmt.Errorf( |
| | | 101 | | "version %s is outside maintenance range [%s, %s) for branch %q", |
| | | 102 | | proposed, minVer, maxVer, policy.Name, |
| | | 103 | | ) |
| | | 104 | | } |
| | | 105 | | return nil |
| | | 106 | | } |
| | | 107 | | |
| | | 108 | | // DefaultBranchPolicies returns the standard branch configuration matching semantic-release defaults. |
| | | 109 | | func DefaultBranchPolicies() []BranchPolicy { |
| | | 110 | | return []BranchPolicy{ |
| | | 111 | | {Name: "main", IsDefault: true, Type: BranchTypeRelease}, |
| | | 112 | | {Name: "master", IsDefault: true, Type: BranchTypeRelease}, |
| | | 113 | | {Name: "next", Prerelease: true, Channel: "next", Type: BranchTypePrerelease}, |
| | | 114 | | {Name: "next-major", Prerelease: true, Channel: "next-major", Type: BranchTypePrerelease}, |
| | | 115 | | {Name: "beta", Prerelease: true, Channel: "beta", Type: BranchTypePrerelease}, |
| | | 116 | | {Name: "alpha", Prerelease: true, Channel: "alpha", Type: BranchTypePrerelease}, |
| | | 117 | | } |
| | | 118 | | } |
| | | 119 | | |
| | | 120 | | // FindBranchPolicy returns the matching policy for a branch name, or nil if none matches. |
| | | 121 | | // Supports glob patterns in policy names. |
| | | 122 | | func FindBranchPolicy(policies []BranchPolicy, branch string) *BranchPolicy { |
| | | 123 | | for i := range policies { |
| | | 124 | | if policies[i].Name == branch { |
| | | 125 | | return &policies[i] |
| | | 126 | | } |
| | | 127 | | // Try glob match for patterns like "release/*". |
| | | 128 | | if matched, _ := filepath.Match(policies[i].Name, branch); matched { |
| | | 129 | | return &policies[i] |
| | | 130 | | } |
| | | 131 | | } |
| | | 132 | | |
| | | 133 | | // Auto-detect maintenance branches by name pattern (e.g. "1.x", "1.0.x"). |
| | | 134 | | if isMaintenancePattern(branch) { |
| | | 135 | | return &BranchPolicy{ |
| | | 136 | | Name: branch, |
| | | 137 | | Range: branch, |
| | | 138 | | Channel: "release-" + branch, |
| | | 139 | | Type: BranchTypeMaintenance, |
| | | 140 | | } |
| | | 141 | | } |
| | | 142 | | |
| | | 143 | | return nil |
| | | 144 | | } |
| | | 145 | | |
| | | 146 | | func isMaintenancePattern(name string) bool { |
| | | 147 | | parts := strings.Split(name, ".") |
| | | 148 | | if len(parts) < 2 || parts[len(parts)-1] != "x" { |
| | | 149 | | return false |
| | | 150 | | } |
| | | 151 | | _, err := strconv.Atoi(parts[0]) |
| | | 152 | | return err == nil |
| | | 153 | | } |