package releaseprep import ( "fmt" "os" "path/filepath" "regexp" "strconv" "strings" ) const ( defaultVersionFile = "internal/releaseprep/version/version.go" defaultVersionExpr = `const String = "([^"]+)"` defaultChangelog = "changelog.md" ) type Options struct { VersionFile string VersionPattern string Changelog string } type semver struct { major int minor int patch int } type resolvedOptions struct { VersionFile string VersionExpr *regexp.Regexp Changelog string } func Prepare(rootDir, version, releaseDate string, options Options) error { normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v") if normalizedVersion == "" { return fmt.Errorf("version must not be empty") } releaseDate = strings.TrimSpace(releaseDate) if releaseDate == "" { return fmt.Errorf("release date must not be empty") } resolved, err := resolveOptions(options) if err != nil { return err } if err := updateVersionFile(rootDir, normalizedVersion, resolved); err != nil { return err } if err := updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil { return err } return nil } func RecommendedTag(rootDir string, options Options) (string, error) { resolved, err := resolveOptions(options) if err != nil { return "", err } currentVersion, err := readCurrentVersion(rootDir, resolved) if err != nil { return "", err } unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog) if err != nil { return "", err } parsed, err := parseSemver(currentVersion) if err != nil { return "", err } switch { case strings.Contains(unreleasedBody, "### Breaking"), sectionHasEntries(unreleasedBody, "Removed"): parsed.major++ parsed.minor = 0 parsed.patch = 0 case sectionHasEntries(unreleasedBody, "Added"): parsed.minor++ parsed.patch = 0 default: parsed.patch++ } return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil } func sectionHasEntries(unreleasedBody, sectionName string) bool { heading := "### " + sectionName sectionStart := strings.Index(unreleasedBody, heading) if sectionStart == -1 { return false } afterHeading := unreleasedBody[sectionStart+len(heading):] if strings.HasPrefix(afterHeading, "\r") { afterHeading = afterHeading[1:] } if strings.HasPrefix(afterHeading, "\n") { afterHeading = afterHeading[1:] } nextHeading := strings.Index(afterHeading, "\n### ") sectionBody := afterHeading if nextHeading != -1 { sectionBody = afterHeading[:nextHeading] } return strings.TrimSpace(sectionBody) != "" } func resolveOptions(options Options) (resolvedOptions, error) { versionFile := strings.TrimSpace(options.VersionFile) if versionFile == "" { versionFile = defaultVersionFile } pattern := strings.TrimSpace(options.VersionPattern) if pattern == "" { pattern = defaultVersionExpr } versionExpr, err := regexp.Compile(pattern) if err != nil { return resolvedOptions{}, fmt.Errorf("compile version pattern: %w", err) } if versionExpr.NumSubexp() != 1 { return resolvedOptions{}, fmt.Errorf("version pattern must contain exactly one capture group") } changelog := strings.TrimSpace(options.Changelog) if changelog == "" { changelog = defaultChangelog } return resolvedOptions{VersionFile: versionFile, VersionExpr: versionExpr, Changelog: changelog}, nil } func updateVersionFile(rootDir, version string, options resolvedOptions) error { path := filepath.Join(rootDir, options.VersionFile) contents, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read version file: %w", err) } match := options.VersionExpr.FindStringSubmatch(string(contents)) if len(match) < 2 { return fmt.Errorf("version value not found in %s", path) } replacement := strings.Replace(match[0], match[1], version, 1) updated := strings.Replace(string(contents), match[0], replacement, 1) if updated == string(contents) { return nil } if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { return fmt.Errorf("write version file: %w", err) } return nil } func updateChangelog(rootDir, version, releaseDate, changelogPath string) error { unreleasedBody, text, afterHeader, nextSectionStart, path, err := readChangelogState(rootDir, changelogPath) if err != nil { return err } if strings.TrimSpace(unreleasedBody) == "" { return fmt.Errorf("unreleased section is empty") } newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate) newSection += "\n" + unreleasedBody if !strings.HasSuffix(newSection, "\n") { newSection += "\n" } updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:] if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { return fmt.Errorf("write changelog: %w", err) } return nil } func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) { path := filepath.Join(rootDir, options.VersionFile) contents, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("read version file: %w", err) } match := options.VersionExpr.FindStringSubmatch(string(contents)) if len(match) < 2 { return "", fmt.Errorf("version value not found in %s", path) } return strings.TrimSpace(match[1]), nil } func readUnreleasedBody(rootDir, changelogPath string) (string, error) { unreleasedBody, _, _, _, _, err := readChangelogState(rootDir, changelogPath) if err != nil { return "", err } if strings.TrimSpace(unreleasedBody) == "" { return "", fmt.Errorf("unreleased section is empty") } return unreleasedBody, nil } func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) { path := filepath.Join(rootDir, changelogPath) contents, err := os.ReadFile(path) if err != nil { return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err) } text := string(contents) unreleasedHeader := "## [Unreleased]\n" start := strings.Index(text, unreleasedHeader) if start == -1 { return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog") } afterHeader := start + len(unreleasedHeader) nextSectionRelative := strings.Index(text[afterHeader:], "\n## [") if nextSectionRelative == -1 { nextSectionRelative = len(text[afterHeader:]) } nextSectionStart := afterHeader + nextSectionRelative unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n") return unreleasedBody, text, afterHeader, nextSectionStart, path, nil } func parseSemver(version string) (semver, error) { parts := strings.Split(strings.TrimSpace(version), ".") if len(parts) != 3 { return semver{}, fmt.Errorf("version %q is not semantic major.minor.patch", version) } major, err := strconv.Atoi(parts[0]) if err != nil { return semver{}, fmt.Errorf("parse major version: %w", err) } minor, err := strconv.Atoi(parts[1]) if err != nil { return semver{}, fmt.Errorf("parse minor version: %w", err) } patch, err := strconv.Atoi(parts[2]) if err != nil { return semver{}, fmt.Errorf("parse patch version: %w", err) } return semver{major: major, minor: minor, patch: patch}, nil }