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
|
- name: Run behavior suite on main pushes
|
||||||
if: ${{ github.ref == 'refs/heads/main' }}
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
run: ./script/run-behavior-suite-docker.sh
|
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: true
|
||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Install UPX
|
||||||
|
uses: crazy-max/ghaction-upx@v3
|
||||||
|
with:
|
||||||
|
install-only: true
|
||||||
|
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
output="dist/gosick_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||||
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \
|
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
|
- name: Package artifact
|
||||||
run: |
|
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/),
|
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).
|
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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Native Go implementations for `clone`, `link`, `unlink`, and `track`.
|
- 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.
|
- 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.
|
- Coverage reports and badges published to shared object storage for branches and pull requests.
|
||||||
- Pull requests now receive coverage report links in CI comments.
|
- 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
|
### 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:
|
behavior-verbose:
|
||||||
./script/run-behavior-suite-docker.sh --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