package releaseprep import ( "fmt" "os" "path/filepath" "regexp" "strconv" "strings" ) var versionPattern = regexp.MustCompile(`const String = "[^"]+"`) type semver struct { major int minor int patch int } func Prepare(rootDir, version, releaseDate string) 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") } if err := updateVersionFile(rootDir, normalizedVersion); err != nil { return err } if err := updateChangelog(rootDir, normalizedVersion, releaseDate); err != nil { return err } return nil } func RecommendedTag(rootDir string) (string, error) { currentVersion, err := readCurrentVersion(rootDir) if err != nil { return "", err } unreleasedBody, err := readUnreleasedBody(rootDir) if err != nil { return "", err } parsed, err := parseSemver(currentVersion) if err != nil { return "", err } switch { case strings.Contains(unreleasedBody, "### Breaking"), strings.Contains(unreleasedBody, "### Removed"): parsed.major++ parsed.minor = 0 parsed.patch = 0 case strings.Contains(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 updateVersionFile(rootDir, version string) error { path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") contents, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read version file: %w", err) } updated := versionPattern.ReplaceAllString(string(contents), fmt.Sprintf(`const String = %q`, version)) if updated == string(contents) { return fmt.Errorf("version constant not found in %s", path) } 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 string) error { unreleasedBody, text, afterHeader, nextSectionStart, err := readChangelogState(rootDir) if err != nil { return err } if strings.TrimSpace(unreleasedBody) == "" { return fmt.Errorf("unreleased section is empty") } path := filepath.Join(rootDir, "changelog.md") 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) (string, error) { path := filepath.Join(rootDir, "internal", "homesick", "version", "version.go") contents, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("read version file: %w", err) } match := versionPattern.FindString(string(contents)) if match == "" { return "", fmt.Errorf("version constant not found in %s", path) } return strings.TrimSuffix(strings.TrimPrefix(match, `const String = "`), `"`), nil } func readUnreleasedBody(rootDir string) (string, error) { unreleasedBody, _, _, _, err := readChangelogState(rootDir) if err != nil { return "", err } if strings.TrimSpace(unreleasedBody) == "" { return "", fmt.Errorf("unreleased section is empty") } return unreleasedBody, nil } func readChangelogState(rootDir string) (string, string, int, int, error) { path := filepath.Join(rootDir, "changelog.md") 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, 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 }