feat(releaseprep): migrate standalone tool into vociferate
This commit is contained in:
83
.gitea/workflows/prepare-release.yml
Normal file
83
.gitea/workflows/prepare-release.yml
Normal file
@@ -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"
|
||||||
56
.gitea/workflows/push-validation.yml
Normal file
56
.gitea/workflows/push-validation.yml
Normal file
@@ -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
|
||||||
53
README.md
Normal file
53
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
63
action.yml
Normal file
63
action.yml
Normal file
@@ -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[@]}"
|
||||||
53
cmd/releaseprep/main.go
Normal file
53
cmd/releaseprep/main.go
Normal file
@@ -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 <version> --date <YYYY-MM-DD> [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>] | --recommend [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>]")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
271
internal/releaseprep/releaseprep.go
Normal file
271
internal/releaseprep/releaseprep.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
3
internal/releaseprep/version/version.go
Normal file
3
internal/releaseprep/version/version.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
const String = "0.1.0"
|
||||||
14
justfile
Normal file
14
justfile
Normal file
@@ -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}}"
|
||||||
18
script/prepare-release.sh
Executable file
18
script/prepare-release.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
echo "usage: $0 <version>" >&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
|
||||||
Reference in New Issue
Block a user