From 799c8d167dc41a0306c1b06533c9479307e80415 Mon Sep 17 00:00:00 2001 From: Micheal Wilkinson Date: Fri, 20 Mar 2026 14:54:57 +0000 Subject: [PATCH] feat(release): automate release preparation --- .gitea/workflows/prepare-release.yml | 83 +++++++++++++++++++++++++ cmd/releaseprep/main.go | 33 ++++++++++ internal/releaseprep/releaseprep.go | 90 ++++++++++++++++++++++++++++ justfile | 3 + script/prepare-release.sh | 12 ++++ 5 files changed, 221 insertions(+) create mode 100644 .gitea/workflows/prepare-release.yml create mode 100644 cmd/releaseprep/main.go create mode 100644 internal/releaseprep/releaseprep.go create mode 100755 script/prepare-release.sh diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml new file mode 100644 index 0000000..9456177 --- /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/homesick/version/version.go + git commit -m "release: prepare ${tag}" + git tag "$tag" + git push origin HEAD + git push origin "$tag" \ No newline at end of file diff --git a/cmd/releaseprep/main.go b/cmd/releaseprep/main.go new file mode 100644 index 0000000..d1c8059 --- /dev/null +++ b/cmd/releaseprep/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "git.hrafn.xyz/aether/gosick/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") + root := flag.String("root", ".", "repository root to update") + flag.Parse() + + if *version == "" || *date == "" { + fmt.Fprintln(os.Stderr, "usage: releaseprep --version --date [--root ]") + os.Exit(2) + } + + absRoot, err := filepath.Abs(*root) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve root: %v\n", err) + os.Exit(1) + } + + if err := releaseprep.Prepare(absRoot, *version, *date); err != nil { + fmt.Fprintf(os.Stderr, "prepare release: %v\n", err) + os.Exit(1) + } +} \ No newline at end of file diff --git a/internal/releaseprep/releaseprep.go b/internal/releaseprep/releaseprep.go new file mode 100644 index 0000000..5a43a6a --- /dev/null +++ b/internal/releaseprep/releaseprep.go @@ -0,0 +1,90 @@ +package releaseprep + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +var versionPattern = regexp.MustCompile(`const String = "[^"]+"`) + +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 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 { + path := filepath.Join(rootDir, "changelog.md") + contents, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read changelog: %w", err) + } + + text := string(contents) + unreleasedHeader := "## [Unreleased]\n" + start := strings.Index(text, unreleasedHeader) + if start == -1 { + return 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") + + newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate) + if strings.TrimSpace(unreleasedBody) != "" { + 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 +} \ No newline at end of file diff --git a/justfile b/justfile index ea5dabb..544d7df 100644 --- a/justfile +++ b/justfile @@ -19,3 +19,6 @@ behavior: behavior-verbose: ./script/run-behavior-suite-docker.sh --verbose + +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..2fb4f7f --- /dev/null +++ b/script/prepare-release.sh @@ -0,0 +1,12 @@ +#!/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" \ No newline at end of file