14 Commits

Author SHA1 Message Date
Micheal Wilkinson
75f636f9ba test(rc): add failing tests for Rc command
Some checks failed
Push Validation / validate (push) Failing after 1m32s
2026-03-20 15:26:50 +00:00
Micheal Wilkinson
1e5de20a41 docs(changelog): document breaking change section convention 2026-03-20 15:06:33 +00:00
Micheal Wilkinson
07d73660eb docs(changelog): seed unreleased section with breaking heading 2026-03-20 15:05:20 +00:00
Micheal Wilkinson
029175cb55 docs(changelog): note breaking release section 2026-03-20 15:04:25 +00:00
Micheal Wilkinson
38f649e99b feat(release): support breaking changelog notes 2026-03-20 15:04:15 +00:00
Micheal Wilkinson
af491aa267 test(release): treat breaking notes as major bump 2026-03-20 15:03:30 +00:00
Micheal Wilkinson
0dd38e5267 docs(changelog): note release tag recommendation guard 2026-03-20 14:59:57 +00:00
Micheal Wilkinson
93918f3a39 feat(release): guard empty notes and recommend next tag 2026-03-20 14:59:46 +00:00
Micheal Wilkinson
3b8dadbd29 test(release): guard empty notes and suggest next tag 2026-03-20 14:58:31 +00:00
Micheal Wilkinson
f9c853a4e9 docs(changelog): note release preparation automation 2026-03-20 14:55:16 +00:00
Micheal Wilkinson
799c8d167d feat(release): automate release preparation 2026-03-20 14:54:57 +00:00
Micheal Wilkinson
feb8ca3434 test(release): use external package for release prep tests 2026-03-20 14:51:23 +00:00
Micheal Wilkinson
dbb6c82562 test(release): cover automated release preparation 2026-03-20 14:49:42 +00:00
Micheal Wilkinson
c3f809a586 chore(release): add UPX compression for linux artifacts 2026-03-20 14:46:54 +00:00
10 changed files with 690 additions and 1 deletions

View 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"

View File

@@ -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

View File

@@ -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: |

View File

@@ -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
View 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)
}
}

View 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)
}

View 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
}

View 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)
}

View File

@@ -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
View 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"