diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml new file mode 100644 index 0000000..6fc9a5d --- /dev/null +++ b/.gitea/workflows/prepare-release.yml @@ -0,0 +1,83 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + version: + description: Semantic version to release, with or without leading v. + required: true + +jobs: + prepare: + runs-on: ubuntu-latest + container: docker.io/catthehacker/ubuntu:act-latest + defaults: + run: + shell: bash + env: + RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: Prepare release files + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + ./script/prepare-release.sh "$RELEASE_VERSION" + + - name: Run tests + run: | + set -euo pipefail + go test ./... + + - name: Configure git author + run: | + set -euo pipefail + git config user.name "gitea-actions[bot]" + git config user.email "gitea-actions[bot]@users.noreply.local" + + - name: Commit release changes and push tag + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + normalized_version="${RELEASE_VERSION#v}" + tag="v${normalized_version}" + + if git rev-parse "$tag" >/dev/null 2>&1; then + echo "Tag ${tag} already exists" >&2 + exit 1 + fi + + case "$GITHUB_SERVER_URL" in + https://*) + authed_remote="https://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" + ;; + http://*) + authed_remote="http://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#http://}/${GITHUB_REPOSITORY}.git" + ;; + *) + echo "Unsupported GITHUB_SERVER_URL: ${GITHUB_SERVER_URL}" >&2 + exit 1 + ;; + esac + + git remote set-url origin "$authed_remote" + git add changelog.md internal/releaseprep/version/version.go + git commit -m "release: prepare ${tag}" + git tag "$tag" + git push origin HEAD + git push origin "$tag" diff --git a/.gitea/workflows/push-validation.yml b/.gitea/workflows/push-validation.yml new file mode 100644 index 0000000..cc4c723 --- /dev/null +++ b/.gitea/workflows/push-validation.yml @@ -0,0 +1,56 @@ +name: Push Validation + +on: + push: + branches: + - "**" + tags-ignore: + - "*" + +jobs: + validate: + runs-on: ubuntu-latest + container: docker.io/catthehacker/ubuntu:act-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + check-latest: true + cache: true + cache-dependency-path: go.sum + + - name: Run full unit test suite with coverage + run: | + set -euo pipefail + go test -covermode=atomic -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + + - name: Recommend next release tag on main pushes + if: ${{ github.ref == 'refs/heads/main' }} + run: | + set -euo pipefail + + if recommended_tag="$(go run ./cmd/releaseprep --recommend --root . 2>release-recommendation.err)"; then + { + echo + echo '## Release Recommendation' + echo + echo "- Recommended next tag: \`${recommended_tag}\`" + } >> "$GITHUB_STEP_SUMMARY" + else + recommendation_error="$(tr '\n' ' ' < release-recommendation.err | sed 's/[[:space:]]\+/ /g' | sed 's/^ //; s/ $//')" + echo "::warning::${recommendation_error}" + { + echo + echo '## Release Recommendation' + echo + echo "- No recommended tag emitted: ${recommendation_error}" + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5f1b2e --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# vociferate + +A reusable release preparation tool for Go repositories. + +## Build + +Build with just: + +```bash +just go-build +``` + +Or directly with Go: + +```bash +go build -o dist/releaseprep ./cmd/releaseprep +``` + +## Usage + +Prepare release files: + +```bash +go run ./cmd/releaseprep --version v1.2.3 --date 2026-03-20 --root . +``` + +Recommend next release tag from changelog content: + +```bash +go run ./cmd/releaseprep --recommend --root . +``` + +### Flags + +- `--version` semantic version to release (with or without leading `v`). +- `--date` release date in `YYYY-MM-DD` format. +- `--recommend` print recommended next tag based on `## [Unreleased]`. +- `--root` repository root directory. +- `--version-file` path to version source file relative to `--root`. +- `--version-pattern` regexp with exactly one capture group for version value. +- `--changelog` path to changelog file relative to `--root`. + +Defaults: + +- `version-file`: `internal/releaseprep/version/version.go` +- `version-pattern`: `const String = "([^"]+)"` +- `changelog`: `changelog.md` + +## Testing + +```bash +just go-test +``` diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..f0e681b --- /dev/null +++ b/action.yml @@ -0,0 +1,63 @@ +name: releaseprep +description: Prepare release files or recommend a next semantic version tag. + +inputs: + version: + description: Semantic version to release. + required: false + version-file: + description: Path to version file relative to repository root. + required: false + default: '' + version-pattern: + description: Regular expression with one capture group for current version. + required: false + default: '' + changelog: + description: Path to changelog file relative to repository root. + required: false + default: changelog.md + recommend: + description: If true, print recommended next release tag. + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.26.1' + + - name: Run releaseprep + shell: bash + run: | + set -euo pipefail + + args=(--root .) + + if [[ "${{ inputs.recommend }}" == "true" ]]; then + args+=(--recommend) + else + if [[ -z "${{ inputs.version }}" ]]; then + echo "input 'version' is required when recommend is false" >&2 + exit 2 + fi + args+=(--version "${{ inputs.version }}" --date "$(date -u +%F)") + fi + + if [[ -n "${{ inputs.version-file }}" ]]; then + args+=(--version-file "${{ inputs.version-file }}") + fi + + if [[ -n "${{ inputs.version-pattern }}" ]]; then + args+=(--version-pattern "${{ inputs.version-pattern }}") + fi + + if [[ -n "${{ inputs.changelog }}" ]]; then + args+=(--changelog "${{ inputs.changelog }}") + fi + + go run git.hrafn.xyz/aether/vociferate/cmd/releaseprep@latest "${args[@]}" diff --git a/cmd/releaseprep/main.go b/cmd/releaseprep/main.go new file mode 100644 index 0000000..1884d7e --- /dev/null +++ b/cmd/releaseprep/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "git.hrafn.xyz/aether/vociferate/internal/releaseprep" +) + +func main() { + version := flag.String("version", "", "semantic version to release, with or without leading v") + date := flag.String("date", "", "release date in YYYY-MM-DD format") + recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog") + root := flag.String("root", ".", "repository root to update") + versionFile := flag.String("version-file", "", "path to the version file, relative to --root") + versionPattern := flag.String("version-pattern", "", "regexp with one capture group for the version value") + changelog := flag.String("changelog", "", "path to changelog file, relative to --root") + flag.Parse() + + absRoot, err := filepath.Abs(*root) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve root: %v\n", err) + os.Exit(1) + } + + opts := releaseprep.Options{ + VersionFile: *versionFile, + VersionPattern: *versionPattern, + Changelog: *changelog, + } + + if *recommend { + tag, err := releaseprep.RecommendedTag(absRoot, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "recommend release: %v\n", err) + os.Exit(1) + } + fmt.Println(tag) + return + } + + if *version == "" || *date == "" { + fmt.Fprintln(os.Stderr, "usage: releaseprep --version --date [--root ] [--version-file ] [--version-pattern ] [--changelog ] | --recommend [--root ] [--version-file ] [--version-pattern ] [--changelog ]") + os.Exit(2) + } + + if err := releaseprep.Prepare(absRoot, *version, *date, opts); err != nil { + fmt.Fprintf(os.Stderr, "prepare release: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/releaseprep/releaseprep.go b/internal/releaseprep/releaseprep.go new file mode 100644 index 0000000..f167812 --- /dev/null +++ b/internal/releaseprep/releaseprep.go @@ -0,0 +1,271 @@ +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 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):] + 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 fmt.Errorf("version value 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, 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 +} diff --git a/internal/releaseprep/version/version.go b/internal/releaseprep/version/version.go new file mode 100644 index 0000000..59fc368 --- /dev/null +++ b/internal/releaseprep/version/version.go @@ -0,0 +1,3 @@ +package version + +const String = "0.1.0" diff --git a/justfile b/justfile new file mode 100644 index 0000000..18f41d4 --- /dev/null +++ b/justfile @@ -0,0 +1,14 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +default: + @just --list + +go-build: + @mkdir -p dist + go build -o dist/releaseprep ./cmd/releaseprep + +go-test: + go test ./... + +prepare-release version: + ./script/prepare-release.sh "{{version}}" diff --git a/script/prepare-release.sh b/script/prepare-release.sh new file mode 100755 index 0000000..3c5863a --- /dev/null +++ b/script/prepare-release.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +release_date="$(date -u +%F)" + +go run ./cmd/releaseprep \ + --root "$repo_root" \ + --version "$1" \ + --date "$release_date" \ + --version-file internal/releaseprep/version/version.go \ + --version-pattern 'const String = "([^"]+)"' \ + --changelog changelog.md