diff --git a/.gitignore b/.gitignore index 0b75e89ee7..4afdfea2e7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,6 @@ minikube.kubeconfig # Example and binary output directory out -releasenotes # vscode .vscode diff --git a/Makefile b/Makefile index 850b3f4624..e5ee1b6907 100644 --- a/Makefile +++ b/Makefile @@ -584,7 +584,7 @@ release-manifests: $(KUSTOMIZE) $(RELEASE_DIR) ## Builds the manifests to publis .PHONY: release-notes release-notes: $(RELEASE_NOTES_DIR) $(RELEASE_NOTES) - $(GO) run ./hack/tools/release/notes.go --from=$(PREVIOUS_TAG) > $(RELEASE_NOTES_DIR)/$(RELEASE_TAG).md + cd hack/tools && $(GO) run release/notes.go --releaseTag=$(RELEASE_TAG) > $(realpath $(RELEASE_NOTES_DIR))/$(RELEASE_TAG).md .PHONY: release release: diff --git a/docs/releasing.md b/docs/releasing.md index 702308dfe7..8e26b4add2 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -59,76 +59,67 @@ CAPM3 uses [semantic versioning](https://semver.org). ### Repository setup -Clone the repository: +- Clone the repository: `git clone git@github.com:metal3-io/cluster-api-provider-metal3` -or if using existing repository, verify your intended remote is set to -`metal3-io`: `git remote -v`. For this document, we assume it is `origin`. - -- If creating a new minor branch, identify the commit you wish to create the - branch from, and create a branch `release-1.x`: - `git checkout -b release-1.x` and push it to remote: - `git push origin release-1.x` to create it -- If creating a new patch release, use existing branch `release-1.x`: - `git checkout origin/release-1.x` - -### Tags - -First we create a primary release tag, that triggers release note creation and -image building processes. - -- Create a signed, annotated tag with: `git tag -s -a v1.x.y -m v1.x.y` -- Push the tags to the GitHub repository: `git push origin v1.x.y` - -This triggers two things: - -- GitHub action workflow for automated release process creates a draft release - in GitHub repository with correct content, comparing the pushed tag to - previous tag. Running actions are visible on the - [Actions](https://github.com/metal3-io/cluster-api-provider-metal3/actions) - page, and draft release will be visible on top of the - [Releases](https://github.com/metal3-io/cluster-api-provider-metal3/releases) - page. -- GH action `build-images-action` starts building release image with the release - tag in Jenkins, and it gets pushed to Quay. Make sure the release tag is - visible in - [Quay tags page](https://quay.io/repository/metal3-io/cluster-api-provider-metal3?tab=tags). - If the release tag build is not visible, check if the action has failed and - retrigger as necessary. - -We also need to create one or more tags for the Go modules ecosystem: - -- For any subdirectory with `go.mod` in it (excluding `hack/tools` and - `hack/fake-apiserver`), create another Git tag with directory prefix, ie. - `git tag api/v1.x.y` and `git tag test/v1.x.y`. This enables the - tags to be used as a Go module version for any downstream users. - - **NOTE**: Do not create annotated tags (`-a`, or implicitly via `-m` or `-s`) - for Go modules. Release notes expects only the main tag to be annotated, - otherwise it might create incorrect release notes. Push both of the tags to - `origin`. - -### Release notes - -Next step is to clean up the release note manually. Release note has been -generated by the `release` action, do not click the `Generate release notes` -button. In case there is issue with release action, you may rerun it via -`Actions` tab, or you can `make release-notes` to get a markdown file with -the release content to be inserted. - -- If release is not a beta or release candidate, check for duplicates, reverts, - and incorrect classifications of PRs, and whatever release creation tagged to - be manually checked. +or if using existing repository, make sure origin is set to the fork and +upstream is set to `metal3-io`. Verify if your remote is set properly or not +by using following command `git remote -v`. + +- Fetch the remote (`metal3-io`): `git fetch upstream` +This makes sure that all the tags are accessible. + +### Creating Release Notes + +- Switch to the main branch: `git checkout main` + +- Create a new branch for the release notes**: + `git checkout -b release-notes-1.x.x origin/main` + +- Generate the release notes: `RELEASE_TAG=v1.x.x make release-notes` + - Replace `v1.x.x` with the new release tag you're creating. + - This command generates the release notes here + `releasenotes/.md` . + +- Next step is to clean up the release note manually. + - If release is not a beta or release candidate, check for duplicates, + reverts, and incorrect classifications of PRs, and whatever release + creation tagged to be manually checked. - For any superseded PRs (like same dependency uplifted multiple times, or - commit revertions) that provide no value to the release, move them to + commit revertion) that provide no value to the release, move them to Superseded section. This way the changes are acknowledged to be part of the release, but not overwhelming the important changes contained by the release. -- If the release you're making is not a new major release, new minor release, - or a new patch release from the latest release branch, uncheck the box for - latest release. -- If it is a release candidate (RC) or a beta release, tick pre-release box. -- Save the release note as a draft, and have others review it. + +- Commit your changes, push the new branch and create a pull request: + - The commit and PR title should be 🚀 Release v1.x.y: + - `git commit -S -s -m ":rocket: Release v1.x.x"` + - `git push -u origin release-notes-1.x.x` + - Important! The commit should only contain the release notes file, nothing + else, otherwise automation will not work. + +- Ask maintainers and release team members to review your pull request. + +Once PR is merged following GitHub actions are triggered: + +- GitHub action `Create Release` runs following jobs: + - GitHub job `push_release_tags` will create and push the tags. This action + will also create release branch if its missing and release is `rc` or + minor. + - GitHub job `create draft release` creates draft release. Don't publish the + release until release tag is visible in. Running actions are visible on the + [Actions](https://github.com/metal3-io/cluster-api-provider-metal3/actions) + page, and draft release will be visible on top of the + [Releases](https://github.com/metal3-io/cluster-api-provider-metal3/releases). + If the release you're making is not a new major release, new minor release, + or a new patch release from the latest release branch, uncheck the box for + latest release. If it is a release candidate (RC) or a beta release, + tick pre-release box. + - GitHub job `build_CAPM3` builds release image with the release tag, + and pushes it to Quay. Make sure the release tag is visible in + [Quay tags page](https://quay.io/repository/metal3-io/cluster-api-provider-metal3?tab=tags). + If the release tag build is not visible, check if the action has failed and + retrigger as necessary. ### Release artifacts diff --git a/hack/tools/go.mod b/hack/tools/go.mod index 3f042f6d6d..f4b3804397 100644 --- a/hack/tools/go.mod +++ b/hack/tools/go.mod @@ -3,9 +3,13 @@ module github.com/metal3-io/cluster-api-provider-metal3/hack/tools go 1.22.8 require ( + github.com/blang/semver v3.5.1+incompatible github.com/drone/envsubst v1.0.3 github.com/golang/mock v1.6.0 + github.com/google/go-github v17.0.0+incompatible github.com/jteeuwen/go-bindata v3.0.7+incompatible + github.com/pkg/errors v0.9.1 + golang.org/x/oauth2 v0.21.0 k8s.io/code-generator v0.31.4 sigs.k8s.io/controller-tools v0.16.5 sigs.k8s.io/kustomize/kustomize/v5 v5.5.0 @@ -27,6 +31,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -39,7 +44,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/onsi/gomega v1.34.2 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/hack/tools/go.sum b/hack/tools/go.sum index a2fa6a0898..7492318dab 100644 --- a/hack/tools/go.sum +++ b/hack/tools/go.sum @@ -1,3 +1,5 @@ +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/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -39,9 +41,14 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/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= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -143,6 +150,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/hack/tools/release/notes.go b/hack/tools/release/notes.go index 3d689c829e..c93b72fc6b 100644 --- a/hack/tools/release/notes.go +++ b/hack/tools/release/notes.go @@ -20,12 +20,18 @@ limitations under the License. package main import ( - "bytes" + "context" "flag" "fmt" + "log" "os" "os/exec" "strings" + + "github.com/blang/semver" + "github.com/google/go-github/github" + "github.com/pkg/errors" + "golang.org/x/oauth2" ) /* @@ -36,17 +42,16 @@ 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" -) - -const ( - warningTemplate = ":rotating_light: This is a %s. Use it only for testing purposes. If you find any bugs, file an [issue](https://github.com/metal3-io/cluster-api-provider-metal3/issues/new/).\n\n" + 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" + repoOwner = "metal3-io" + repoName = "cluster-api-provider-metal3" + warningTemplate = ":rotating_light: This is a %s. Use it only for testing purposes.\nIf you find any bugs, file an [issue](https://github.com/metal3-io/cluster-api-provider-metal3/issues/new/).\n\n" ) var ( @@ -59,8 +64,7 @@ var ( unknown, superseded, } - - fromTag = flag.String("from", "", "The tag or commit to start from.") + toTag = flag.String("releaseTag", "", "The tag or commit to end to.") ) func main() { @@ -68,25 +72,42 @@ func main() { os.Exit(run()) } -func latestTag() string { - cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") - out, err := cmd.Output() - if err != nil { - return firstCommit() +func latestTag() (string, error) { + if toTag != nil && *toTag != "" { + return *toTag, nil } - return string(bytes.TrimSpace(out)) + return "", errors.New("RELEASE_TAG is not set") } -func lastTag() string { - if fromTag != nil && *fromTag != "" { - return *fromTag +// lastTag returns the tag to start collecting commits from based on the latestTag. +// For pre-releases and minor releases, it returns the latest minor release tag +// (e.g., for v1.9.0, v1.9.0-beta.0, or v1.9.0-rc.0, it returns v1.8.0). +// For patch releases, it returns the latest patch release tag (e.g., for v1.9.1 it returns v1.9.0). +func lastTag(latestTag string) (string, error) { + if isBeta(latestTag) || isRC(latestTag) || isMinor(latestTag) { + if index := strings.LastIndex(latestTag, "-"); index != -1 { + latestTag = latestTag[:index] + } + latestTag = strings.TrimPrefix(latestTag, "v") + + semVersion, err := semver.New(latestTag) + if err != nil { + return "", errors.Wrapf(err, "parsing semver for %s", latestTag) + } + semVersion.Minor-- + lastReleaseTag := fmt.Sprintf("v%s", semVersion.String()) + return lastReleaseTag, nil } - cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") - out, err := cmd.Output() + + latestTag = strings.TrimPrefix(latestTag, "v") + + semVersion, err := semver.New(latestTag) if err != nil { - return firstCommit() + return "", errors.Wrapf(err, "parsing semver for %s", latestTag) } - return string(bytes.TrimSpace(out)) + semVersion.Patch-- + lastReleaseTag := fmt.Sprintf("v%s", semVersion.String()) + return lastReleaseTag, nil } func isBeta(tag string) bool { @@ -97,19 +118,26 @@ func isRC(tag string) bool { return strings.Contains(tag, "-rc.") } -func firstCommit() string { - cmd := exec.Command("git", "rev-list", "--max-parents=0", "HEAD") - out, err := cmd.Output() - if err != nil { - return "UNKNOWN" - } - return string(bytes.TrimSpace(out)) +func isMinor(tag string) bool { + return strings.HasSuffix(tag, ".0") } func run() int { - lastTag := lastTag() - latestTag := latestTag() - cmd := exec.Command("git", "rev-list", lastTag+"..HEAD", "--merges", "--pretty=format:%B") // #nosec G204:gosec + latestTag, err := latestTag() + if err != nil { + log.Fatalf("Failed to get latestTag: %v", err) + } + lastTag, err := lastTag(latestTag) + if err != nil { + log.Fatalf("Failed to get lastTag: %v", err) + } + + commitHash, err := getCommitHashFromNewTag(latestTag) + if err != nil { + log.Fatalf("Failed to get commit hash from latestTag %s: %v", latestTag, err) + } + + cmd := exec.Command("git", "rev-list", lastTag+".."+commitHash, "--merges", "--pretty=format:%B") // #nosec G204:gosec merges := map[string][]string{ features: {}, @@ -173,6 +201,8 @@ func run() int { key = warning body = strings.TrimPrefix(body, ":warning:") body = strings.TrimPrefix(body, "⚠️") + case strings.HasPrefix(body, ":rocket:"), strings.HasPrefix(body, "🚀"): + continue default: key = unknown } @@ -182,7 +212,12 @@ func run() int { continue } body = fmt.Sprintf("- %s", body) - fmt.Sscanf(c.merge, "Merge pull request %s from %s", &prNumber, &fork) + + _, err := fmt.Sscanf(c.merge, "Merge pull request %s from %s", &prNumber, &fork) + if err != nil { + log.Fatalf("Error parsing merge commit message: %v", err) + } + merges[key] = append(merges[key], formatMerge(body, prNumber)) } @@ -191,14 +226,15 @@ func run() int { merges[superseded] = append(merges[superseded], "- ``") } + fmt.Println("") // TODO Turn this into a link (requires knowing the project name + organization) - fmt.Printf("Changes since %v\n---\n", lastTag) + fmt.Printf("# Changes since %v\n\n", lastTag) // print the changes by category for _, key := range outputOrder { mergeslice := merges[key] if len(mergeslice) > 0 { - fmt.Println("## " + key) + fmt.Printf("## %v\n\n", key) for _, merge := range mergeslice { fmt.Println(merge) } @@ -225,8 +261,7 @@ func run() int { fmt.Printf("\n\n") } - fmt.Printf("The image for this release is: %v\n", latestTag) - fmt.Printf("Mariadb image tag is capm3-%v\n", latestTag) + fmt.Printf("The container image for this release is: %v\n", latestTag) fmt.Println("\n_Thanks to all our contributors!_ 😊") return 0 @@ -243,3 +278,54 @@ func formatMerge(line, prNumber string) string { } return fmt.Sprintf("%s (%s)", line, prNumber) } + +// getCommitHashFromNewTag returns the latest commit hash for the specified tag. +// For minor and pre releases, it returns the main branch's latest commit. +// For patch releases, it returns the latest commit on the corresponding release branch. +func getCommitHashFromNewTag(newTag string) (string, error) { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + return "", errors.New("GITHUB_TOKEN is required") + } + + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + branch := "main" + if !isBeta(newTag) { + branch = getReleaseBranchFromTag(newTag) + // Check if branch exist in upstream or not + _, _, err := client.Repositories.GetBranch(ctx, repoOwner, repoName, branch) + if err != nil { + // If branch does not exist, defaults to main + branch = "main" + } + } + + ref, _, err := client.Git.GetRef(ctx, repoOwner, repoName, "refs/heads/"+branch) + if err != nil { + log.Fatalf("Error fetching ref: %v", err) + } + commitHash := ref.GetObject().GetSHA() + return commitHash, nil +} + +func trimPrereleasePrefix(version string) string { + if idx := strings.Index(version, "-"); idx != -1 { + return version[:idx] + } + return version +} + +func getReleaseBranchFromTag(tag string) string { + tag = strings.TrimPrefix(tag, "v") + tag = trimPrereleasePrefix(tag) + if index := strings.LastIndex(tag, "."); index != -1 { + tag = tag[:index] + } + return fmt.Sprintf("release-%s", tag) +}