Compare commits
14 Commits
8fc831dfdf
...
75f636f9ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f636f9ba | ||
|
|
1e5de20a41 | ||
|
|
07d73660eb | ||
|
|
029175cb55 | ||
|
|
38f649e99b | ||
|
|
af491aa267 | ||
|
|
0dd38e5267 | ||
|
|
93918f3a39 | ||
|
|
3b8dadbd29 | ||
|
|
f9c853a4e9 | ||
|
|
799c8d167d | ||
|
|
feb8ca3434 | ||
|
|
dbb6c82562 | ||
|
|
c3f809a586 |
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/homesick/version/version.go
|
||||
git commit -m "release: prepare ${tag}"
|
||||
git tag "$tag"
|
||||
git push origin HEAD
|
||||
git push origin "$tag"
|
||||
@@ -121,3 +121,26 @@ jobs:
|
||||
- name: Run behavior suite on main pushes
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: ./script/run-behavior-suite-docker.sh
|
||||
|
||||
- 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
|
||||
|
||||
@@ -39,11 +39,26 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Install UPX
|
||||
uses: crazy-max/ghaction-upx@v3
|
||||
with:
|
||||
install-only: true
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
mkdir -p dist
|
||||
output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \
|
||||
go build -o dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/homesick
|
||||
go build -o "$output" ./cmd/homesick
|
||||
|
||||
- name: Compress binary with UPX
|
||||
if: ${{ matrix.goos == 'linux' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||
if ! upx --best --lzma "$output"; then
|
||||
echo "::warning::UPX compression failed for ${output}; continuing with uncompressed binary"
|
||||
fi
|
||||
|
||||
- name: Package artifact
|
||||
run: |
|
||||
|
||||
@@ -5,8 +5,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in the automated release tooling.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking
|
||||
|
||||
### Added
|
||||
|
||||
- Native Go implementations for `clone`, `link`, `unlink`, and `track`.
|
||||
@@ -15,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Just workflow support for building and running the Linux behavior binary.
|
||||
- Coverage reports and badges published to shared object storage for branches and pull requests.
|
||||
- Pull requests now receive coverage report links in CI comments.
|
||||
- Manual release preparation workflow and script to bump the reported version, promote unreleased changelog notes, and create release tags.
|
||||
- Main branch validation now emits a recommended next release tag based on unreleased changelog sections, and release preparation now rejects empty unreleased notes.
|
||||
- Release recommendations now support an explicit `### Breaking` section for major-version changes that would otherwise be described under `### Changed`.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
44
cmd/releaseprep/main.go
Normal file
44
cmd/releaseprep/main.go
Normal file
@@ -0,0 +1,44 @@
|
||||
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")
|
||||
recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog")
|
||||
root := flag.String("root", ".", "repository root to update")
|
||||
flag.Parse()
|
||||
|
||||
absRoot, err := filepath.Abs(*root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resolve root: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *recommend {
|
||||
tag, err := releaseprep.RecommendedTag(absRoot)
|
||||
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>] | --recommend [--root <dir>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if err := releaseprep.Prepare(absRoot, *version, *date); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "prepare release: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
190
internal/homesick/core/rc_test.go
Normal file
190
internal/homesick/core/rc_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.hrafn.xyz/aether/gosick/internal/homesick/core"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type RcSuite struct {
|
||||
suite.Suite
|
||||
tmpDir string
|
||||
homeDir string
|
||||
reposDir string
|
||||
stdout *bytes.Buffer
|
||||
stderr *bytes.Buffer
|
||||
app *core.App
|
||||
}
|
||||
|
||||
func TestRcSuite(t *testing.T) {
|
||||
suite.Run(t, new(RcSuite))
|
||||
}
|
||||
|
||||
func (s *RcSuite) SetupTest() {
|
||||
s.tmpDir = s.T().TempDir()
|
||||
s.homeDir = filepath.Join(s.tmpDir, "home")
|
||||
s.reposDir = filepath.Join(s.homeDir, ".homesick", "repos")
|
||||
require.NoError(s.T(), os.MkdirAll(s.reposDir, 0o755))
|
||||
|
||||
s.stdout = &bytes.Buffer{}
|
||||
s.stderr = &bytes.Buffer{}
|
||||
s.app = &core.App{
|
||||
HomeDir: s.homeDir,
|
||||
ReposDir: s.reposDir,
|
||||
Stdout: s.stdout,
|
||||
Stderr: s.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RcSuite) createCastle(name string) string {
|
||||
castleRoot := filepath.Join(s.reposDir, name)
|
||||
require.NoError(s.T(), os.MkdirAll(castleRoot, 0o755))
|
||||
return castleRoot
|
||||
}
|
||||
|
||||
var _ io.Writer
|
||||
|
||||
// TestRc_UnknownCastleReturnsError ensures Rc returns an error when the
|
||||
// castle directory does not exist.
|
||||
func (s *RcSuite) TestRc_UnknownCastleReturnsError() {
|
||||
err := s.app.Rc("nonexistent")
|
||||
require.Error(s.T(), err)
|
||||
}
|
||||
|
||||
// TestRc_NoScriptsAndNoHomesickrc is a no-op when neither .homesick.d nor
|
||||
// .homesickrc are present.
|
||||
func (s *RcSuite) TestRc_NoScriptsAndNoHomesickrcIsNoop() {
|
||||
s.createCastle("dotfiles")
|
||||
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||
}
|
||||
|
||||
// TestRc_ExecutesScriptsInSortedOrder verifies that executable scripts inside
|
||||
// .homesick.d are run in lexicographic (sorted) order.
|
||||
func (s *RcSuite) TestRc_ExecutesScriptsInSortedOrder() {
|
||||
castleRoot := s.createCastle("dotfiles")
|
||||
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||
|
||||
orderFile := filepath.Join(s.tmpDir, "order.txt")
|
||||
scriptA := filepath.Join(homesickD, "10_a.sh")
|
||||
scriptB := filepath.Join(homesickD, "20_b.sh")
|
||||
|
||||
require.NoError(s.T(), os.WriteFile(scriptA, []byte("#!/bin/sh\necho a >> "+orderFile+"\n"), 0o755))
|
||||
require.NoError(s.T(), os.WriteFile(scriptB, []byte("#!/bin/sh\necho b >> "+orderFile+"\n"), 0o755))
|
||||
|
||||
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||
|
||||
content, err := os.ReadFile(orderFile)
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "a\nb\n", string(content))
|
||||
}
|
||||
|
||||
// TestRc_SkipsNonExecutableFiles ensures that files without the executable bit
|
||||
// are not run.
|
||||
func (s *RcSuite) TestRc_SkipsNonExecutableFiles() {
|
||||
castleRoot := s.createCastle("dotfiles")
|
||||
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||
|
||||
notExec := filepath.Join(homesickD, "10_script.sh")
|
||||
// Write a script that would exit 1 if actually run — verify it is skipped.
|
||||
require.NoError(s.T(), os.WriteFile(notExec, []byte("#!/bin/sh\nexit 1\n"), 0o644))
|
||||
|
||||
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||
}
|
||||
|
||||
// TestRc_HomesickrcCreatesRubyWrapper verifies that a .homesickrc file causes
|
||||
// a Ruby wrapper to be written into .homesick.d before execution.
|
||||
func (s *RcSuite) TestRc_HomesickrcCreatesRubyWrapper() {
|
||||
castleRoot := s.createCastle("dotfiles")
|
||||
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||
|
||||
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||
|
||||
wrapperPath := filepath.Join(castleRoot, ".homesick.d", "00_homesickrc.rb")
|
||||
require.FileExists(s.T(), wrapperPath)
|
||||
|
||||
info, err := os.Stat(wrapperPath)
|
||||
require.NoError(s.T(), err)
|
||||
require.NotZero(s.T(), info.Mode()&0o111, "wrapper must be executable")
|
||||
|
||||
content, err := os.ReadFile(wrapperPath)
|
||||
require.NoError(s.T(), err)
|
||||
require.Contains(s.T(), string(content), ".homesickrc")
|
||||
}
|
||||
|
||||
// TestRc_HomesickrcWrapperRunsBeforeOtherScripts ensures the wrapper file
|
||||
// (00_homesickrc.rb) sorts before typical user scripts and is present in
|
||||
// .homesick.d after Rc returns.
|
||||
func (s *RcSuite) TestRc_HomesickrcWrapperCreatedBeforeExecution() {
|
||||
castleRoot := s.createCastle("dotfiles")
|
||||
homesickRc := filepath.Join(castleRoot, ".homesickrc")
|
||||
require.NoError(s.T(), os.WriteFile(homesickRc, []byte("# ruby setup code\n"), 0o644))
|
||||
|
||||
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||
|
||||
// A sentinel script that records whether the wrapper already exists.
|
||||
orderFile := filepath.Join(s.tmpDir, "check.txt")
|
||||
sentinel := filepath.Join(homesickD, "50_check.sh")
|
||||
wrapperPath := filepath.Join(homesickD, "00_homesickrc.rb")
|
||||
require.NoError(s.T(), os.WriteFile(sentinel, []byte(
|
||||
"#!/bin/sh\n[ -f "+wrapperPath+" ] && echo present >> "+orderFile+"\n",
|
||||
), 0o755))
|
||||
|
||||
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||
|
||||
content, err := os.ReadFile(orderFile)
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "present\n", string(content))
|
||||
}
|
||||
|
||||
// TestRc_FailingScriptReturnsError ensures that a non-zero exit from a script
|
||||
// propagates as an error.
|
||||
func (s *RcSuite) TestRc_FailingScriptReturnsError() {
|
||||
castleRoot := s.createCastle("dotfiles")
|
||||
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||
|
||||
failing := filepath.Join(homesickD, "10_fail.sh")
|
||||
require.NoError(s.T(), os.WriteFile(failing, []byte("#!/bin/sh\nexit 42\n"), 0o755))
|
||||
|
||||
err := s.app.Rc("dotfiles")
|
||||
require.Error(s.T(), err)
|
||||
}
|
||||
|
||||
// TestRc_ScriptOutputForwarded verifies that stdout and stderr from scripts
|
||||
// are forwarded to the App's writers.
|
||||
func (s *RcSuite) TestRc_ScriptOutputForwarded() {
|
||||
castleRoot := s.createCastle("dotfiles")
|
||||
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||
|
||||
script := filepath.Join(homesickD, "10_output.sh")
|
||||
require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\necho hello\necho world >&2\n"), 0o755))
|
||||
|
||||
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||
require.Contains(s.T(), s.stdout.String(), "hello")
|
||||
require.Contains(s.T(), s.stderr.String(), "world")
|
||||
}
|
||||
|
||||
// TestRc_ScriptsRunWithCwdSetToCastleRoot verifies scripts execute with the
|
||||
// castle root as the working directory.
|
||||
func (s *RcSuite) TestRc_ScriptsRunWithCwdSetToCastleRoot() {
|
||||
castleRoot := s.createCastle("dotfiles")
|
||||
homesickD := filepath.Join(castleRoot, ".homesick.d")
|
||||
require.NoError(s.T(), os.MkdirAll(homesickD, 0o755))
|
||||
|
||||
script := filepath.Join(homesickD, "10_pwd.sh")
|
||||
require.NoError(s.T(), os.WriteFile(script, []byte("#!/bin/sh\npwd\n"), 0o755))
|
||||
|
||||
require.NoError(s.T(), s.app.Rc("dotfiles"))
|
||||
require.Contains(s.T(), s.stdout.String(), castleRoot)
|
||||
}
|
||||
190
internal/releaseprep/releaseprep.go
Normal file
190
internal/releaseprep/releaseprep.go
Normal file
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
122
internal/releaseprep/releaseprep_test.go
Normal file
122
internal/releaseprep/releaseprep_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package releaseprep_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.hrafn.xyz/aether/gosick/internal/releaseprep"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type PrepareSuite struct {
|
||||
suite.Suite
|
||||
rootDir string
|
||||
}
|
||||
|
||||
func TestPrepareSuite(t *testing.T) {
|
||||
suite.Run(t, new(PrepareSuite))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) SetupTest() {
|
||||
s.rootDir = s.T().TempDir()
|
||||
versionDir := filepath.Join(s.rootDir, "internal", "homesick", "version")
|
||||
require.NoError(s.T(), os.MkdirAll(versionDir, 0o755))
|
||||
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(versionDir, "version.go"),
|
||||
[]byte("package version\n\nconst String = \"1.1.6\"\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n"),
|
||||
0o644,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
|
||||
err := releaseprep.Prepare(s.rootDir, "v1.1.7", "2026-03-20")
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, "internal", "homesick", "version", "version.go"))
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "package version\n\nconst String = \"1.1.7\"\n", string(versionBytes))
|
||||
|
||||
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", string(changelogBytes))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20")
|
||||
|
||||
require.ErrorContains(s.T(), err, "unreleased section")
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20")
|
||||
|
||||
require.ErrorContains(s.T(), err, "unreleased section is empty")
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMinorBumpWhenAddedEntriesExist() {
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v1.2.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v1.1.7", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.0.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir)
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.0.0", tag)
|
||||
}
|
||||
3
justfile
3
justfile
@@ -19,3 +19,6 @@ behavior:
|
||||
|
||||
behavior-verbose:
|
||||
./script/run-behavior-suite-docker.sh --verbose
|
||||
|
||||
prepare-release version:
|
||||
./script/prepare-release.sh "{{version}}"
|
||||
|
||||
12
script/prepare-release.sh
Executable file
12
script/prepare-release.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user