596 lines
17 KiB
Go
596 lines
17 KiB
Go
// Package vociferate provides changelog-driven release preparation utilities.
|
|
//
|
|
// It updates version metadata, promotes the Unreleased changelog section into a
|
|
// dated version section, recommends the next semantic version tag from pending
|
|
// changelog entries, and normalizes changelog links when repository metadata is
|
|
// available.
|
|
package vociferate
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
defaultVersionFile = "release-version"
|
|
defaultVersionExpr = `^\s*([^\r\n]+)\s*$`
|
|
defaultChangelog = "CHANGELOG.md"
|
|
defaultUnreleasedTemplate = "### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n"
|
|
)
|
|
|
|
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
|
|
var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
|
var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
|
|
var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
|
var refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
|
|
|
|
type Options struct {
|
|
// VersionFile is the path to the file that stores the current version,
|
|
// relative to the repository root. When empty, release-version is used.
|
|
VersionFile string
|
|
// VersionPattern is a regular expression with exactly one capture group for
|
|
// extracting the current version from VersionFile.
|
|
// When empty, a line-oriented default matcher is used.
|
|
VersionPattern string
|
|
// Changelog is the path to the changelog file, relative to the repository
|
|
// root. When empty, CHANGELOG.md is used.
|
|
Changelog string
|
|
}
|
|
|
|
type semver struct {
|
|
major int
|
|
minor int
|
|
patch int
|
|
}
|
|
|
|
type resolvedOptions struct {
|
|
VersionFile string
|
|
VersionExpr *regexp.Regexp
|
|
Changelog string
|
|
}
|
|
|
|
// Prepare updates version state and promotes the Unreleased changelog notes
|
|
// into a new release section.
|
|
//
|
|
// The version may be provided with or without a leading "v" and releaseDate
|
|
// must use YYYY-MM-DD formatting. Prepare updates both the configured version
|
|
// file and changelog, and enriches changelog headings with repository links
|
|
// when repository metadata can be derived from CI environment variables or the
|
|
// origin git remote.
|
|
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
|
|
}
|
|
|
|
// RecommendedTag returns the next semantic release tag (for example, v1.2.3)
|
|
// based on the current version and Unreleased changelog content.
|
|
//
|
|
// Bump rules are:
|
|
// - major: Unreleased contains a Breaking section or Removed entries
|
|
// - minor: Unreleased contains Added entries
|
|
// - patch: all other cases
|
|
//
|
|
// When no previous release is present in the changelog, the first
|
|
// recommendation is always v1.0.0.
|
|
func RecommendedTag(rootDir string, options Options) (string, error) {
|
|
resolved, err := resolveOptions(options)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var currentVersion string
|
|
isFirstRelease := false
|
|
if options.VersionFile != "" {
|
|
currentVersion, err = readCurrentVersion(rootDir, resolved)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
} else {
|
|
version, found, err := readLatestChangelogVersion(rootDir, resolved.Changelog)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !found {
|
|
currentVersion = "0.0.0"
|
|
isFirstRelease = true
|
|
} else {
|
|
currentVersion = version
|
|
}
|
|
}
|
|
|
|
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
parsed, err := parseSemver(currentVersion)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if isFirstRelease {
|
|
return "v1.0.0", nil
|
|
}
|
|
|
|
switch {
|
|
case sectionHasEntries(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):]
|
|
afterHeading = strings.TrimPrefix(afterHeading, "\r")
|
|
afterHeading = strings.TrimPrefix(afterHeading, "\n")
|
|
|
|
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 {
|
|
if os.IsNotExist(err) {
|
|
return os.WriteFile(path, []byte(version+"\n"), 0o644)
|
|
}
|
|
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 !unreleasedHasEntries(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" + defaultUnreleasedTemplate + "\n" + newSection + text[nextSectionStart:]
|
|
repoURL, ok := deriveRepositoryURL(rootDir)
|
|
if ok {
|
|
updated = addChangelogLinks(updated, repoURL, rootDir)
|
|
}
|
|
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 !unreleasedHasEntries(unreleasedBody) {
|
|
return "", fmt.Errorf("unreleased section is empty")
|
|
}
|
|
|
|
return unreleasedBody, nil
|
|
}
|
|
|
|
func unreleasedHasEntries(unreleasedBody string) bool {
|
|
for _, line := range strings.Split(unreleasedBody, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "### ") {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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)
|
|
headerLoc := unreleasedHeadingRe.FindStringIndex(text)
|
|
if headerLoc == nil {
|
|
return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog")
|
|
}
|
|
|
|
afterHeader := headerLoc[1]
|
|
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 readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
|
|
path := filepath.Join(rootDir, changelogPath)
|
|
contents, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("read changelog: %w", err)
|
|
}
|
|
|
|
match := linkedReleasedSectionRe.FindStringSubmatch(string(contents))
|
|
if match == nil {
|
|
return "", false, nil
|
|
}
|
|
return match[1], true, nil
|
|
}
|
|
|
|
func deriveRepositoryURL(rootDir string) (string, bool) {
|
|
override := strings.TrimSpace(os.Getenv("VOCIFERATE_REPOSITORY_URL"))
|
|
if override != "" {
|
|
repositoryPath, ok := deriveRepositoryPath(rootDir)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
baseURL := strings.TrimSuffix(strings.TrimSpace(override), "/")
|
|
return baseURL + "/" + repositoryPath, true
|
|
}
|
|
|
|
serverURL := strings.TrimSpace(os.Getenv("GITHUB_SERVER_URL"))
|
|
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY"))
|
|
if serverURL != "" && repository != "" {
|
|
return strings.TrimSuffix(serverURL, "/") + "/" + strings.TrimPrefix(repository, "/"), true
|
|
}
|
|
|
|
gitConfigPath := filepath.Join(rootDir, ".git", "config")
|
|
contents, err := os.ReadFile(gitConfigPath)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
remoteURL, ok := originRemoteURLFromGitConfig(string(contents))
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
repoURL, ok := normalizeRepoURL(remoteURL)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
return repoURL, true
|
|
}
|
|
|
|
func deriveRepositoryPath(rootDir string) (string, bool) {
|
|
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY"))
|
|
if repository != "" {
|
|
return strings.TrimPrefix(repository, "/"), true
|
|
}
|
|
|
|
gitConfigPath := filepath.Join(rootDir, ".git", "config")
|
|
contents, err := os.ReadFile(gitConfigPath)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
remoteURL, ok := originRemoteURLFromGitConfig(string(contents))
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
repoURL, ok := normalizeRepoURL(remoteURL)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
parsedURL := strings.TrimPrefix(repoURL, "https://")
|
|
parsedURL = strings.TrimPrefix(parsedURL, "http://")
|
|
slash := strings.Index(parsedURL, "/")
|
|
if slash == -1 || slash == len(parsedURL)-1 {
|
|
return "", false
|
|
}
|
|
|
|
return parsedURL[slash+1:], true
|
|
}
|
|
|
|
func originRemoteURLFromGitConfig(config string) (string, bool) {
|
|
inOrigin := false
|
|
for _, line := range strings.Split(config, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
inOrigin = trimmed == `[remote "origin"]`
|
|
continue
|
|
}
|
|
|
|
if !inOrigin {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(trimmed, "url") {
|
|
parts := strings.SplitN(trimmed, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
url := strings.TrimSpace(parts[1])
|
|
if url != "" {
|
|
return url, true
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func normalizeRepoURL(remoteURL string) (string, bool) {
|
|
remoteURL = strings.TrimSpace(remoteURL)
|
|
if remoteURL == "" {
|
|
return "", false
|
|
}
|
|
|
|
if strings.HasPrefix(remoteURL, "http://") || strings.HasPrefix(remoteURL, "https://") {
|
|
normalized := strings.TrimSuffix(strings.TrimSuffix(remoteURL, "/"), ".git")
|
|
return normalized, true
|
|
}
|
|
|
|
if strings.HasPrefix(remoteURL, "ssh://") {
|
|
withoutScheme := strings.TrimPrefix(remoteURL, "ssh://")
|
|
at := strings.Index(withoutScheme, "@")
|
|
if at == -1 {
|
|
return "", false
|
|
}
|
|
hostAndPath := withoutScheme[at+1:]
|
|
host, path, ok := strings.Cut(hostAndPath, "/")
|
|
if !ok || host == "" || path == "" {
|
|
return "", false
|
|
}
|
|
return "https://" + host + "/" + strings.TrimSuffix(path, ".git"), true
|
|
}
|
|
|
|
if strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":") {
|
|
afterAt, ok := strings.CutPrefix(remoteURL, strings.Split(remoteURL, "@")[0]+"@")
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
host, path, ok := strings.Cut(afterAt, ":")
|
|
if !ok || host == "" || path == "" {
|
|
return "", false
|
|
}
|
|
return "https://" + host + "/" + strings.TrimSuffix(path, ".git"), true
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
func addChangelogLinks(text, repoURL, rootDir string) string {
|
|
repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
|
|
if repoURL == "" {
|
|
return text
|
|
}
|
|
|
|
displayRepoURL := displayURL(repoURL)
|
|
|
|
// Normalize headings to plain format, stripping any existing inline links.
|
|
text = unreleasedHeadingRe.ReplaceAllString(text, "## [Unreleased]\n")
|
|
text = releaseHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
|
|
parts := releaseHeadingRe.FindStringSubmatch(match)
|
|
if len(parts) < 2 {
|
|
return match
|
|
}
|
|
version := parts[1]
|
|
return fmt.Sprintf("## [%s] - ", version)
|
|
})
|
|
|
|
// Strip any trailing reference link block (blank lines followed by ref link lines).
|
|
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
|
cutAt := len(lines)
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if strings.TrimSpace(lines[i]) == "" || refLinkLineRe.MatchString(lines[i]) {
|
|
cutAt = i
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
text = strings.Join(lines[:cutAt], "\n") + "\n"
|
|
|
|
// Build and append reference link definitions.
|
|
releasedMatches := releasedSectionRe.FindAllStringSubmatch(text, -1)
|
|
releasedVersions := make([]string, 0, len(releasedMatches))
|
|
for _, match := range releasedMatches {
|
|
if len(match) >= 2 {
|
|
releasedVersions = append(releasedVersions, match[1])
|
|
}
|
|
}
|
|
|
|
linkDefs := make([]string, 0, len(releasedVersions)+1)
|
|
if len(releasedVersions) > 0 {
|
|
latest := releasedVersions[0]
|
|
linkDefs = append(linkDefs, fmt.Sprintf("[Unreleased]: %s", compareURL(displayRepoURL, "v"+latest, "main")))
|
|
} else {
|
|
linkDefs = append(linkDefs, fmt.Sprintf("[Unreleased]: %s/src/branch/main", displayRepoURL))
|
|
}
|
|
|
|
firstCommitShort, hasFirstCommit := firstCommitShortHash(rootDir)
|
|
for i, version := range releasedVersions {
|
|
if i+1 < len(releasedVersions) {
|
|
previousVersion := releasedVersions[i+1]
|
|
linkDefs = append(linkDefs, fmt.Sprintf("[%s]: %s", version, compareURL(displayRepoURL, "v"+previousVersion, "v"+version)))
|
|
continue
|
|
}
|
|
|
|
if hasFirstCommit {
|
|
linkDefs = append(linkDefs, fmt.Sprintf("[%s]: %s", version, compareURL(displayRepoURL, firstCommitShort, "v"+version)))
|
|
continue
|
|
}
|
|
|
|
linkDefs = append(linkDefs, fmt.Sprintf("[%s]: %s", version, compareURL(displayRepoURL, "v"+version, "main")))
|
|
}
|
|
|
|
return strings.TrimRight(text, "\n") + "\n\n" + strings.Join(linkDefs, "\n") + "\n"
|
|
}
|
|
|
|
func displayURL(url string) string {
|
|
trimmed := strings.TrimSpace(url)
|
|
if strings.HasPrefix(trimmed, "https://") {
|
|
return trimmed
|
|
}
|
|
if strings.HasPrefix(trimmed, "http://") {
|
|
return "https://" + strings.TrimPrefix(trimmed, "http://")
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func firstCommitShortHash(rootDir string) (string, bool) {
|
|
command := exec.Command("git", "-C", rootDir, "rev-list", "--max-parents=0", "--abbrev-commit", "HEAD")
|
|
output, err := command.Output()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
commit := strings.TrimSpace(string(output))
|
|
if commit == "" {
|
|
return "", false
|
|
}
|
|
|
|
if strings.Contains(commit, "\n") {
|
|
commit = strings.SplitN(commit, "\n", 2)[0]
|
|
}
|
|
|
|
return commit, true
|
|
}
|
|
|
|
func compareURL(repoURL, baseRef, headRef string) string {
|
|
return fmt.Sprintf("%s/compare/%s...%s", repoURL, baseRef, headRef)
|
|
}
|
|
|
|
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
|
|
}
|