fix(versioning): properly handle prerelease version bumps

This commit is contained in:
Julian Tölle 2024-08-02 18:09:13 +02:00
parent 718364fe5c
commit 3b6aca0f9b
4 changed files with 443 additions and 55 deletions

View file

@ -64,12 +64,12 @@ func run(cmd *cobra.Command, args []string) error {
}) })
} }
changesets, tag, err := getChangesetsFromForge(ctx, f) changesets, latestTag, stableTag, err := getChangesetsFromForge(ctx, f)
if err != nil { if err != nil {
return fmt.Errorf("failed to get changesets: %w", err) return fmt.Errorf("failed to get changesets: %w", err)
} }
err = reconcileReleasePR(ctx, f, changesets, tag) err = reconcileReleasePR(ctx, f, changesets, latestTag, stableTag)
if err != nil { if err != nil {
return fmt.Errorf("failed to reconcile release pr: %w", err) return fmt.Errorf("failed to reconcile release pr: %w", err)
} }
@ -77,36 +77,39 @@ func run(cmd *cobra.Command, args []string) error {
return nil return nil
} }
func getChangesetsFromForge(ctx context.Context, forge rp.Forge) ([]rp.Changeset, *rp.Tag, error) { func getChangesetsFromForge(ctx context.Context, forge rp.Forge) (changesets []rp.Changeset, latestTag *rp.Tag, stableTag *rp.Tag, err error) {
tag, err := forge.LatestTag(ctx) latestTag, stableTag, err = forge.LatestTags(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
if tag != nil { if latestTag != nil {
logger.InfoContext(ctx, "found previous tag", "tag.hash", tag.Hash, "tag.name", tag.Name) logger.InfoContext(ctx, "found latest tag", "tag.hash", latestTag.Hash, "tag.name", latestTag.Name)
} else { } else {
logger.InfoContext(ctx, "no previous tag found") logger.InfoContext(ctx, "no latest tag found")
}
if latestTag.Hash != stableTag.Hash {
logger.InfoContext(ctx, "found stable tag", "tag.hash", stableTag.Hash, "tag.name", stableTag.Name)
} }
releasableCommits, err := forge.CommitsSince(ctx, tag) releasableCommits, err := forge.CommitsSince(ctx, stableTag)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
logger.InfoContext(ctx, "Found releasable commits", "length", len(releasableCommits)) logger.InfoContext(ctx, "Found releasable commits", "length", len(releasableCommits))
changesets, err := forge.Changesets(ctx, releasableCommits) changesets, err = forge.Changesets(ctx, releasableCommits)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, nil, err
} }
logger.InfoContext(ctx, "Found changesets", "length", len(changesets)) logger.InfoContext(ctx, "Found changesets", "length", len(changesets))
return changesets, tag, nil return changesets, latestTag, stableTag, nil
} }
func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Changeset, tag *rp.Tag) error { func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Changeset, latestTag *rp.Tag, stableTag *rp.Tag) error {
rpBranch := fmt.Sprintf(RELEASER_PLEASER_BRANCH, flagBranch) rpBranch := fmt.Sprintf(RELEASER_PLEASER_BRANCH, flagBranch)
rpBranchRef := plumbing.NewBranchReferenceName(rpBranch) rpBranchRef := plumbing.NewBranchReferenceName(rpBranch)
// Check Forge for open PR // Check Forge for open PR
@ -131,7 +134,8 @@ func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Cha
} }
} }
nextVersion, err := rp.NextVersion(tag, changesets, releaseOverrides.NextVersionType) versionBump := rp.VersionBumpFromChangesets(changesets)
nextVersion, err := rp.NextVersion(latestTag, stableTag, versionBump, releaseOverrides.NextVersionType)
if err != nil { if err != nil {
return err return err
} }

View file

@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"github.com/blang/semver/v4"
"github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/google/go-github/v63/github" "github.com/google/go-github/v63/github"
@ -32,8 +33,9 @@ type Forge interface {
GitAuth() transport.AuthMethod GitAuth() transport.AuthMethod
// LatestTag returns the last tag created on the main branch. If no tag is found, it returns nil. // LatestTags returns the last stable tag created on the main branch. If there is a more recent pre-release tag,
LatestTag(context.Context) (*Tag, error) // that is also returned. If no tag is found, it returns nil.
LatestTags(context.Context) (stable *Tag, prerelease *Tag, err error)
// CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this // CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this
// function should return all commits. // function should return all commits.
@ -82,24 +84,56 @@ func (g *GitHub) GitAuth() transport.AuthMethod {
} }
} }
func (g *GitHub) LatestTag(ctx context.Context) (*Tag, error) { func (g *GitHub) LatestTags(ctx context.Context) (latest *Tag, stable *Tag, err error) {
g.log.Debug("listing all tags in github repository") g.log.DebugContext(ctx, "listing all tags in github repository")
// We only get the first page because the latest tag is returned as the first item
tags, _, err := g.client.Repositories.ListTags(ctx, g.options.Owner, g.options.Repo, nil) page := 1
if err != nil {
return nil, err for {
tags, resp, err := g.client.Repositories.ListTags(
ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{Page: page, PerPage: GitHubPerPageMax},
)
if err != nil {
return nil, nil, err
}
for _, ghTag := range tags {
tag := &Tag{
Hash: ghTag.GetCommit().GetSHA(),
Name: ghTag.GetName(),
}
version, err := semver.Parse(tag.Name)
if err != nil {
g.log.WarnContext(
ctx, "unable to parse tag as semver, skipping",
"tag.name", tag.Name,
"tag.hash", tag.Hash,
"error", err,
)
continue
}
if latest == nil {
latest = tag
}
if len(version.Pre) == 0 {
// Stable version tag
// We return once we have found the latest stable tag, not needed to look at every single tag.
return latest, tag, nil
}
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
} }
if len(tags) > 0 { return nil, nil, nil
// TODO: Is tags sorted?
tag := tags[0]
return &Tag{
Hash: tag.GetCommit().GetSHA(),
Name: tag.GetName(),
}, nil
}
return nil, nil
} }
func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) { func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) {

View file

@ -8,56 +8,68 @@ import (
"github.com/leodido/go-conventionalcommits" "github.com/leodido/go-conventionalcommits"
) )
func NextVersion(currentTag *Tag, changesets []Changeset, nextVersionType NextVersionType) (string, error) { func NextVersion(latestTag *Tag, stableTag *Tag, versionBump conventionalcommits.VersionBump, nextVersionType NextVersionType) (string, error) {
// TODO: Validate for versioning after pre-releases // TODO: Validate for versioning after pre-releases
currentVersion := "v0.0.0" latestVersion := "v0.0.0"
if currentTag != nil { if latestTag != nil {
currentVersion = currentTag.Name latestVersion = latestTag.Name
}
stableVersion := "v0.0.0"
if stableTag != nil {
stableVersion = stableTag.Name
} }
// The lib can not handle v prefixes // The lib can not handle v prefixes
currentVersion = strings.TrimPrefix(currentVersion, "v") latestVersion = strings.TrimPrefix(latestVersion, "v")
stableVersion = strings.TrimPrefix(stableVersion, "v")
version, err := semver.Parse(currentVersion) latest, err := semver.Parse(latestVersion)
if err != nil { if err != nil {
return "", err return "", err
} }
versionBump := maxVersionBump(changesets) stable, err := semver.Parse(stableVersion)
if err != nil {
return "", err
}
next := stable // Copy all fields
switch versionBump { switch versionBump {
case conventionalcommits.UnknownVersion: case conventionalcommits.UnknownVersion:
// No new version, TODO: Throw error? return "", fmt.Errorf("invalid latest bump (unknown)")
case conventionalcommits.PatchVersion: case conventionalcommits.PatchVersion:
err = version.IncrementPatch() err = next.IncrementPatch()
case conventionalcommits.MinorVersion: case conventionalcommits.MinorVersion:
err = version.IncrementMinor() err = next.IncrementMinor()
case conventionalcommits.MajorVersion: case conventionalcommits.MajorVersion:
err = version.IncrementMajor() err = next.IncrementMajor()
}
if err != nil {
return "", err
} }
switch nextVersionType { switch nextVersionType {
case NextVersionTypeUndefined, NextVersionTypeNormal:
next.Pre = make([]semver.PRVersion, 0)
case NextVersionTypeAlpha, NextVersionTypeBeta, NextVersionTypeRC: case NextVersionTypeAlpha, NextVersionTypeBeta, NextVersionTypeRC:
id := uint64(0) id := uint64(0)
if version.Pre[0].String() == nextVersionType.String() { if len(latest.Pre) >= 2 && latest.Pre[0].String() == nextVersionType.String() {
if version.Pre[1].String() == "" || !version.Pre[1].IsNumeric() { if latest.Pre[1].String() == "" || !latest.Pre[1].IsNumeric() {
return "", fmt.Errorf("invalid format of previous tag") return "", fmt.Errorf("invalid format of previous tag")
} }
id = version.Pre[1].VersionNum + 1 id = latest.Pre[1].VersionNum + 1
} }
setPRVersion(&version, nextVersionType.String(), id) setPRVersion(&next, nextVersionType.String(), id)
case NextVersionTypeUndefined, NextVersionTypeNormal:
version.Pre = make([]semver.PRVersion, 0)
} }
return "v" + version.String(), nil if err != nil {
return "", err
}
return "v" + next.String(), nil
} }
func maxVersionBump(changesets []Changeset) conventionalcommits.VersionBump { func VersionBumpFromChangesets(changesets []Changeset) conventionalcommits.VersionBump {
bump := conventionalcommits.UnknownVersion bump := conventionalcommits.UnknownVersion
for _, changeset := range changesets { for _, changeset := range changesets {

338
versioning_test.go Normal file
View file

@ -0,0 +1,338 @@
package rp
import (
"fmt"
"testing"
"github.com/leodido/go-conventionalcommits"
"github.com/stretchr/testify/assert"
)
func TestNextVersion(t *testing.T) {
type args struct {
latestTag *Tag
stableTag *Tag
versionBump conventionalcommits.VersionBump
nextVersionType NextVersionType
}
tests := []struct {
name string
args args
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "simple bump (major)",
args: args{
latestTag: &Tag{Name: "v1.1.1"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v2.0.0",
wantErr: assert.NoError,
},
{
name: "simple bump (minor)",
args: args{
latestTag: &Tag{Name: "v1.1.1"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.2.0",
wantErr: assert.NoError,
},
{
name: "simple bump (patch)",
args: args{
latestTag: &Tag{Name: "v1.1.1"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.1.2",
wantErr: assert.NoError,
},
{
name: "normal to prerelease (major)",
args: args{
latestTag: &Tag{Name: "v1.1.1"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v2.0.0-rc.0",
wantErr: assert.NoError,
},
{
name: "normal to prerelease (minor)",
args: args{
latestTag: &Tag{Name: "v1.1.1"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.2.0-rc.0",
wantErr: assert.NoError,
},
{
name: "normal to prerelease (patch)",
args: args{
latestTag: &Tag{Name: "v1.1.1"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.1.2-rc.0",
wantErr: assert.NoError,
},
{
name: "prerelease bump (major)",
args: args{
latestTag: &Tag{Name: "v2.0.0-rc.0"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v2.0.0-rc.1",
wantErr: assert.NoError,
},
{
name: "prerelease bump (minor)",
args: args{
latestTag: &Tag{Name: "v1.2.0-rc.0"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.2.0-rc.1",
wantErr: assert.NoError,
},
{
name: "prerelease bump (patch)",
args: args{
latestTag: &Tag{Name: "v1.1.2-rc.0"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.1.2-rc.1",
wantErr: assert.NoError,
},
{
name: "prerelease different bump (major)",
args: args{
latestTag: &Tag{Name: "v1.2.0-rc.0"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v2.0.0-rc.1",
wantErr: assert.NoError,
},
{
name: "prerelease different bump (minor)",
args: args{
latestTag: &Tag{Name: "v1.1.2-rc.0"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.2.0-rc.1",
wantErr: assert.NoError,
},
{
name: "prerelease to prerelease",
args: args{
latestTag: &Tag{Name: "v1.1.1-alpha.2"},
stableTag: &Tag{Name: "v1.1.0"},
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.1.1-rc.0",
wantErr: assert.NoError,
},
{
name: "prerelease to normal (explicit)",
args: args{
latestTag: &Tag{Name: "v1.1.1-alpha.2"},
stableTag: &Tag{Name: "v1.1.0"},
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeNormal,
},
want: "v1.1.1",
wantErr: assert.NoError,
},
{
name: "prerelease to normal (implicit)",
args: args{
latestTag: &Tag{Name: "v1.1.1-alpha.2"},
stableTag: &Tag{Name: "v1.1.0"},
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.1.1",
wantErr: assert.NoError,
},
{
name: "nil tag (major)",
args: args{
latestTag: nil,
stableTag: nil,
versionBump: conventionalcommits.MajorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.0.0",
wantErr: assert.NoError,
},
{
name: "nil tag (minor)",
args: args{
latestTag: nil,
stableTag: nil,
versionBump: conventionalcommits.MinorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v0.1.0",
wantErr: assert.NoError,
},
{
name: "nil tag (patch)",
args: args{
latestTag: nil,
stableTag: nil,
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v0.0.1",
wantErr: assert.NoError,
},
{
name: "error on invalid tag semver",
args: args{
latestTag: &Tag{Name: "foodazzle"},
stableTag: &Tag{Name: "foodazzle"},
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "",
wantErr: assert.Error,
},
{
name: "error on invalid tag prerelease",
args: args{
latestTag: &Tag{Name: "v1.1.1-rc.foo"},
stableTag: &Tag{Name: "v1.1.1-rc.foo"},
versionBump: conventionalcommits.PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "",
wantErr: assert.Error,
},
{
name: "error on invalid bump",
args: args{
latestTag: &Tag{Name: "v1.1.1"},
stableTag: &Tag{Name: "v1.1.1"},
versionBump: conventionalcommits.UnknownVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "",
wantErr: assert.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NextVersion(tt.args.latestTag, tt.args.stableTag, tt.args.versionBump, tt.args.nextVersionType)
if !tt.wantErr(t, err, fmt.Sprintf("NextVersion(%v, %v, %v, %v)", tt.args.latestTag, tt.args.stableTag, tt.args.versionBump, tt.args.nextVersionType)) {
return
}
assert.Equalf(t, tt.want, got, "NextVersion(%v, %v, %v, %v)", tt.args.latestTag, tt.args.stableTag, tt.args.versionBump, tt.args.nextVersionType)
})
}
}
func TestVersionBumpFromChangesets(t *testing.T) {
tests := []struct {
name string
changesets []Changeset
want conventionalcommits.VersionBump
}{
{
name: "no entries (unknown)",
changesets: []Changeset{},
want: conventionalcommits.UnknownVersion,
},
{
name: "non-release type (unknown)",
changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "docs"}}}},
want: conventionalcommits.UnknownVersion,
},
{
name: "single breaking (major)",
changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{BreakingChange: true}}}},
want: conventionalcommits.MajorVersion,
},
{
name: "single feat (minor)",
changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "feat"}}}},
want: conventionalcommits.MinorVersion,
},
{
name: "single fix (patch)",
changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}}},
want: conventionalcommits.PatchVersion,
},
{
name: "multiple changesets (major)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}},
{ChangelogEntries: []AnalyzedCommit{{BreakingChange: true}}},
},
want: conventionalcommits.MajorVersion,
},
{
name: "multiple changesets (minor)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}},
{ChangelogEntries: []AnalyzedCommit{{Type: "feat"}}},
},
want: conventionalcommits.MinorVersion,
},
{
name: "multiple changesets (patch)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "docs"}}},
{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}}},
},
want: conventionalcommits.PatchVersion,
},
{
name: "multiple entries in one changeset (major)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}}},
},
want: conventionalcommits.MajorVersion,
},
{
name: "multiple entries in one changeset (minor)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}}},
},
want: conventionalcommits.MinorVersion,
},
{
name: "multiple entries in one changeset (patch)",
changesets: []Changeset{
{ChangelogEntries: []AnalyzedCommit{{Type: "docs"}, {Type: "fix"}}},
},
want: conventionalcommits.PatchVersion,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, VersionBumpFromChangesets(tt.changesets), "VersionBumpFromChangesets(%v)", tt.changesets)
})
}
}