diff --git a/.github/workflows/check-release.yaml b/.github/workflows/check-release.yaml new file mode 100644 index 0000000000..9a75305ce2 --- /dev/null +++ b/.github/workflows/check-release.yaml @@ -0,0 +1,46 @@ +name: Check Release + +on: + pull_request: + branches: + - release-* + paths: + - VERSION + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + SEMVER_PATTERN: '^v([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.([0-9]+))?$' + +jobs: + check-release: + runs-on: ubuntu-latest + + steps: + - name: Chekcout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check whether version matches semver pattern + run: | + VERSION=$(cat VERSION) + if [[ ${VERSION} =~ ${{ env.SEMVER_PATTERN }} ]]; then + echo "Version '${VERSION}' matches semver pattern." + else + echo "Version '${VERSION}' does not match semver pattern." + exit 1 + fi + echo "VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Check if tag exists + run: | + git fetch --tags + if git tag -l | grep -q "^${VERSION}$"; then + echo "Tag '${VERSION}' already exists." + exit 1 + else + echo "Tag '${VERSION}' does not exist." + fi diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000000..92612c6565 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,227 @@ +name: Release + +on: + push: + branches: + - release-* + paths: + - VERSION + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + SEMVER_PATTERN: '^v([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.([0-9]+))?$' + IMAGE_REGISTRY: docker.io + IMAGE_REPOSITORY: kubeflow/training-operator + +jobs: + check-release: + runs-on: ubuntu-latest + + steps: + - name: Chekcout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check whether version matches semver pattern + run: | + VERSION=$(cat VERSION) + if [[ ${VERSION} =~ ${{ env.SEMVER_PATTERN }} ]]; then + echo "Version '${VERSION}' matches semver pattern." + else + echo "Version '${VERSION}' does not match semver pattern." + exit 1 + fi + echo "VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Check if tag exists + run: | + git fetch --tags + if git tag -l | grep -q "^${VERSION}$"; then + echo "Tag '${VERSION}' already exists." + exit 1 + else + echo "Tag '${VERSION}' does not exist." + fi + + build-images: + needs: check-release + + runs-on: ubuntu-latest + + strategy: + fail-fast: false # Need to confirm with maintainers + matrix: + platform: + - linux/amd64 + - linux/arm64 + + steps: + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Read version from VERSION file + run: | + VERSION=$(cat VERSION) + echo "VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_REPOSITORY }} + tags: | + type=semver,pattern={{version}},value=${{ env.VERSION }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.IMAGE_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: build/images/training-operator/Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_REPOSITORY }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + release-images: + needs: build-images + + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Read version from VERSION file + run: | + VERSION=$(cat VERSION) + echo "VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_REPOSITORY }} + tags: | + type=semver,pattern={{version}},value=${{ env.VERSION }} + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.IMAGE_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_REPOSITORY }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_REPOSITORY }}:${{ steps.meta.outputs.version }} + + push-tags: + needs: release-images + + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Read version from VERSION file + run: | + VERSION=$(cat VERSION) + echo "VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Create and push tag + run: | + git tag -a "${VERSION}" -m "Training Operator Official Release ${VERSION}" + git push origin "${VERSION}" + + draft-release: + needs: push-tags + + permissions: + contents: write + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Read version from VERSION file + run: | + VERSION=$(cat VERSION) + echo "VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Create release + id: release + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + name: "${{ env.VERSION }}" + tag_name: ${{ env.VERSION }} + prerelease: ${{ contains(env.VERSION, 'rc') }} + target_commitish: ${{ github.sha }} + draft: true diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000..3f0882c5ed --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v1.8.1 \ No newline at end of file diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000000..31df6f5621 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,100 @@ +# Releasing the Training operator + +## Prerequisites + +- [Write](https://docs.github.com/organizations/managing-access-to-your-organizations-repositories/repository-permission-levels-for-an-organization#permission-levels-for-repositories-owned-by-an-organization) permission for the Spark operator repository. + +- Create a [GitHub Token](https://docs.github.com/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token). + +- Install `PyGithub` to generate the [Changelog](../CHANGELOG.md): + + ```bash + pip install PyGithub==2.3.0 + ``` + +## Versioning policy + +Training Operator version format follows [Semantic Versioning](https://semver.org/). Training Operator versions are in the format of `vX.Y.Z`, where `X` is the major version, `Y` is the minor version, and `Z` is the patch version. The patch version contains only bug fixes. + +Additionally, Training Operator does pre-releases in this format: `vX.Y.Z-rc.N` where `N` is a number of the `Nth` release candidate (RC) before an upcoming public release named `vX.Y.Z`. + +## Release branches and tags + +Training Operator releases are tagged with tags like `vX.Y.Z`, for example `v1.7.2`. + +Release branches are in the format of `release-X.Y`, where `X.Y` stands for the minor release. + +`vX.Y.Z` releases are released from the `release-X.Y` branch. For example, `v1.7.2` release should be on `release-1.7` branch. + +If you want to push changes to the `release-X.Y` release branch, you have to cherry pick your changes from the `master` branch and submit a PR. + +## Create a new release + +### Create release branch + +1. Depends on what version you want to release, + + - Major or Minor version - Use the GitHub UI to create a release branch from `master` and name the release branch `release-X.Y`. + - Patch version - You don't need to create a new release branch. + +2. Fetch the upstream changes into your local directory: + + ```bash + git fetch upstream + ``` + +3. Checkout into the release branch: + + ```bash + git checkout release-X.Y + git rebase upstream/release-X.Y + ``` + +### Create GitHub tag + +1. Modify `VERSION` file in the root directory of the project: + + - For the RC tag as follows: + + ```bash + vX.Y.Z-rc.N + ``` + + - For the official release tag as follows: + + ```bash + vX.Y.Z + ``` +2. Commit the changes: + + ```bash + git add VERSION + git commit -s -m "Training Operator Official Release v${VERSION}" + git push origin release-X.Y + ``` +3. Submit a PR to the release branch. + +### Release Training Operator Image + +After `VERSION` file is modified and pushed to the release branch, a release workflow will be triggered to build and push Training operator docker images to Docker Hub. + + +## Update Changelog + +Update the `CHANGELOG.md` file by running: + +```bash +python hack/generate-changelog.py \ + --token= \ + --range=.. +``` + +If you are creating the **first minor pre-release** or the **minor** release (`X.Y`), your `previous-release` is equal to the latest release on the `release-X.Y` branch. +For example: `--range=v1.7.1..v1.8.0`. + +Otherwise, your `previous-release` is equal to the latest release on the `release-X.Y` branch. +For example: `--range=v1.7.0..v1.8.0-rc.0` + +Group PRs in the Changelog into Features, Bug fixes, Documentation, etc. + +Finally, submit a PR with the updated Changelog. diff --git a/hack/generate-changelog.py b/hack/generate-changelog.py new file mode 100644 index 0000000000..70fadfc5cc --- /dev/null +++ b/hack/generate-changelog.py @@ -0,0 +1,72 @@ +import argparse + +from github import Github + +REPO_NAME = "kubeflow/training-operator" +CHANGELOG_FILE = "CHANGELOG.md" + +parser = argparse.ArgumentParser() +parser.add_argument("--token", type=str, help="GitHub Access Token") +parser.add_argument( + "--range", type=str, help="Changelog is generated for this release range" +) +args = parser.parse_args() + +if args.token is None: + raise Exception("GitHub Token must be set") +try: + previous_release = args.range.split("..")[0] + current_release = args.range.split("..")[1] +except Exception: + raise Exception("Release range must be set in this format: v1.7.0..v1.8.0") + +# Get list of commits from the range. +github_repo = Github(args.token).get_repo(REPO_NAME) +comparison = github_repo.compare(previous_release, current_release) +commits = comparison.commits + +# The latest commit contains the release date. +release_date = str(commits[-1].commit.author.date).split(" ")[0] +release_url = "https://github.com/{}/tree/{}".format(REPO_NAME, current_release) + +# Get all PRs in reverse chronological order from the commits. +pr_list = "" +pr_set = set() +for commit in commits.reversed: + # Only add commits with PRs. + for pr in commit.get_pulls(): + # Each PR is added only one time to the list. + if pr.number in pr_set: + continue + if not pr.merged: + continue + pr_set.add(pr.number) + + new_pr = "- {title} ([#{id}]({pr_link}) by [@{user_id}]({user_url}))\n".format( + title=pr.title, + id=pr.number, + pr_link=pr.html_url, + user_id=pr.user.login, + user_url=pr.user.html_url, + ) + pr_list += new_pr + +change_log = [ + "\n", + "## [{}]({}) ({})\n".format(current_release, release_url, release_date), + "\n", + pr_list, + "\n", + "[Full Changelog]({})\n".format(comparison.html_url), +] + +# Update Changelog with the new changes. +with open(CHANGELOG_FILE, "r+") as f: + lines = f.readlines() + f.seek(0) + lines = lines[:1] + change_log + lines[1:] + f.writelines(lines) + +print("Changelog has been updated\n") +print("Group PRs in the Changelog into Features, Bug fixes, Misc, etc.\n") +print("After that, submit a PR with the updated Changelog") \ No newline at end of file