diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 3dda43b0..be6329b2 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -6,6 +6,7 @@ on: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GO_VERSION: "1.20" jobs: validate: @@ -25,6 +26,8 @@ jobs: - uses: actions/setup-go@v4 with: go-version-file: "./go.mod" + go-version: "${{ env.GO_VERSION }}" + cache: true - name: ${{ matrix.target }} run: make ${{ matrix.target }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7d5774db..ce61cb0a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,7 +7,11 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - GO_VERSION: '1.20' + GO_VERSION: "1.20" + +permissions: + packages: write + contents: write jobs: image: @@ -15,7 +19,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - target: [bootsrap, controlplane] + target: [bootstrap, controlplane] steps: - name: Checkout code @@ -46,7 +50,7 @@ jobs: uses: docker/metadata-action@v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} - images: ${{ env.REGISTRY }}/${{ matrix.target }}-${{ env.IMAGE_NAME }} + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{ matrix.target }} flavor: latest=false tags: type=ref,event=tag @@ -71,6 +75,7 @@ jobs: push: true build-args: | LDFLAGS=${{ env.DOCKER_BUILD_LDFLAGS }} + package=./${{ matrix.target }}/main.go tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64,linux/arm64 @@ -93,7 +98,13 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: ./.github/actions/setup-go + - name: Set release + run: echo "RELEASE_TAG=${GITHUB_REF:10}" >> $GITHUB_ENV + + - uses: actions/setup-go@v4 + with: + go-version: "${{ env.GO_VERSION }}" + cache: true - uses: actions/cache@v3 with: @@ -108,25 +119,17 @@ jobs: uses: docker/metadata-action@v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-controlplane + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-bootstrap flavor: latest=false tags: type=ref,event=tag - name: manifest - run: make release-${{ matrix.target }} - env: - TAG: ${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} + run: make release - - name: Generate Release Notes - run: | - release_notes=$(gh api repos/{owner}/{repo}/releases/generate-notes -F tag_name=${{ github.ref }} --jq .body) - echo 'RELEASE_NOTES<> $GITHUB_ENV - echo "${release_notes}" >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OWNER: ${{ github.repository_owner }} - REPO: ${{ github.event.repository.name }} + - name: manifest + run: make release-notes - name: Create Release id: create_release @@ -134,7 +137,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - files: out/release/* - body: ${{ env.RELEASE_NOTES }} - draft: false - prerelease: false \ No newline at end of file + files: out/* + body_path: _releasenotes/${{ env.RELEASE_TAG }}.md + draft: true + prerelease: false diff --git a/.gitignore b/.gitignore index fb2298aa..2d1dd766 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,6 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out -# Dependency directories (remove the comment below to include it) -# vendor/ - - bin/ -out/ \ No newline at end of file +out/ +_releasenotes diff --git a/Dockerfile b/Dockerfile index 6443ed30..9e415379 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,22 @@ -# Build the manager binary -# Run this with docker build --build-arg builder_image= -ARG builder_image +# Copyright 2019 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. -# Build architecture -ARG ARCH +# Build the manager binary +FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.20.7 as build +ARG TARGETOS TARGETARCH +ARG package -# Ignore Hadolint rule "Always tag the version of an image explicitly." -# It's an invalid finding since the image is explicitly set in the Makefile. -# https://github.com/hadolint/hadolint/wiki/DL3006 -# hadolint ignore=DL3006 -FROM ${builder_image} as builder WORKDIR /workspace # Run this with docker build --build-arg goproxy=$(go env GOPROXY) to override the goproxy @@ -34,22 +41,15 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ go build ./bootstrap/main.go -# Build -ARG package=. -ARG ARCH -ARG ldflags - -# Do not force rebuild of up-to-date packages (do not use -a) and use the compiler cache folder -RUN --mount=type=cache,target=/root/.cache/go-build \ - --mount=type=cache,target=/go/pkg/mod \ - CGO_ENABLED=0 GOOS=linux GOARCH=${ARCH} \ - go build -trimpath -ldflags "${ldflags} -extldflags '-static'" \ +RUN --mount=type=cache,target=/root/.cache \ + --mount=type=cache,target=/go/pkg \ + GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 \ + go build -ldflags "${LDFLAGS} -extldflags '-static'" \ -o manager ${package} -# Production image -FROM gcr.io/distroless/static:nonroot-${ARCH} +FROM --platform=${BUILDPLATFORM} gcr.io/distroless/static:nonroot WORKDIR / -COPY --from=builder /workspace/manager . +COPY --from=build /workspace/manager . # Use uid of nonroot user (65532) because kubernetes expects numeric user when applying pod security policies USER 65532 -ENTRYPOINT ["/manager"] +ENTRYPOINT ["/manager"] \ No newline at end of file diff --git a/Makefile b/Makefile index 5f6a0aa6..67e36aef 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ SHELL:=/usr/bin/env bash .DEFAULT_GOAL:=help -GO_VERSION ?= 1.20.6 +GO_VERSION ?= 1.20.7 GO_CONTAINER_IMAGE ?= docker.io/library/golang:$(GO_VERSION) ARCH ?= $(shell go env GOARCH) @@ -90,6 +90,32 @@ KUSTOMIZE_VER := v4.0.4 KUSTOMIZE_BIN := kustomize KUSTOMIZE := $(TOOLS_BIN_DIR)/$(KUSTOMIZE_BIN)-$(KUSTOMIZE_VER) +## -------------------------------------- +## Release +## -------------------------------------- + +##@ release: + +## latest git tag for the commit, e.g., v0.3.10 +RELEASE_TAG ?= $(shell git describe --abbrev=0 2>/dev/null) +ifneq (,$(findstring -,$(RELEASE_TAG))) + PRE_RELEASE=true +endif +# the previous release tag, e.g., v0.3.9, excluding pre-release tags +PREVIOUS_TAG ?= $(shell git tag -l | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$$" | sort -V | grep -B1 $(RELEASE_TAG) | head -n 1 2>/dev/null) +## set by Prow, ref name of the base branch, e.g., main +RELEASE_ALIAS_TAG := $(PULL_BASE_REF) +RELEASE_DIR := out +RELEASE_NOTES_DIR := _releasenotes + +.PHONY: $(RELEASE_DIR) +$(RELEASE_DIR): + mkdir -p $(RELEASE_DIR)/ + +.PHONY: $(RELEASE_NOTES_DIR) +$(RELEASE_NOTES_DIR): + mkdir -p $(RELEASE_NOTES_DIR)/ + all-bootstrap: manager-bootstrap @@ -122,10 +148,9 @@ deploy-bootstrap: manifests-bootstrap manifests-bootstrap: $(KUSTOMIZE) $(CONTROLLER_GEN) $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=bootstrap/config/crd/bases output:rbac:dir=bootstrap/config/rbac -release-bootstrap: manifests-bootstrap ## Release bootstrap - mkdir -p out +release-bootstrap:$(RELEASE_DIR) manifests-bootstrap ## Release bootstrap cd bootstrap/config/manager && $(KUSTOMIZE) edit set image controller=${BOOTSTRAP_IMG} - $(KUSTOMIZE) build bootstrap/config/default > out/bootstrap-components.yaml + $(KUSTOMIZE) build bootstrap/config/default > $(RELEASE_DIR)/bootstrap-components.yaml # Generate code generate-bootstrap: $(CONTROLLER_GEN) @@ -133,7 +158,7 @@ generate-bootstrap: $(CONTROLLER_GEN) # Build the docker image docker-build-bootstrap: manager-bootstrap ## Build bootstrap - DOCKER_BUILDKIT=1 docker build --build-arg builder_image=$(GO_CONTAINER_IMAGE) --build-arg goproxy=$(GOPROXY) --build-arg ARCH=$(ARCH) --build-arg package=./bootstrap/main.go --build-arg ldflags="$(LDFLAGS)" . -t ${BOOTSTRAP_IMG} + DOCKER_BUILDKIT=1 docker build --build-arg builder_image=$(GO_CONTAINER_IMAGE) --build-arg goproxy=$(GOPROXY) --build-arg TARGETARCH=$(ARCH) --build-arg package=./bootstrap/main.go --build-arg ldflags="$(LDFLAGS)" . -t ${BOOTSTRAP_IMG} # Push the docker image docker-push-bootstrap: ## Push bootstrap @@ -170,21 +195,29 @@ deploy-controlplane: manifests-controlplane manifests-controlplane: $(KUSTOMIZE) $(CONTROLLER_GEN) $(CONTROLLER_GEN) rbac:roleName=manager-role webhook crd paths="./..." output:crd:artifacts:config=controlplane/config/crd/bases output:rbac:dir=controlplane/config/rbac -release-controlplane: manifests-controlplane ## Release control-plane - mkdir -p out +release-controlplane: $(RELEASE_DIR) manifests-controlplane ## Release control-plane cd controlplane/config/manager && $(KUSTOMIZE) edit set image controller=${CONTROLPLANE_IMG} - $(KUSTOMIZE) build controlplane/config/default > out/control-plane-components.yaml + $(KUSTOMIZE) build controlplane/config/default > $(RELEASE_DIR)/control-plane-components.yaml generate-controlplane: $(CONTROLLER_GEN) $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="$(shell pwd)/controlplane/..." docker-build-controlplane: manager-controlplane ## Build control-plane - DOCKER_BUILDKIT=1 docker build --build-arg builder_image=$(GO_CONTAINER_IMAGE) --build-arg goproxy=$(GOPROXY) --build-arg ARCH=$(ARCH) --build-arg package=./controlplane/main.go --build-arg ldflags="$(LDFLAGS)" . -t ${CONTROLPLANE_IMG} + DOCKER_BUILDKIT=1 docker build --build-arg builder_image=$(GO_CONTAINER_IMAGE) --build-arg goproxy=$(GOPROXY) --build-arg TARGETARCH=$(ARCH) --build-arg package=./controlplane/main.go --build-arg ldflags="$(LDFLAGS)" . -t ${CONTROLPLANE_IMG} docker-push-controlplane: ## Push control-plane docker push ${CONTROLPLANE_IMG} release: release-bootstrap release-controlplane + +.PHONY: release-notes +release-notes: $(RELEASE_NOTES_DIR) $(RELEASE_NOTES) + if [ -n "${PRE_RELEASE}" ]; then \ + echo ":rotating_light: This is a RELEASE CANDIDATE. Use it only for testing purposes. If you find any bugs, file an [issue](https://github.com/kubernetes-sigs/cluster-api/issues/new)." > $(RELEASE_NOTES_DIR)/$(RELEASE_TAG).md; \ + else \ + go run ./hack/tools/release/notes.go --from=$(PREVIOUS_TAG) > $(RELEASE_NOTES_DIR)/$(RELEASE_TAG).md; \ + fi + ## -------------------------------------- ## Help ## -------------------------------------- diff --git a/hack/tools/release/notes.go b/hack/tools/release/notes.go new file mode 100644 index 00000000..47857121 --- /dev/null +++ b/hack/tools/release/notes.go @@ -0,0 +1,519 @@ +//go:build tools +// +build tools + +/* +Copyright 2019 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 release notes generator. +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +/* +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" + proposals = ":memo: Proposals" + warning = ":warning: Breaking Changes" + other = ":seedling: Others" + unknown = ":question: Sort these by hand" +) + +var ( + outputOrder = []string{ + proposals, + warning, + features, + bugs, + other, + documentation, + unknown, + } + + repo = flag.String("repository", "kubernetes-sigs/cluster-api", "The repo to run the tool from.") + + fromTag = flag.String("from", "", "The tag or commit to start from.") + + since = flag.String("since", "", "Include commits starting from and including this date. Accepts format: YYYY-MM-DD") + until = flag.String("until", "", "Include commits up to and including this date. Accepts format: YYYY-MM-DD") + numWorkers = flag.Int("workers", 10, "Number of concurrent routines to process PR entries. If running into GitHub rate limiting, use 1.") + + prefixAreaLabel = flag.Bool("prefix-area-label", true, "If enabled, will prefix the area label.") + + addKubernetesVersionSupport = flag.Bool("add-kubernetes-version-support", true, "If enabled, will add the Kubernetes version support header.") + + tagRegex = regexp.MustCompile(`^\[release-[\w-\.]*\]`) + + userFriendlyAreas = map[string]string{ + "e2e-testing": "e2e", + "provider/control-plane-kubeadm": "KCP", + "provider/infrastructure-docker": "CAPD", + "dependency": "Dependency", + "devtools": "Devtools", + "machine": "Machine", + "api": "API", + "machinepool": "MachinePool", + "clustercachetracker": "ClusterCacheTracker", + "clusterclass": "ClusterClass", + "testing": "Testing", + "release": "Release", + "machineset": "MachineSet", + "clusterresourceset": "ClusterResourceSet", + "machinedeployment": "MachineDeployment", + "ipam": "IPAM", + "provider/bootstrap-kubeadm": "CAPBK", + "provider/infrastructure-in-memory": "CAPIM", + "provider/core": "Core", + "runtime-sdk": "Runtime SDK", + "ci": "CI", + } + + releaseBackportMarker = regexp.MustCompile(`(?m)^\[release-\d\.\d\]\s*`) +) + +func main() { + flag.Parse() + os.Exit(run()) +} + +func lastTag() string { + if fromTag != nil && *fromTag != "" { + return *fromTag + } + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") + out, err := cmd.Output() + if err != nil { + return firstCommit() + } + return string(bytes.TrimSpace(out)) +} + +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)) +} + +// Since git doesn't include the last day in rev-list we want to increase 1 day to include it in the interval. +func increaseDateByOneDay(date string) (string, error) { + layout := "2006-01-02" + datetime, err := time.Parse(layout, date) + if err != nil { + return "", err + } + datetime = datetime.Add(time.Hour * 24) + return datetime.Format(layout), nil +} + +const ( + missingAreaLabelPrefix = "MISSING_AREA" + areaLabelPrefix = "area/" + multipleAreaLabelsPrefix = "MULTIPLE_AREAS[" + documentationAreaLabel = "documentation" +) + +type githubPullRequest struct { + Labels []githubLabel `json:"labels"` +} + +type githubLabel struct { + Name string `json:"name"` +} + +func getAreaLabel(merge string) (string, error) { + // Get pr id from merge commit + prID := strings.Replace(strings.TrimSpace(strings.Split(merge, " ")[3]), "#", "", -1) + + cmd := exec.Command("gh", "api", fmt.Sprintf("repos/%s/pulls/%s", *repo, prID)) //nolint:gosec + + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("%s: %v", string(out), err) + } + + pr := &githubPullRequest{} + if err := json.Unmarshal(out, pr); err != nil { + return "", err + } + + var areaLabels []string + for _, label := range pr.Labels { + if area, ok := trimAreaLabel(label.Name); ok { + if userFriendlyArea, ok := userFriendlyAreas[area]; ok { + area = userFriendlyArea + } + + areaLabels = append(areaLabels, area) + } + } + + switch len(areaLabels) { + case 0: + return missingAreaLabelPrefix, nil + case 1: + return areaLabels[0], nil + default: + return multipleAreaLabelsPrefix + strings.Join(areaLabels, "/") + "]", nil + } +} + +// trimAreaLabel removes the "area/" prefix from area labels and returns it. +// If the label is an area label, the second return value is true, otherwise false. +func trimAreaLabel(label string) (string, bool) { + trimmed := strings.TrimPrefix(label, areaLabelPrefix) + if len(trimmed) < len(label) { + return trimmed, true + } + + return label, false +} + +func run() int { + if err := ensureInstalledDependencies(); err != nil { + fmt.Println(err) + return 1 + } + + var commitRange string + var cmd *exec.Cmd + + if *since != "" && *until != "" { + commitRange = fmt.Sprintf("%s - %s", *since, *until) + + lastDay, err := increaseDateByOneDay(*until) + if err != nil { + fmt.Println(err) + return 1 + } + cmd = exec.Command("git", "rev-list", "HEAD", "--since=\""+*since+"\"", "--until=\""+lastDay+"\"", "--merges", "--pretty=format:%B") //nolint:gosec + } else if *since != "" || *until != "" { + fmt.Println("--since and --until are required together or both unset") + return 1 + } else { + commitRange = lastTag() + cmd = exec.Command("git", "rev-list", commitRange+"..HEAD", "--merges", "--pretty=format:%B") //nolint:gosec + } + + merges := map[string][]string{ + features: {}, + bugs: {}, + documentation: {}, + warning: {}, + other: {}, + unknown: {}, + } + out, err := cmd.CombinedOutput() + if err != nil { + fmt.Println("Error") + fmt.Println(string(out)) + return 1 + } + + commits := []*commit{} + outLines := strings.Split(string(out), "\n") + for _, line := range outLines { + line = strings.TrimSpace(line) + last := len(commits) - 1 + switch { + case strings.HasPrefix(line, "commit"): + commits = append(commits, &commit{}) + case strings.HasPrefix(line, "Merge"): + commits[last].merge = line + continue + case line == "": + default: + commits[last].body = line + } + } + + results := make(chan releaseNoteEntryResult) + commitCh := make(chan *commit) + var wg sync.WaitGroup + + wg.Add(*numWorkers) + for i := 0; i < *numWorkers; i++ { + go func() { + for commit := range commitCh { + processed := releaseNoteEntryResult{} + processed.prEntry, processed.err = generateReleaseNoteEntry(commit) + results <- processed + } + wg.Done() + }() + } + + go func() { + for _, c := range commits { + commitCh <- c + } + close(commitCh) + }() + + go func() { + wg.Wait() + close(results) + }() + + for result := range results { + if result.err != nil { + fmt.Println(result.err) + os.Exit(0) + } + + if result.prEntry.title == "" { + continue + } + + if result.prEntry.section == documentation { + merges[result.prEntry.section] = append(merges[result.prEntry.section], result.prEntry.prNumber) + } else { + merges[result.prEntry.section] = append(merges[result.prEntry.section], result.prEntry.title) + } + } + + if *addKubernetesVersionSupport { + // TODO Turn this into a link (requires knowing the project name + organization) + fmt.Print(`## 👌 Kubernetes version support + +- Management Cluster: v1.**X**.x -> v1.**X**.x +- Workload Cluster: v1.**X**.x -> v1.**X**.x + +[More information about version support can be found here](https://cluster-api.sigs.k8s.io/reference/versions.html) + +`) + } + + fmt.Printf("## Changes since %v\n---\n", commitRange) + + fmt.Printf("## :chart_with_upwards_trend: Overview\n") + if count := len(commits); count == 1 { + fmt.Println("- 1 new commit merged") + } else if count > 1 { + fmt.Printf("- %d new commits merged\n", count) + } + if count := len(merges[warning]); count == 1 { + fmt.Println("- 1 breaking change :warning:") + } else if count > 1 { + fmt.Printf("- %d breaking changes :warning:\n", count) + } + if count := len(merges[features]); count == 1 { + fmt.Println("- 1 feature addition ✨") + } else if count > 1 { + fmt.Printf("- %d feature additions ✨\n", count) + } + if count := len(merges[bugs]); count == 1 { + fmt.Println("- 1 bug fixed 🐛") + } else if count > 1 { + fmt.Printf("- %d bugs fixed 🐛\n", count) + } + fmt.Println() + + for _, key := range outputOrder { + mergeslice := merges[key] + if len(mergeslice) == 0 { + continue + } + + switch key { + case documentation: + if len(mergeslice) == 1 { + fmt.Printf( + ":book: Additionally, there has been 1 contribution to our documentation and book. (%s) \n\n", + mergeslice[0], + ) + } else { + fmt.Printf( + ":book: Additionally, there have been %d contributions to our documentation and book. (%s) \n\n", + len(mergeslice), + strings.Join(mergeslice, ", "), + ) + } + default: + fmt.Println("## " + key) + sort.Slice(mergeslice, func(i int, j int) bool { + str1 := strings.ToLower(mergeslice[i]) + str2 := strings.ToLower(mergeslice[j]) + return str1 < str2 + }) + + for _, merge := range mergeslice { + fmt.Println(merge) + } + fmt.Println() + } + } + + fmt.Println("") + fmt.Println("_Thanks to all our contributors!_ 😊") + + return 0 +} + +func trimTitle(title string) string { + // Remove a tag prefix if found. + title = tagRegex.ReplaceAllString(title, "") + + return strings.TrimSpace(title) +} + +type commit struct { + merge string + body string +} + +func formatMerge(line, prNumber string) string { + if prNumber == "" { + return line + } + return fmt.Sprintf("%s (%s)", line, prNumber) +} + +func ensureInstalledDependencies() error { + if !commandExists("git") { + return errors.New("git not available. Git is required to be present in the PATH") + } + + if !commandExists("gh") { + return errors.New("gh GitHub CLI not available. GitHub CLI is required to be present in the PATH. Refer to https://cli.github.com/ for installation") + } + + return nil +} + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +// releaseNoteEntryResult is the result of processing a PR to create a release note item. +// Used to aggregate the line item and error when processing concurrently. +type releaseNoteEntryResult struct { + prEntry *releaseNoteEntry + err error +} + +// releaseNoteEntry represents a line item in the release notes. +type releaseNoteEntry struct { + title string + section string + prNumber string +} + +func modifyEntryTitle(title string, prefixes []string) string { + entryWithoutTag := title + for _, prefix := range prefixes { + entryWithoutTag = strings.TrimLeft(strings.TrimPrefix(entryWithoutTag, prefix), " ") + } + + return strings.ToUpper(string(entryWithoutTag[0])) + entryWithoutTag[1:] +} + +// generateReleaseNoteEntry processes a commit into a PR line item for the release notes. +func generateReleaseNoteEntry(c *commit) (*releaseNoteEntry, error) { + entry := &releaseNoteEntry{} + if c.body == "" { + c.body = "ERROR: BODY MISSING. FIX MANUALLY" + } + entry.title = trimTitle(c.body) + var fork string + + var area string + if *prefixAreaLabel { + var err error + area, err = getAreaLabel(c.merge) + if err != nil { + return nil, err + } + } + + switch { + case strings.HasPrefix(entry.title, ":sparkles:"), strings.HasPrefix(entry.title, "✨"): + entry.section = features + entry.title = modifyEntryTitle(entry.title, []string{":sparkles:", "✨"}) + case strings.HasPrefix(entry.title, ":bug:"), strings.HasPrefix(entry.title, "🐛"): + entry.section = bugs + entry.title = modifyEntryTitle(entry.title, []string{":bug:", "🐛"}) + case strings.HasPrefix(entry.title, ":book:"), strings.HasPrefix(entry.title, "📖"): + entry.section = documentation + entry.title = modifyEntryTitle(entry.title, []string{":book:", "📖"}) + if strings.Contains(entry.title, "CAEP") || strings.Contains(entry.title, "proposal") { + entry.section = proposals + } + case strings.HasPrefix(entry.title, ":seedling:"), strings.HasPrefix(entry.title, "🌱"): + entry.section = other + entry.title = modifyEntryTitle(entry.title, []string{":seedling:", "🌱"}) + case strings.HasPrefix(entry.title, ":warning:"), strings.HasPrefix(entry.title, "⚠️"): + entry.section = warning + entry.title = modifyEntryTitle(entry.title, []string{":warning:", "⚠️"}) + default: + entry.section = unknown + } + + // If the area label indicates documentation, use documentation as the section + // no matter what was the emoji used. This takes into account that the area label + // tends to be more accurate than the emoji (data point observed by the release team). + // We handle this after the switch statement to make sure we remove all emoji prefixes. + if area == documentationAreaLabel { + entry.section = documentation + } + + entry.title = strings.TrimSpace(entry.title) + entry.title = trimReleaseBackportMarker(entry.title) + + if entry.title == "" { + return entry, nil + } + + if *prefixAreaLabel { + entry.title = fmt.Sprintf("- %s: %s", area, entry.title) + } else { + entry.title = fmt.Sprintf("- %s", entry.title) + } + + _, _ = fmt.Sscanf(c.merge, "Merge pull request %s from %s", &entry.prNumber, &fork) + entry.title = formatMerge(entry.title, entry.prNumber) + + return entry, nil +} + +// trimReleaseBackportMarker removes the `[release-x.x]` prefix from a PR title if present. +// These are mostly used for back-ported PRs in release branches. +func trimReleaseBackportMarker(title string) string { + return releaseBackportMarker.ReplaceAllString(title, "${1}") +}