diff --git a/hack/release/go.mod b/hack/release/go.mod new file mode 100644 index 00000000..bbf5c455 --- /dev/null +++ b/hack/release/go.mod @@ -0,0 +1,11 @@ +module github.com/metal3-io/project-infra/hack/release + +go 1.23.2 + +require ( + github.com/blang/semver v3.5.1+incompatible + github.com/google/go-github v17.0.0+incompatible + golang.org/x/oauth2 v0.25.0 +) + +require github.com/google/go-querystring v1.1.0 // indirect diff --git a/hack/release/go.sum b/hack/release/go.sum new file mode 100644 index 00000000..b8b7b7dd --- /dev/null +++ b/hack/release/go.sum @@ -0,0 +1,12 @@ +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/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/hack/release/main.go b/hack/release/main.go new file mode 100644 index 00000000..54dbace8 --- /dev/null +++ b/hack/release/main.go @@ -0,0 +1,230 @@ +//go:build tools +// +build tools + +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/blang/semver" + "github.com/google/go-github/github" + "golang.org/x/oauth2" +) + +/* +This tool prints all the titles of all PRs from previous release to HEAD. +This needs to be run *before* a tag is created. + +Use these as the base of your release notes. +*/ + +const ( + features = ":sparkles: New Features" + bugs = ":bug: Bug Fixes" + documentation = ":book: Documentation" + warning = ":warning: Breaking Changes" + other = ":seedling: Others" + unknown = ":question: Sort these by hand" + superseded = ":recycle: Superseded or Reverted" + warningTemplate = ":rotating_light: This is a %s. Use it only for testing purposes.\nIf you find any bugs, file an [issue](https://github.com/%s/%s/issues/new/).\n\n" +) + +var ( + outputOrder = []string{ + warning, + features, + bugs, + documentation, + other, + unknown, + superseded, + } + releaseTag string + repoOwner string + repoName string + semVersion semver.Version + lastReleaseTag string +) + +func main() { + releaseTag = os.Getenv("RELEASE_TAG") + if releaseTag == "" { + log.Fatal("RELEASE_TAG is required") + } + repoOwner = os.Getenv("REPO_OWNER") + if repoOwner == "" { + log.Fatal("REPO_OWNER is required") + } + repoName = os.Getenv("REPO_NAME") + if repoName == "" { + log.Fatal("REPO_NAME is required") + } + + // Create a context + ctx := context.Background() + + // Authenticate with GitHub token if available + token := os.Getenv("GITHUB_TOKEN") + var client *github.Client + if token != "" { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + client = github.NewClient(tc) + } else { + client = github.NewClient(nil) + } + releaseName := strings.TrimPrefix(releaseTag, "v") + var err error + semVersion, err = semver.Make(releaseName) + if err != nil { + log.Fatalf("Incorrect releaseTag: %v", err) + } + + // Get the name of the release branch. Default to "main" if it's a minor release + releaseBranch := fmt.Sprintf("release-%d.%d", semVersion.Major, semVersion.Minor) + if semVersion.Patch == 0 { + releaseBranch = "main" + } + + // Get the release tag used for comparison + lastVersion := semVersion + if lastVersion.Patch == 0 { + lastVersion.Minor-- + } else { + lastVersion.Patch-- + } + lastReleaseTag = fmt.Sprintf("v%d.%d.%d", lastVersion.Major, lastVersion.Minor, lastVersion.Patch) + + // Compare commits between the tag and the release branch + comparison, _, err := client.Repositories.CompareCommits(ctx, repoOwner, repoName, lastReleaseTag, releaseBranch) + if err != nil { + log.Fatalf("failed to compare commits: %v", err) + } + merges := map[string][]string{ + features: {}, + bugs: {}, + documentation: {}, + warning: {}, + other: {}, + unknown: {}, + superseded: {}, + } + + for _, commit := range comparison.Commits { + // Skip merge commits (those with more than one parent) + if len(commit.Parents) > 1 { + continue + } + // Search for PRs associated with the commit + for { + query := fmt.Sprintf("%s repo:%s/%s type:pr", commit.GetSHA(), repoOwner, repoName) + searchResults, resp, err := client.Search.Issues(ctx, query, nil) + if err != nil { + // Handle rate limit + if resp != nil && resp.StatusCode == 403 && resp.Rate.Remaining == 0 { + resetTime := time.Until(resp.Rate.Reset.Time) + time.Sleep(resetTime + time.Second) // Add an extra second for safety + continue // Retry the same query after the reset + } + log.Fatalf("Failed to search for PR: %v", err) + } + + // Format the result + var prNumber int + if len(searchResults.Issues) > 0 { + prNumber = searchResults.Issues[0].GetNumber() + } + + // Append commit message and PR number + body := strings.Split(commit.GetCommit().GetMessage(), "\n")[0] // Take the first line of the commit message + if body == "" { + continue + } + var key string + switch { + case strings.HasPrefix(body, ":sparkles:"), strings.HasPrefix(body, "✨"): + key = features + body = strings.TrimPrefix(body, ":sparkles:") + body = strings.TrimPrefix(body, "✨") + case strings.HasPrefix(body, ":bug:"), strings.HasPrefix(body, "🐛"): + key = bugs + body = strings.TrimPrefix(body, ":bug:") + body = strings.TrimPrefix(body, "🐛") + case strings.HasPrefix(body, ":book:"), strings.HasPrefix(body, "📖"): + key = documentation + body = strings.TrimPrefix(body, ":book:") + body = strings.TrimPrefix(body, "📖") + case strings.HasPrefix(body, ":seedling:"), strings.HasPrefix(body, "🌱"): + key = other + body = strings.TrimPrefix(body, ":seedling:") + body = strings.TrimPrefix(body, "🌱") + case strings.HasPrefix(body, ":warning:"), strings.HasPrefix(body, "⚠️"): + key = warning + body = strings.TrimPrefix(body, ":warning:") + body = strings.TrimPrefix(body, "⚠️") + case strings.HasPrefix(body, ":rocket:"), strings.HasPrefix(body, "🚀"): + continue + default: + key = unknown + } + merges[key] = append(merges[key], fmt.Sprintf("- %s (#%d)", body, prNumber)) + break + } + } + fmt.Println("") + // if we're doing beta/rc, print breaking changes and hide the rest of the changes + if len(semVersion.Pre) > 0 { + switch semVersion.Pre[0].VersionStr { + case "beta": + fmt.Printf(warningTemplate, "BETA RELEASE", repoOwner, repoName) + case "rc": + fmt.Printf(warningTemplate, "RELEASE CANDIDATE", repoOwner, repoName) + } + fmt.Printf("
\n") + fmt.Printf("More details about the release\n\n") + } + fmt.Printf("# Changes since [%s](https://github.com/%s/%s/tree/%s)\n\n", lastReleaseTag, repoOwner, repoName, lastReleaseTag) + // print the changes by category + for _, key := range outputOrder { + mergeslice := merges[key] + if len(mergeslice) > 0 { + fmt.Printf("## %v\n\n", key) + for _, merge := range mergeslice { + fmt.Println(merge) + } + fmt.Println() + } + } + + // close the details tag if we had it open + if len(semVersion.Pre) > 0 { + fmt.Printf("
\n\n") + } + + fmt.Printf("The container image for this release is: %s\n", releaseTag) + if repoName == "cluster-api-provider-metal3" { + fmt.Printf("Mariadb image tag is: capm3-%s\n", releaseTag) + } + fmt.Println("\n_Thanks to all our contributors!_ 😊") +}