// Package release provides release automation with changelog generation and publishing. package release import ( "context" "regexp" "strconv" "dappco.re/go/core" "dappco.re/go/core/build/internal/ax" coreerr "dappco.re/go/core/log" ) // semverRegex matches semantic version strings with or without 'v' prefix. var semverRegex = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+([a-zA-Z0-9.-]+))?$`) // DetermineVersion determines the version for a release. // It checks in order: // 1. Git tag on HEAD // 2. Most recent tag + increment patch // 3. Default to v0.0.1 if no tags exist // // version, err := release.DetermineVersion(".") // → "v1.2.4" func DetermineVersion(dir string) (string, error) { return DetermineVersionWithContext(context.Background(), dir) } // DetermineVersionWithContext determines the version while honouring caller cancellation. // It checks in order: // 1. Git tag on HEAD // 2. Most recent tag + increment patch // 3. Default to v0.0.1 if no tags exist // // version, err := release.DetermineVersionWithContext(ctx, ".") // → "v1.2.4" func DetermineVersionWithContext(ctx context.Context, dir string) (string, error) { // Check if HEAD has a tag headTag, err := getTagOnHeadWithContext(ctx, dir) if err == nil && headTag != "" { return normalizeVersion(headTag), nil } if err != nil && ctx.Err() != nil { return "", coreerr.E("release.DetermineVersionWithContext", "version lookup cancelled", ctx.Err()) } // Get most recent tag latestTag, err := getLatestTagWithContext(ctx, dir) if err != nil && ctx.Err() != nil { return "", coreerr.E("release.DetermineVersionWithContext", "version lookup cancelled", ctx.Err()) } if err != nil || latestTag == "" { // No tags exist, return default return "v0.0.1", nil } // Increment patch version return IncrementVersion(latestTag), nil } // IncrementVersion increments the patch version of a semver string. // - "v1.2.3" → "v1.2.4" // - "1.2.3" → "v1.2.4" // - "v1.2.3-alpha" → "v1.2.4" (strips prerelease) // // next := release.IncrementVersion("v1.2.3") // → "v1.2.4" func IncrementVersion(current string) string { matches := semverRegex.FindStringSubmatch(current) if matches == nil { // Not a valid semver, return as-is with increment suffix return current + ".1" } major, _ := strconv.Atoi(matches[1]) minor, _ := strconv.Atoi(matches[2]) patch, _ := strconv.Atoi(matches[3]) // Increment patch patch++ return core.Sprintf("v%d.%d.%d", major, minor, patch) } // IncrementMinor increments the minor version of a semver string. // - "v1.2.3" → "v1.3.0" // - "1.2.3" → "v1.3.0" // // next := release.IncrementMinor("v1.2.3") // → "v1.3.0" func IncrementMinor(current string) string { matches := semverRegex.FindStringSubmatch(current) if matches == nil { return current + ".1" } major, _ := strconv.Atoi(matches[1]) minor, _ := strconv.Atoi(matches[2]) // Increment minor, reset patch minor++ return core.Sprintf("v%d.%d.0", major, minor) } // IncrementMajor increments the major version of a semver string. // - "v1.2.3" → "v2.0.0" // - "1.2.3" → "v2.0.0" // // next := release.IncrementMajor("v1.2.3") // → "v2.0.0" func IncrementMajor(current string) string { matches := semverRegex.FindStringSubmatch(current) if matches == nil { return current + ".1" } major, _ := strconv.Atoi(matches[1]) // Increment major, reset minor and patch major++ return core.Sprintf("v%d.0.0", major) } // ParseVersion parses a semver string into its components. // Returns (major, minor, patch, prerelease, build, error). // // major, minor, patch, pre, build, err := release.ParseVersion("v1.2.3-alpha+001") func ParseVersion(version string) (int, int, int, string, string, error) { matches := semverRegex.FindStringSubmatch(version) if matches == nil { return 0, 0, 0, "", "", coreerr.E("release.ParseVersion", "invalid semver: "+version, nil) } major, _ := strconv.Atoi(matches[1]) minor, _ := strconv.Atoi(matches[2]) patch, _ := strconv.Atoi(matches[3]) prerelease := matches[4] build := matches[5] return major, minor, patch, prerelease, build, nil } // ValidateVersion checks if a string is a valid semver. // // if release.ValidateVersion("v1.2.3") { ... } func ValidateVersion(version string) bool { return semverRegex.MatchString(version) } // normalizeVersion ensures the version starts with 'v'. func normalizeVersion(version string) string { if !core.HasPrefix(version, "v") { return "v" + version } return version } func getTagOnHeadWithContext(ctx context.Context, dir string) (string, error) { output, err := ax.RunDir(ctx, dir, "git", "describe", "--tags", "--exact-match", "HEAD") if err != nil { return "", err } return core.Trim(output), nil } func getLatestTagWithContext(ctx context.Context, dir string) (string, error) { output, err := ax.RunDir(ctx, dir, "git", "describe", "--tags", "--abbrev=0") if err != nil { return "", err } return core.Trim(output), nil } // CompareVersions compares two semver strings. // Returns -1 if a < b, 0 if a == b, 1 if a > b. // // result := release.CompareVersions("v1.2.3", "v1.2.4") // → -1 func CompareVersions(a, b string) int { aMajor, aMinor, aPatch, _, _, errA := ParseVersion(a) bMajor, bMinor, bPatch, _, _, errB := ParseVersion(b) // Invalid versions are considered less than valid ones if errA != nil && errB != nil { switch { case a < b: return -1 case a > b: return 1 default: return 0 } } if errA != nil { return -1 } if errB != nil { return 1 } // Compare major if aMajor != bMajor { if aMajor < bMajor { return -1 } return 1 } // Compare minor if aMinor != bMinor { if aMinor < bMinor { return -1 } return 1 } // Compare patch if aPatch != bPatch { if aPatch < bPatch { return -1 } return 1 } return 0 }