diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..e036db44 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,131 @@ +# This code is borrowed from https://github.com/kubernetes-sigs/cluster-api/blob/main/.github/workflows/release.yaml +name: Create Release + +on: + push: + branches: + - main + paths: + - 'releasenotes/*.md' + +permissions: {} + +jobs: + push_release_tags: + permissions: + contents: write + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.release-version.outputs.release_version }} + if: github.repository == 'metal3-io/ironic-standalone-operator' + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 + - name: Get release version + id: release-version + run: | + if [[ ${{ steps.changed-files.outputs.all_changed_files_count }} != 1 ]]; then + echo "1 release notes file should be changed to create a release tag, found ${{ steps.changed-files.outputs.all_changed_files_count }}" + exit 1 + fi + for changed_file in ${{ steps.changed-files.outputs.all_changed_files }}; do + export RELEASE_VERSION=$(echo "${changed_file}" | grep -oP '(?<=/)[^/]+(?=\.md)') + echo "RELEASE_VERSION=${RELEASE_VERSION}" >> ${GITHUB_ENV} + echo "RELEASE_VERSION=${RELEASE_VERSION}" >> ${GITHUB_OUTPUT} + if [[ "${RELEASE_VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "Valid semver: ${RELEASE_VERSION}" + else + echo "Invalid semver: ${RELEASE_VERSION}" + exit 1 + fi + done + - name: Determine the release branch to use + run: | + if [[ ${RELEASE_VERSION} =~ beta ]] || [[ ${RELEASE_VERSION} =~ alpha ]]; then + export RELEASE_BRANCH=main + echo "RELEASE_BRANCH=${RELEASE_BRANCH}" >> ${GITHUB_ENV} + echo "This is a beta or alpha release, will use release branch ${RELEASE_BRANCH}" + else + export RELEASE_BRANCH=release-$(echo ${RELEASE_VERSION} | sed -E 's/^v([0-9]+)\.([0-9]+)\..*$/\1.\2/') + echo "RELEASE_BRANCH=${RELEASE_BRANCH}" >> ${GITHUB_ENV} + echo "This is not a beta or alpha release, will use release branch ${RELEASE_BRANCH}" + fi + - name: Create or checkout release branch + run: | + if git show-ref --verify --quiet "refs/remotes/origin/${RELEASE_BRANCH}"; then + echo "Branch ${RELEASE_BRANCH} already exists" + git checkout "${RELEASE_BRANCH}" + else + git checkout -b "${RELEASE_BRANCH}" + git push origin "${RELEASE_BRANCH}" + echo "Created branch ${RELEASE_BRANCH}" + fi + - name: Validate tag does not already exist + run: | + if [[ -n "$(git tag -l "${RELEASE_VERSION}")" ]]; then + echo "Tag ${RELEASE_VERSION} already exists, exiting" + exit 1 + fi + - name: Create Release Tag + run: | + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + git tag -a ${RELEASE_VERSION} -m ${RELEASE_VERSION} + git tag api/${RELEASE_VERSION} + git tag test/${RELEASE_VERSION} + git push origin ${RELEASE_VERSION} + git push origin api/${RELEASE_VERSION} + git push origin test/${RELEASE_VERSION} + echo "Created tags ${RELEASE_VERSION}, api/${RELEASE_VERSION}, and test/${RELEASE_VERSION}" + release: + name: create draft release + runs-on: ubuntu-latest + needs: push_release_tags + permissions: + contents: write + steps: + - name: Set env + run: echo "RELEASE_TAG=${RELEASE_TAG}" >> ${GITHUB_ENV} + env: + RELEASE_TAG: ${{needs.push_release_tags.outputs.release_tag}} + - name: checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + ref: ${{ env.RELEASE_TAG }} + - name: Calculate go version + run: echo "go_version=$(make go-version)" >> ${GITHUB_ENV} + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: ${{ env.go_version }} + - name: get release notes + run: | + curl -L "https://raw.githubusercontent.com/${{ github.repository }}/main/releasenotes/${{ env.RELEASE_TAG }}.md" \ + -o "${{ env.RELEASE_TAG }}.md" + - name: Release + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 + with: + draft: true + body_path: ${{ env.RELEASE_TAG }}.md + tag_name: ${{ env.RELEASE_TAG }} + build_irso: + permissions: + contents: read + needs: push_release_tags + name: Build IrSO container image + if: github.repository == 'metal3-io/ironic-standalone-operator' + uses: metal3-io/project-infra/.github/workflows/container-image-build.yml@main + with: + image-name: 'ironic-standalone-operator' + pushImage: true + ref: ${{ needs.push_release_tags.outputs.release_tag }} + secrets: + QUAY_USERNAME: ${{ secrets.QUAY_USERNAME }} + QUAY_PASSWORD: ${{ secrets.QUAY_PASSWORD }} + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/Makefile b/Makefile index 1e4cdf0e..dc5052d2 100644 --- a/Makefile +++ b/Makefile @@ -304,3 +304,21 @@ catalog-push: ## Push a catalog image. ##@ helpers: go-version: ## Print the go version we use to compile our binaries and images @echo $(GO_VERSION) + +## -------------------------------------- +## Release +## -------------------------------------- +RELEASE_TAG ?= $(shell git describe --abbrev=0 2>/dev/null) +ifneq (,$(findstring -,$(RELEASE_TAG))) + PRE_RELEASE=true +endif +# the previous release tag, e.g., v1.7.0, 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) | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$$" | head -n 1 2>/dev/null) +RELEASE_NOTES_DIR := releasenotes + +$(RELEASE_NOTES_DIR): + mkdir -p $(RELEASE_NOTES_DIR)/ + +.PHONY: release-notes +release-notes: $(RELEASE_NOTES_DIR) $(RELEASE_NOTES) + cd hack/tools && $(GO) run release/notes.go --releaseTag=$(RELEASE_TAG) > $(realpath $(RELEASE_NOTES_DIR))/$(RELEASE_TAG).md diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 00000000..68dfe7ae --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,223 @@ +# ironic-standalone-operator releasing + +This document details the steps to create a release for +`ironic-standalone-operator` aka IrSO. + +**NOTE**: Always follow release documentation from the main branch. Release +documentation in release branches may be outdated. + +## Before making a release + +Things you should check before making a release: + +- Check the + [Metal3 release process](https://github.com/metal3-io/metal3-docs/blob/main/processes/releasing.md) + for high-level process and possible follow-up actions +- Use the `./hack/verify-release.sh` script as helper to identify possible + issues to be addressed before creating any release tags. It verifies issues + like: + - Verify any other direct or indirect dependency is uplifted to close any public + vulnerabilities + +To make sure the correct Ironic versions are supported and tested: + +- Add any missing versions and their branch names to the [list of versions + in the API][supported-versions]. +- Extend the [functional tests suite][suite_test] with new tests if needed: + - update [the list of known Ironic API versions][known-api-versions] using + the [Ironic API versions listing][api-versions-list]. + - upgrade from the newest version to `latest` with and without HA + - upgrade to the newest version from the one before it (with and without HA) + Hint: you can usually copy existing tests, modifying only the `spec.version` + field and the API versions on the `TestAssumptions` structure. + +[supported-versions]: https://github.com/metal3-io/ironic-standalone-operator/blob/b630805cdd782a51845fc16086e5f64fa77e29af/api/v1alpha1/ironic_types.go#L23-L34 +[suite_test]: https://github.com/metal3-io/ironic-standalone-operator/blob/main/test/suite_test.go +[known-api-versions]: https://github.com/metal3-io/ironic-standalone-operator/blob/b630805cdd782a51845fc16086e5f64fa77e29af/test/suite_test.go#L56-L67 +[api-versions-list]: https://docs.openstack.org/ironic/latest/contributor/webapi-version-history.html + +## Permissions + +Creating the first release in a series requires repository `write` permissions +for branch creation. + +These permissions are implicit for the org admins and repository admins. +Release team member gets his/her permissions via `metal3-release-team` +membership. This GitHub team has the required permissions in each repository +required to release IrSO. Adding person to the team gives him/her the necessary +rights in all relevant repositories in the organization. Individual persons +should not be given permissions directly. + +Patch releases don't require extra permissions. + +## Process + +IrSO uses [semantic versioning](https://semver.org). + +- Regular releases: `v0.x.y` +- Beta releases: `v0.x.y-beta.z` +- Release candidate releases: `v0.x.y-rc.z` + +### Repository setup + +Clone the repository: `git clone git@github.com:metal3-io/ironic-standalone-operator` + +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. + +### Preparing a branch + +IrSO requires a branch to be created and updated before the automation runs. +If (and only if) you're creating a release `v0.x.0` (i.e. a minor release): + +- Switch to the main branch: `git checkout main` + +- Identify the commit you wish to create the branch from, and create a branch + `release-0.x`: `git checkout -b release-0.x` and push it to remote: + `git push upstream release-0.x` to create it. Replace `upstream` with + the actual remote name for the upstream source (not your private fork). + +- Setup the CI for the new branch in the prow configuration. [Prior + art](https://github.com/metal3-io/project-infra/pull/1034). + +Create a development branch (e.g. `prepare-0.x`) from the newly created branch: + +- Change [the default Ironic version][default-version] to the most recent + branch of the Ironic image. + +- Change the branch of IrSO itself from `latest` to `release-0.x` in two + places: [IMG in Makefile][img-makefile] and + [Kustomize configuration][kustomize]. + +- Commit your changes, push the new branch and create a pull request: + - The commit and PR title should be + ✨ Switch to Ironic X.Y by default, prepare release-0.x: + -`git commit -S -s -m ":sparkles: Switch to Ironic X.Y by default, prepare release-0.x"` + where X.Y is the Ironic branch you used [above][default-version] + -`git push -u origin prepare-0.x` + - The pull request must target the new branch (`release-0.x`), not `main`! + +Wait for the pull request to be merged before proceeding. + +[default-version]: https://github.com/metal3-io/ironic-standalone-operator/blob/b630805cdd782a51845fc16086e5f64fa77e29af/pkg/ironic/version.go#L14-L15 +[img-makefile]: https://github.com/metal3-io/ironic-standalone-operator/blob/b630805cdd782a51845fc16086e5f64fa77e29af/Makefile#L74-L75 +[kustomize]: https://github.com/metal3-io/ironic-standalone-operator/blob/b630805cdd782a51845fc16086e5f64fa77e29af/config/manager/manager.yaml#L47 + +### Creating Release Notes + +- Switch to the main branch: `git checkout main` + +- Create a new branch for the release notes**: + `git checkout -b release-notes-0.x.x origin/main` + +- Generate the release notes: `RELEASE_TAG=v0.x.x make release-notes` + - Replace `v0.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 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. + +- Commit your changes, push the new branch and create a pull request: + - The commit and PR title should be 🚀 Release v0.x.y: + -`git commit -S -s -m ":rocket: Release v0.x.x"` + -`git push -u origin release-notes-0.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/ironic-standalone-operator/actions) + page, and draft release will be visible on top of the + [Releases](https://github.com/metal3-io/ironic-standalone-operator/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_irso` build release images with the + release tag, and push them to Quay. Make sure the release tags are visible in + Quay tags pages: + - [IrSO](https://quay.io/repository/metal3-io/ironic-standalone-operator?tab=tags) + If the new release tag is not available for any of the images, check if the + action has failed and retrigger as necessary. + +### Release artifacts + +We need to verify all release artifacts are correctly built or generated by the +release workflow. You can use `./hack/verify-release.sh` to check for existence +of release artifacts, which should include the following: + +Git tags pushed: + +- Primary release tag: `v0.x.y` +- Go module tags: `api/v0.x.y` and `test/v0.x.y` + +Container images built and tagged at Quay registry: + +- [ironic-standalone-operator:v0.x.y](https://quay.io/repository/metal3-io/ironic-standalone-operator?tab=tags) + +You can also check the draft release and its tags in the Github UI. + +### Make the release + +After everything is checked out, hit the `Publish` button your GitHub draft +release! + +## Post-release actions for new release branches + +Some post-release actions are needed if new minor or major branch was created. + +### Branch protection rules + +Branch protection rules need to be applied to the new release branch. Copy the +settings after the previous release branch. Branch protection rules require +user to have `admin` permissions in the repository. + +### Documentation + +Update the [user +guide](https://github.com/metal3-io/metal3-docs/tree/main/docs/user-guide/src): + +- Update [supported + versions](https://github.com/metal3-io/metal3-docs/blob/main/docs/user-guide/src/irso/introduction.md#supported-versions) + with the Ironic versions that the new release supports. + +- Consider if Ironic versions in the + [examples](https://github.com/metal3-io/metal3-docs/blob/main/docs/user-guide/src/irso/install-basics.md) + need updating if they are too old or no longer supported. + +### Update milestones + +- Make sure the next two milestones exist. For example, after 0.4 is out, 0.5 + and 0.6 should exist in Github. +- Set the next milestone date based on the expected release date, which usually + happens shortly after the next Ironic release. +- Remove milestone date for passed milestones. + +Milestones must also be updated in the Prow configuration +([example](https://github.com/metal3-io/project-infra/pull/1035)). + +## Additional actions outside this repository + +Further additional actions are required in the Metal3 project after IrSO release. +For that, please continue following the instructions provided in +[Metal3 release process](https://github.com/metal3-io/metal3-docs/blob/main/processes/releasing.md) diff --git a/hack/tools/go.mod b/hack/tools/go.mod new file mode 100644 index 00000000..5094f1ef --- /dev/null +++ b/hack/tools/go.mod @@ -0,0 +1,14 @@ +module github.com/metal3-io/ironic-standalone-operator/hack/tools + +go 1.23.7 + +require ( + github.com/blang/semver v3.5.1+incompatible + github.com/google/go-github v17.0.0+incompatible + golang.org/x/oauth2 v0.30.0 +) + +require ( + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect +) diff --git a/hack/tools/go.sum b/hack/tools/go.sum new file mode 100644 index 00000000..26f18f2e --- /dev/null +++ b/hack/tools/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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/hack/tools/release/notes.go b/hack/tools/release/notes.go new file mode 100644 index 00000000..95299b65 --- /dev/null +++ b/hack/tools/release/notes.go @@ -0,0 +1,341 @@ +//go:build tools +// +build tools + +/* +Copyright 2021 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" + "flag" + "fmt" + "log" + "os" + "os/exec" + "strings" + + "errors" + + "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" + repoOwner = "metal3-io" + repoName = "ironic-standalone-operator" + 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/ironic-standalone-operator/issues/new/).\n\n" +) + +var ( + outputOrder = []string{ + warning, + features, + bugs, + documentation, + other, + unknown, + superseded, + } + toTag = flag.String("releaseTag", "", "The tag or commit to end to.") +) + +func main() { + flag.Parse() + os.Exit(run()) +} + +func latestTag() (string, error) { + if toTag != nil && *toTag != "" { + return *toTag, nil + } + return "", errors.New("RELEASE_TAG is not set") +} + +// 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 "", fmt.Errorf("parsing semver for %s: %w", latestTag, err) + } + semVersion.Minor-- + lastReleaseTag := fmt.Sprintf("v%s", semVersion.String()) + return lastReleaseTag, nil + } + + latestTag = strings.TrimPrefix(latestTag, "v") + + semVersion, err := semver.New(latestTag) + if err != nil { + return "", fmt.Errorf("parsing semver for %s: %w", latestTag, err) + } + semVersion.Patch-- + lastReleaseTag := fmt.Sprintf("v%s", semVersion.String()) + return lastReleaseTag, nil +} + +func isBeta(tag string) bool { + return strings.Contains(tag, "-beta.") +} + +func isRC(tag string) bool { + return strings.Contains(tag, "-rc.") +} + +func isMinor(tag string) bool { + return strings.HasSuffix(tag, ".0") +} + +func run() int { + 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: {}, + bugs: {}, + documentation: {}, + warning: {}, + other: {}, + unknown: {}, + superseded: {}, + } + 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 + } + } + + for _, c := range commits { + body := strings.TrimSpace(c.body) + var key, prNumber, fork 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, ":running:"), strings.HasPrefix(body, "🏃"): + // This has been deprecated in favor of :seedling: + key = other + body = strings.TrimPrefix(body, ":running:") + 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 + } + + body = strings.TrimSpace(body) + if body == "" { + continue + } + body = fmt.Sprintf("- %s", body) + + if strings.HasPrefix(c.merge, "Merge pull request") { + _, 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) + } + } else if strings.HasPrefix(c.merge, "Merge commit from fork") { + _, err = fmt.Sscanf(c.merge, "Merge commit from fork") + if err != nil { + log.Fatalf("Error parsing merge commit message: %v", err) + } + } else { + log.Fatalf("Unexpected merge commit message format: %s", c.merge) + } + + merges[key] = append(merges[key], formatMerge(body, prNumber)) + } + + // Add empty superseded section, if not beta/rc, we don't cleanup those notes + if !isBeta(latestTag) && !isRC(latestTag) { + merges[superseded] = append(merges[superseded], "- ``") + } + + fmt.Println("") + if isBeta(latestTag) { + fmt.Printf(warningTemplate, "BETA RELEASE") + } + if isRC(latestTag) { + fmt.Printf(warningTemplate, "RELEASE CANDIDATE") + } + // TODO Turn this into a link (requires knowing the project name + organization) + 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.Printf("## %v\n\n", key) + for _, merge := range mergeslice { + fmt.Println(merge) + } + fmt.Println() + } + + // if we're doing beta/rc, print breaking changes and hide the rest of the changes + if key == warning { + if isBeta(latestTag) || isRC(latestTag) { + fmt.Printf("
\n") + fmt.Printf("More details about the release\n\n") + } + } + } + + // then close the details if we had it open + if isBeta(latestTag) || isRC(latestTag) { + fmt.Printf("
\n\n") + } + + fmt.Printf("The image for this release is: %v\n", latestTag) + fmt.Println("\n_Thanks to all our contributors!_ 😊") + + return 0 +} + +type commit struct { + merge string + body string +} + +func formatMerge(line, prNumber string) string { + if prNumber == "" { + return line + } + 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) +} diff --git a/releasenotes/.gitkeep b/releasenotes/.gitkeep new file mode 100644 index 00000000..e69de29b