diff --git a/Makefile b/Makefile index dea7187b21b9..05e38c173d26 100644 --- a/Makefile +++ b/Makefile @@ -1166,6 +1166,10 @@ release-notes: release-notes-tool test-release-notes-tool: go test -C hack/tools -v -tags tools,integration sigs.k8s.io/cluster-api/hack/tools/release/notes +.PHONY: release-provider-issues-tool +release-provider-issues-tool: # Creates GitHub issues in a pre-defined list of CAPI provider repositories + @go run ./hack/tools/release/internal/update_providers/provider_issues.go + .PHONY: release-weekly-update-tool release-weekly-update-tool: go build -C hack/tools -o $(ROOT_DIR)/bin/weekly -tags tools sigs.k8s.io/cluster-api/hack/tools/release/weekly diff --git a/docs/release/release-tasks.md b/docs/release/release-tasks.md index 62d9a8ac6d4d..59c852ba60bc 100644 --- a/docs/release/release-tasks.md +++ b/docs/release/release-tasks.md @@ -464,7 +464,9 @@ We should inform at least the following providers via a new issue on their respe * Packet: https://github.com/kubernetes-sigs/cluster-api-provider-packet/issues/new * vSphere: https://github.com/kubernetes-sigs/cluster-api-provider-vsphere/issues/new -TODO: Right now we don't have a template for this message but the Comms Team will provide one later. +To create GitHub issues at the Cluster API providers repositories and inform about a new minor beta release, use ["provider_issues.go"](../../hack/tools/release/internal/update_providers/provider_issues.go) go utility. +- Ensure that the [provider repos pre-requisites](../../hack/tools/release/internal/update_providers/README.md#pre-requisites) are completed. +- From the root of this repository, run `make release-provider-issues-tool` to create git issues at the provider repositories. ## CI Signal/Bug Triage/Automation Manager @@ -543,4 +545,4 @@ The goal of bug triage is to triage incoming issues and if necessary flag them w and add them to the milestone of the current release. We probably have to figure out some details about the overlap between the bug triage task here, release leads -and Cluster API maintainers. \ No newline at end of file +and Cluster API maintainers. diff --git a/hack/tools/release/internal/update_providers/README.md b/hack/tools/release/internal/update_providers/README.md new file mode 100644 index 000000000000..80b52fdb646f --- /dev/null +++ b/hack/tools/release/internal/update_providers/README.md @@ -0,0 +1,38 @@ +# Update CAPI Providers with CAPI beta releases + +`provider_issues` is a go utility intended to open git issues on provider repos about the first beta minor release of CAPI. + +## Pre-requisites + +- Create a github token with the below access and export it in your environment as `GITHUB_ISSUE_OPENER_TOKEN`. Set the validity to the least number of days since this utility is a one time use tool per release cycle. + - `repo:status` - Grants access to commit status on public and private repositories. + - `repo_deployment` - Grants access to deployment statuses on public and private repositories. + - `public_repo` - Grants access to public repositories + +- Export `PROVIDER_ISSUES_DRY_RUN` environment variable to `"true"` to run the utility in dry run mode. Export it to `"false"` to create issues on the provider repositories. Example: + + ```sh + export PROVIDER_ISSUES_DRY_RUN="true" + ``` + +- Export `RELEASE_TAG` environment variable to the CAPI release version e.g. `1.6.0`. The suffix `-beta.0` is appended by the utility. + Example: + + ```sh + export RELEASE_TAG="1.6.0" + ``` + +- Export `RELEASE_DATE` to the targeted CAPI release version date. Fetch the target date from latest [release file](https://github.com/kubernetes-sigs/cluster-api/tree/main/docs/release/releases). + Example: + + ```sh + export RELEASE_DATE="2023-11-28" + ``` + +## How to run the tool + +- Finish the pre-requites [tasks](#pre-requisites). +- From the root of the project Cluster API, run `make release-provider-issues-tool` to create issues at provider repositories. + - Note that the utility will + - do a dry run on setting `PROVIDER_ISSUES_DRY_RUN="true"` (and will not create git issues) + - create git issues on setting `PROVIDER_ISSUES_DRY_RUN="false"`. diff --git a/hack/tools/release/internal/update_providers/provider_issues.go b/hack/tools/release/internal/update_providers/provider_issues.go new file mode 100644 index 000000000000..c76f6e91fe83 --- /dev/null +++ b/hack/tools/release/internal/update_providers/provider_issues.go @@ -0,0 +1,352 @@ +//go:build tools +// +build tools + +/* +Copyright 2023 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. +*/ + +// main is the main package for the open issues utility. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "text/template" + "time" +) + +const ( + baseURL = "https://api.github.com" +) + +var ( + repoList = []string{ + "kubernetes-sigs/cluster-api-addon-provider-helm", + "kubernetes-sigs/cluster-api-provider-aws", + "kubernetes-sigs/cluster-api-provider-azure", + "kubernetes-sigs/cluster-api-provider-cloudstack", + "kubernetes-sigs/cluster-api-provider-digitalocean", + "kubernetes-sigs/cluster-api-provider-gcp", + "kubernetes-sigs/cluster-api-provider-kubemark", + "kubernetes-sigs/cluster-api-provider-kubevirt", + "kubernetes-sigs/cluster-api-provider-ibmcloud", + "kubernetes-sigs/cluster-api-provider-nested", + "oracle/cluster-api-provider-oci", + "kubernetes-sigs/cluster-api-provider-openstack", + "kubernetes-sigs/cluster-api-operator", + "kubernetes-sigs/cluster-api-provider-packet", + "kubernetes-sigs/cluster-api-provider-vsphere", + "metal3-io/cluster-api-provider-metal3", + } +) + +// Issue is the struct for the issue. +type Issue struct { + Title string `json:"title"` + Body string `json:"body"` +} + +// IssueResponse is the struct for the issue response. +type IssueResponse struct { + HTMLURL string `json:"html_url"` +} + +// releaseDetails is the struct for the release details. +type releaseDetails struct { + ReleaseTag string + BetaTag string + ReleaseLink string + ReleaseDate string +} + +// Example command: +// +// GITHUB_ISSUE_OPENER_TOKEN="fake" RELEASE_TAG="1.6.0" RELEASE_DATE="2023-11-28" PROVIDER_ISSUES_DRY_RUN="true" make release-provider-issues-tool +func main() { + githubToken, keySet := os.LookupEnv("GITHUB_ISSUE_OPENER_TOKEN") + if !keySet || githubToken == "" { + fmt.Println("GitHub personal access token is required.") + fmt.Println("Refer to README.md in folder for more information.") + os.Exit(1) + } + + // always start in dry run mode unless explicitly set to false + var dryRun bool + isDryRun, dryRunSet := os.LookupEnv("PROVIDER_ISSUES_DRY_RUN") + if !dryRunSet || isDryRun == "" || isDryRun != "false" { + fmt.Printf("\n") + fmt.Println("###############################################") + fmt.Println("This script will run in dry run mode.") + fmt.Println("To run it for real, set the PROVIDER_ISSUES_DRY_RUN environment variable to \"false\".") + fmt.Println("###############################################") + fmt.Printf("\n") + dryRun = true + } else { + dryRun = false + } + + fmt.Println("List of CAPI Providers:") + fmt.Println("-", strings.Join(repoList, "\n- ")) + fmt.Printf("\n") + + details := getReleaseDetails() + + // generate title + titleBuffer := bytes.NewBuffer([]byte{}) + if err := getIssueTitle().Execute(titleBuffer, details); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + + // generate issue body + issueBuffer := bytes.NewBuffer([]byte{}) + if err := getIssueBody().Execute(issueBuffer, details); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + + fmt.Println("Issue Title:") + fmt.Println(titleBuffer.String()) + fmt.Printf("\n") + + fmt.Println("Issue body:") + fmt.Println(issueBuffer.String()) + + // if dry run, exit + if dryRun { + fmt.Printf("\n") + fmt.Println("DRY RUN: issue(s) body will not be posted.") + fmt.Println("Exiting...") + fmt.Printf("\n") + os.Exit(0) + } + + // else, ask for confirmation + fmt.Printf("\n") + fmt.Println("Issues will be posted to the provider repositories.") + continueOrAbort() + fmt.Printf("\n") + + var issuesCreated, issuedFailed []string + for _, repo := range repoList { + issue := Issue{ + Title: titleBuffer.String(), + Body: issueBuffer.String(), + } + + issueJSON, err := json.Marshal(issue) + if err != nil { + fmt.Printf("Failed to marshal issue: %s\n", err) + os.Exit(1) + } + + url := fmt.Sprintf("%s/repos/%s/issues", baseURL, repo) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewBuffer(issueJSON)) + if err != nil { + fmt.Printf("Failed to create request: %s\n", err) + os.Exit(1) + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", githubToken)) + req.Header.Set("X-Github-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "provider_issues") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Failed to send request: %s\n", err) + os.Exit(1) + } + + if resp.StatusCode != http.StatusCreated { + fmt.Println("Failed to create issue for the repository:", repo, "Status:", resp.Status) + issuedFailed = append(issuedFailed, repo) + } else { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf("Failed to read response body: %s\n", err) + err := resp.Body.Close() + if err != nil { + fmt.Printf("Failed to close response body: %s\n", err) + } + os.Exit(1) + } + + var issueResponse IssueResponse + err = json.Unmarshal(responseBody, &issueResponse) + if err != nil { + fmt.Printf("Failed to unmarshal issue response: %s\n", err) + err := resp.Body.Close() + if err != nil { + fmt.Printf("Failed to close response body: %s\n", err) + } + os.Exit(1) + } + + fmt.Println("Issue created for repository:", repo) + issuesCreated = append(issuesCreated, issueResponse.HTMLURL) + } + err = resp.Body.Close() + if err != nil { + fmt.Printf("Failed to close response body: %s\n", err) + } + } + + if len(issuesCreated) > 0 || len(issuedFailed) > 0 { + fmt.Printf("\n") + fmt.Println("###############################################") + fmt.Println("Summary:") + fmt.Println("###############################################") + } + + if len(issuesCreated) > 0 { + fmt.Printf("\n") + fmt.Println("List of issues created:") + fmt.Println("-", strings.Join(issuesCreated, "\n- ")) + } + + if len(issuedFailed) > 0 { + fmt.Printf("\n") + fmt.Println("List of issues failed to create:") + fmt.Println("-", strings.Join(issuedFailed, "\n- ")) + } +} + +// continueOrAbort asks the user to continue or abort the script. +func continueOrAbort() { + fmt.Println("Continue? (y/n)") + var response string + _, err := fmt.Scanln(&response) + if err != nil { + fmt.Printf("Failed to read response: %s\n", err) + os.Exit(1) + } + if response != "y" { + fmt.Println("Aborting...") + os.Exit(0) + } +} + +// getReleaseDetails returns the release details from the environment variables. +func getReleaseDetails() releaseDetails { + releaseSemVer, keySet := os.LookupEnv("RELEASE_TAG") + if !keySet || releaseSemVer == "" { + fmt.Println("RELEASE_TAG is a required environmental variable.") + fmt.Println("Refer to README.md in folder for more information.") + os.Exit(1) + } + + match, err := regexp.Match("\\d\\.\\d\\.\\d", []byte(releaseSemVer)) + if err != nil || !match { + fmt.Println("RELEASE_TAG must be in format `\\d\\.\\d\\.\\d` e.g. 1.5") + os.Exit(1) + } + + releaseDate, keySet := os.LookupEnv("RELEASE_DATE") + if !keySet || releaseDate == "" { + fmt.Println("RELEASE_DATE is a required environmental variable.") + fmt.Println("Refer to README.md in folder for more information.") + os.Exit(1) + } + + formattedReleaseDate, err := formatDate(releaseDate) + if err != nil { + fmt.Println("Unable to parse the date.", err) + fmt.Println("Refer to README.md in folder for more information.") + } + + releaseTag := fmt.Sprintf("v%s", releaseSemVer) + betaTag := fmt.Sprintf("v%s%s", releaseSemVer, "-beta.0") + releaseLink := fmt.Sprintf("https://github.com/kubernetes-sigs/cluster-api/tree/main/docs/release/releases/release-%s.md#timeline", releaseSemVer) + + return releaseDetails{ + ReleaseDate: formattedReleaseDate, + ReleaseTag: releaseTag, + BetaTag: betaTag, + ReleaseLink: releaseLink, + } +} + +// formatDate takes a date in ISO format i.e "2006-01-02" and returns it in the format "Monday 2nd January 2006". +func formatDate(inputDate string) (string, error) { + layoutISO := "2006-01-02" + parsedDate, err := time.Parse(layoutISO, inputDate) + if err != nil { + return "", err + } + + // Get the day suffix + day := parsedDate.Day() + suffix := "th" + if day%10 == 1 && day != 11 { + suffix = "st" + } else if day%10 == 2 && day != 12 { + suffix = "nd" + } else if day%10 == 3 && day != 13 { + suffix = "rd" + } + + weekDay := parsedDate.Weekday().String() + month := parsedDate.Month().String() + formattedDate := fmt.Sprintf("%s, %d%s %s %d", weekDay, day, suffix, month, parsedDate.Year()) + + return formattedDate, nil +} + +// getIssueBody returns the issue body template. +func getIssueBody() *template.Template { + // do not indent the body + // indenting the body will result in the body being posted as a code snippet + issueBody, err := template.New("issue").Parse(`CAPI {{.BetaTag}} has been released and is ready for testing. +Looking forward to your feedback before {{.ReleaseTag}} release! + +## For quick reference + + +- [CAPI {{.BetaTag}} release notes](https://github.com/kubernetes-sigs/cluster-api/releases/tag/{{.BetaTag}}) +- [Shortcut to CAPI git issues](https://github.com/kubernetes-sigs/cluster-api/issues) + +## Following are the planned dates for the upcoming releases + +CAPI {{.ReleaseTag}} will be released on **{{.ReleaseDate}}**. + +More details of the upcoming schedule can be seen at [CAPI {{.ReleaseTag}} release timeline]({{.ReleaseLink}}). + + + +`) + if err != nil { + panic(err) + } + return issueBody +} + +// getIssueTitle returns the issue title template. +func getIssueTitle() *template.Template { + issueTitle, err := template.New("title").Parse(`CAPI {{.BetaTag}} has been released and is ready for testing`) + if err != nil { + panic(err) + } + return issueTitle +}