diff --git a/go.mod b/go.mod index d9f62bd5..46623161 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.22.4 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/Songmu/retry v0.1.0 + github.com/blang/semver v3.5.1+incompatible github.com/google/go-github/v56 v56.0.0 github.com/magefile/mage v1.15.0 github.com/mitchellh/go-homedir v1.1.0 diff --git a/go.sum b/go.sum index 393dd379..b7df65d7 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= diff --git a/pkg/github/github.go b/pkg/github/github.go index 4143c50d..2e70e3ea 100644 --- a/pkg/github/github.go +++ b/pkg/github/github.go @@ -24,18 +24,20 @@ import ( "github.com/Songmu/retry" "github.com/pkg/errors" + "helm.sh/helm/v3/pkg/chart" + "github.com/blang/semver" "github.com/google/go-github/v56/github" "golang.org/x/oauth2" ) type Release struct { - Name string - Description string - Assets []*Asset - Commit string - GenerateReleaseNotes bool - MakeLatest string + Name string + Description string + Assets []*Asset + Commit string + MakeLatest string + SemVer semver.Version } type Asset struct { @@ -102,15 +104,73 @@ func (c *Client) GetRelease(_ context.Context, tag string) (*Release, error) { return result, nil } +// GetLatestChartRelease queries the GitHub API for the previous release of a chart +func (c *Client) GetLatestChartRelease(_ context.Context, prefix string) (*Release, error) { + // Append hyphen to prefix unless already present + prefix = strings.TrimSuffix(prefix, "-") + "-" + + // Find all versions with tags matching prefix + opt := &github.ListOptions{ + PerPage: 100, + } + var versions []semver.Version + for { + rels, resp, err := c.Repositories.ListReleases(context.TODO(), c.owner, c.repo, opt) + if err != nil { + return nil, err + } else if len(rels) == 0 { + return nil, errors.New("no releases found") + } + for _, rel := range rels { + if strings.HasPrefix(*rel.TagName, prefix) { + version := semver.MustParse(strings.TrimPrefix(*rel.TagName, prefix)) + versions = append(versions, version) + } + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + // Sort versions ascending + semver.Sort(versions) + + // Find highest version + latestVersion := versions[len(versions)-1] + var release *github.RepositoryRelease + if rel, _, err := c.Repositories.GetReleaseByTag(context.TODO(), c.owner, c.repo, prefix+latestVersion.String()); err == nil { + release = rel + } + + result := &Release{ + Name: *release.TagName, + Commit: *release.TargetCommitish, + SemVer: latestVersion, + } + return result, nil +} + +// GenerateReleaseNotes generates the release notes for a release +func (c *Client) GenerateReleaseNotes(_ context.Context, latestRelease *Release, chart *chart.Chart) (string, error) { + notes, _, err := c.Repositories.GenerateReleaseNotes(context.TODO(), c.owner, c.repo, &github.GenerateNotesOptions{ + TagName: chart.Metadata.Name + "-" + chart.Metadata.Version, + PreviousTagName: &latestRelease.Name, + }) + if err != nil { + return "", err + } + return notes.Body, err +} + // CreateRelease creates a new release object in the GitHub API func (c *Client) CreateRelease(_ context.Context, input *Release) error { req := &github.RepositoryRelease{ - Name: &input.Name, - Body: &input.Description, - TagName: &input.Name, - TargetCommitish: &input.Commit, - GenerateReleaseNotes: &input.GenerateReleaseNotes, - MakeLatest: &input.MakeLatest, + Name: &input.Name, + Body: &input.Description, + TagName: &input.Name, + TargetCommitish: &input.Commit, + MakeLatest: &input.MakeLatest, } release, _, err := c.Repositories.CreateRelease(context.TODO(), c.owner, c.repo, req) diff --git a/pkg/releaser/releaser.go b/pkg/releaser/releaser.go index 439ae818..f0cc531b 100644 --- a/pkg/releaser/releaser.go +++ b/pkg/releaser/releaser.go @@ -28,6 +28,7 @@ import ( "time" "github.com/Songmu/retry" + "github.com/blang/semver" "text/template" @@ -50,6 +51,8 @@ type GitHub interface { CreateRelease(ctx context.Context, input *github.Release) error GetRelease(ctx context.Context, tag string) (*github.Release, error) CreatePullRequest(owner string, repo string, message string, head string, base string) (string, error) + GetLatestChartRelease(ctx context.Context, prefix string) (*github.Release, error) + GenerateReleaseNotes(ctx context.Context, latestRelease *github.Release, chart *chart.Chart) (string, error) } type Git interface { @@ -238,16 +241,52 @@ func (r *Releaser) computeReleaseName(chart *chart.Chart) (string, error) { return releaseName, nil } -func (r *Releaser) getReleaseNotes(chart *chart.Chart) string { +func (r *Releaser) getReleaseNotes(chart *chart.Chart) (string, error) { if r.config.ReleaseNotesFile != "" { for _, f := range chart.Files { if f.Name == r.config.ReleaseNotesFile { - return string(f.Data) + return string(f.Data), nil } } fmt.Printf("The release note file %q, is not present in the chart package\n", r.config.ReleaseNotesFile) } - return chart.Metadata.Description + if r.config.GenerateReleaseNotes { + latestRelease, err := r.github.GetLatestChartRelease(context.TODO(), chart.Metadata.Name) + if err != nil { + return "", errors.Wrapf(err, "failed to get latest release for chart %s", chart.Metadata.Name) + } + nextVersion := semver.MustParse(chart.Metadata.Version) + versions := []semver.Version{nextVersion, latestRelease.SemVer} + semver.Sort(versions) + highest := versions[len(versions)-1] + // skip generating notes if there's already a higher version in GitHub + if nextVersion.String() == highest.String() { + notes, err := r.github.GenerateReleaseNotes(context.TODO(), latestRelease, chart) + if err != nil { + return "", errors.Wrapf(err, "failed to generate release notes for chart %s", chart.Metadata.Name) + } + return notes, nil + } + } + if r.config.GenerateReleaseNotes { + latestRelease, err := r.github.GetLatestChartRelease(context.TODO(), chart.Metadata.Name) + if err != nil { + return "", errors.Wrapf(err, "failed to get latest release for chart %s", chart.Metadata.Name) + } + nextVersion := semver.MustParse(chart.Metadata.Version) + versions := []semver.Version{nextVersion, latestRelease.SemVer} + semver.Sort(versions) + highest := versions[len(versions)-1] + // skip generating notes if there's already a higher version in GitHub + if nextVersion.String() == highest.String() { + notes, err := r.github.GenerateReleaseNotes(context.TODO(), latestRelease, chart) + if err != nil { + return "", errors.Wrapf(err, "failed to generate release notes for chart %s", chart.Metadata.Name) + } + return notes, nil + } + } + return chart.Metadata.Description, nil } func (r *Releaser) splitPackageNameAndVersion(pkg string) []string { @@ -307,16 +346,19 @@ func (r *Releaser) CreateReleases() error { if err != nil { return err } + notes, err := r.getReleaseNotes(ch) + if err != nil { + return err + } release := &github.Release{ Name: releaseName, - Description: r.getReleaseNotes(ch), + Description: notes, Assets: []*github.Asset{ {Path: p}, }, - Commit: r.config.Commit, - GenerateReleaseNotes: r.config.GenerateReleaseNotes, - MakeLatest: strconv.FormatBool(r.config.MakeReleaseLatest), + Commit: r.config.Commit, + MakeLatest: strconv.FormatBool(r.config.MakeReleaseLatest), } provFile := fmt.Sprintf("%s.prov", p) if _, err := os.Stat(provFile); err == nil { diff --git a/pkg/releaser/releaser_test.go b/pkg/releaser/releaser_test.go index 55c7841f..b7682df6 100644 --- a/pkg/releaser/releaser_test.go +++ b/pkg/releaser/releaser_test.go @@ -21,6 +21,7 @@ import ( "path/filepath" "testing" + "github.com/blang/semver" "github.com/helm/chart-releaser/pkg/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -28,6 +29,7 @@ import ( "helm.sh/helm/v3/pkg/repo" "github.com/helm/chart-releaser/pkg/config" + "helm.sh/helm/v3/pkg/chart" ) type FakeGitHub struct { @@ -115,6 +117,31 @@ func (f *FakeGitHub) CreatePullRequest(owner string, repo string, message string return "https://github.com/owner/repo/pull/42", nil } +// GetLatestChartRelease queries the GitHub API for the previous release of a chart +func (f *FakeGitHub) GetLatestChartRelease(_ context.Context, prefix string) (*github.Release, error) { + f.Called(prefix) + + result := &github.Release{ + Name: prefix + "-1.2.3", + Commit: "c11eea26f51782a8063ded1085384acb2928fd91", + SemVer: semver.Version{ + Major: 1, + Minor: 2, + Patch: 3, + }, + } + return result, nil +} + +// GenerateReleaseNotes generates the release notes for a release +func (f *FakeGitHub) GenerateReleaseNotes(_ context.Context, latestRelease *github.Release, chart *chart.Chart) (string, error) { + f.Called(latestRelease, chart) + + notes := "# Noted." + + return notes, nil +} + func TestReleaser_UpdateIndexFile(t *testing.T) { indexDir, _ := os.MkdirTemp(".", "index") defer os.RemoveAll(indexDir)