diff --git a/.github/workflows/build-and-push-images.yaml b/.github/workflows/build-and-push-images.yaml index 379dffb829..6cab8092c7 100644 --- a/.github/workflows/build-and-push-images.yaml +++ b/.github/workflows/build-and-push-images.yaml @@ -8,6 +8,7 @@ on: tags: - "v*" pull_request: + workflow_dispatch: jobs: build-and-publish: @@ -16,7 +17,7 @@ jobs: runs-on: oracle-vm-16cpu-64gb-x86-64 env: - SHOULD_PUBLISH: ${{ github.event_name == 'push' }} + SHOULD_PUBLISH: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} strategy: fail-fast: false diff --git a/.github/workflows/check-pr-title.yaml b/.github/workflows/check-pr-title.yaml index 5cb8380c94..7b504ef19c 100644 --- a/.github/workflows/check-pr-title.yaml +++ b/.github/workflows/check-pr-title.yaml @@ -42,3 +42,4 @@ jobs: ignoreLabels: | do-not-merge/work-in-progress dependencies + area/release diff --git a/.github/workflows/check-release.yaml b/.github/workflows/check-release.yaml new file mode 100644 index 0000000000..03b1b93ffb --- /dev/null +++ b/.github/workflows/check-release.yaml @@ -0,0 +1,109 @@ +name: Check Release + +on: + pull_request: + branches: + - master + 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]+))?$' + CHART_FILE: charts/kubeflow-trainer/Chart.yaml + PY_API_VERSION_FILE: api/python_api/kubeflow_trainer_api/__init__.py + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check whether version matches semver pattern + run: | + RAW_VERSION=$(cat VERSION | tr -d ' \n') + VERSION=${RAW_VERSION#v} + if [[ ${VERSION} =~ ${{ env.SEMVER_PATTERN }} ]]; then + echo "Version '${RAW_VERSION}' matches semver pattern." + else + echo "Version '${RAW_VERSION}' does not match semver pattern." + exit 1 + fi + echo "VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Check if tag exists + run: | + git fetch --tags + RAW_VERSION=$(cat VERSION | tr -d ' \n') + VERSION=${RAW_VERSION#v} + TAG="v${VERSION}" + if git tag -l | grep -q "^${TAG}$"; then + echo "Tag '${TAG}' already exists." + exit 1 + else + echo "Tag '${TAG}' does not exist." + fi + + - name: Check if manifests image tag matches version + run: | + RAW_VERSION=$(cat VERSION | tr -d ' \n') + VERSION=${RAW_VERSION#v} + TAG="v${VERSION}" + MANIFEST_TAGS=$(grep -r 'newTag:' manifests | sed 's/.*newTag:[[:space:]]*//' | tr -d '"' | tr -d "'" | sort | uniq) + if [ -z "$MANIFEST_TAGS" ]; then + echo "No newTag found in manifests." + exit 1 + fi + for t in $MANIFEST_TAGS; do + if [ "$t" != "$TAG" ]; then + echo "Image tag in manifests ($t) does not match version tag ($TAG)." + exit 1 + fi + done + echo "All image tags in manifests match version tag $TAG." + + - name: Check Helm chart version + run: | + RAW_VERSION=$(cat VERSION | tr -d ' \n') + VERSION=${RAW_VERSION#v} + CHART_VERSION=$(grep -E '^version:' "$CHART_FILE" | head -n1 | awk '{print $2}') + if [ -z "$CHART_VERSION" ]; then + echo "Chart version not found in $CHART_FILE" + exit 1 + fi + if [ "$CHART_VERSION" != "$VERSION" ]; then + echo "Chart version ($CHART_VERSION) does not match VERSION ($VERSION)." + exit 1 + fi + echo "Chart version matches VERSION ($VERSION)." + + - name: Check Python API version + run: | + RAW_VERSION=$(cat VERSION | tr -d ' \n') + VERSION=${RAW_VERSION#v} + PY_VER=$(python - <<'PY' + import os + import re + import sys + from pathlib import Path + path = Path(os.environ["PY_API_VERSION_FILE"]) + text = path.read_text() + match = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", text) + if not match: + print("__version__ not found", file=sys.stderr) + sys.exit(1) + print(match.group(1)) + PY + ) + if [ "$PY_VER" != "$VERSION" ]; then + echo "Python API version ($PY_VER) does not match VERSION ($VERSION)." + exit 1 + fi + echo "Python API version matches VERSION ($VERSION)." diff --git a/.github/workflows/publish-helm-charts.yaml b/.github/workflows/publish-helm-charts.yaml index d563c9e1f7..6dadca0dd6 100644 --- a/.github/workflows/publish-helm-charts.yaml +++ b/.github/workflows/publish-helm-charts.yaml @@ -6,6 +6,11 @@ on: - master tags: - "v*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false env: CHART_PATH: charts/kubeflow-trainer diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000000..c3da4747b5 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,265 @@ +name: Release + +on: + push: + branches: + - master + 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_GITHUB: ghcr.io + IMAGE_REGISTRY_DOCKER: docker.io + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.vars.outputs.version }} + tag: ${{ steps.vars.outputs.tag }} + branch: ${{ steps.vars.outputs.branch }} + is-prerelease: ${{ steps.vars.outputs.is-prerelease }} + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate version and set outputs + id: vars + run: | + RAW_VERSION=$(cat VERSION | tr -d ' \n') + VERSION=${RAW_VERSION#v} + if [[ ! ${RAW_VERSION} =~ ${{ env.SEMVER_PATTERN }} ]]; then + echo "Version '${RAW_VERSION}' does not match semver pattern." + exit 1 + fi + + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) + BRANCH="release-${MAJOR_MINOR}" + TAG="v${VERSION}" + IS_PRERELEASE=false + if [[ ${VERSION} == *"-rc."* ]]; then + IS_PRERELEASE=true + fi + + echo "Version '${RAW_VERSION}' matches semver pattern." + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "branch=${BRANCH}" >> $GITHUB_OUTPUT + echo "tag=${TAG}" >> $GITHUB_OUTPUT + echo "is-prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT + + - name: Ensure tag does not exist + run: | + git fetch --tags + if git tag -l | grep -q "^${{ steps.vars.outputs.tag }}$"; then + echo "Tag '${{ steps.vars.outputs.tag }}' already exists." + exit 1 + fi + + - name: Check manifests image tag matches version + run: | + TAG="${{ steps.vars.outputs.tag }}" + MANIFEST_TAGS=$(grep -r 'newTag:' manifests | sed 's/.*newTag:[[:space:]]*//' | tr -d '"' | tr -d "'" | sort | uniq) + if [ -z "$MANIFEST_TAGS" ]; then + echo "No newTag found in manifests." + exit 1 + fi + for t in $MANIFEST_TAGS; do + if [ "$t" != "$TAG" ]; then + echo "Image tag in manifests ($t) does not match version tag ($TAG)." + exit 1 + fi + done + echo "All image tags in manifests match version tag $TAG." + + build_python_api: + needs: + - prepare + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build tooling + run: | + python -m pip install --upgrade pip + python -m pip install build twine + + - name: Verify Python package version matches release + run: | + EXPECTED="${{ needs.prepare.outputs.version }}" + PY_VER=$(python - <<'PY' + import re + from pathlib import Path + text = Path('api/python_api/kubeflow_trainer_api/__init__.py').read_text() + m = re.search(r"__version__\s*=\s*['\"]([^'\"]+)['\"]", text) + if not m: + raise SystemExit("__version__ not found") + print(m.group(1)) + PY + ) + echo "Expected: ${EXPECTED}; Package: ${PY_VER}" + if [ "$PY_VER" != "$EXPECTED" ]; then + echo "Python API version mismatch" + exit 1 + fi + + - name: Build Python package + working-directory: api/python_api + run: | + python -m build + python -m twine check dist/* + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: python-dist-${{ needs.prepare.outputs.version }} + path: api/python_api/dist/ + + create_branch: + needs: + - prepare + - build_python_api + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + - name: Create release branch + run: | + BRANCH="${{ needs.prepare.outputs.branch }}" + git fetch origin + if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then + echo "Release branch $BRANCH already exists." + else + echo "Creating release branch $BRANCH from $GITHUB_SHA" + git checkout -b "$BRANCH" "$GITHUB_SHA" + git push origin "$BRANCH" + fi + + trigger_builds: + needs: + - prepare + - github_release + runs-on: ubuntu-latest + permissions: + actions: write + + steps: + - name: Trigger image build for release tag + uses: actions/github-script@v7 + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'build-and-push-images.yaml', + ref: '${{ needs.prepare.outputs.tag }}', + }) + + - name: Trigger Helm chart publish for release tag + uses: actions/github-script@v7 + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'publish-helm-charts.yaml', + ref: '${{ needs.prepare.outputs.tag }}', + }) + + publish_pypi: + needs: + - prepare + - build_python_api + - create_branch + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/project/kubeflow-trainer-api/ + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: python-dist-${{ needs.prepare.outputs.version }} + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + print-hash: true + packages-dir: dist/ + password: ${{ secrets.PYPI_API_TOKEN }} + + github_release: + needs: + - prepare + - create_branch + - publish_pypi + + permissions: + contents: write + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + uses: orhun/git-cliff-action@v4 + with: + config: cliff.toml + args: > + --unreleased + --tag ${{ needs.prepare.outputs.tag }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + - name: Create and push tag + run: | + git tag -a "${{ needs.prepare.outputs.tag }}" "$GITHUB_SHA" -m "Kubeflow Trainer ${{ needs.prepare.outputs.tag }}" + git push origin "${{ needs.prepare.outputs.tag }}" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + name: "${{ needs.prepare.outputs.tag }}" + tag_name: ${{ needs.prepare.outputs.tag }} + target_commitish: ${{ github.sha }} + prerelease: ${{ needs.prepare.outputs.is-prerelease == 'true' }} + draft: false + body_path: ${{ steps.changelog.outputs.changelog }} diff --git a/.github/workflows/template-publish-image/action.yaml b/.github/workflows/template-publish-image/action.yaml index def7a67d61..1673826396 100644 --- a/.github/workflows/template-publish-image/action.yaml +++ b/.github/workflows/template-publish-image/action.yaml @@ -82,6 +82,7 @@ runs: images: ${{ inputs.image }} tags: | type=ref,event=tag + type=raw,value=${{ github.ref_name }},enable=${{ github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/') }} type=raw,value=latest,enable={{is_default_branch}} type=sha diff --git a/Makefile b/Makefile index c2f2b6f417..7713728c78 100644 --- a/Makefile +++ b/Makefile @@ -241,3 +241,18 @@ helm-lint: ## Run Helm chart lint test. .PHONY: helm-docs helm-docs: helm-docs-plugin ## Generates markdown documentation for helm charts from requirements and values files. $(HELM_DOCS) --sort-values-order=file + +##@ Release + +# Release version (X.Y.Z or X.Y.Z-rc.N) +VERSION ?= +GITHUB_TOKEN ?= + +.PHONY: release +release: ## Create a new release. + @if [ -z "$(VERSION)" ]; then \ + echo "ERROR: VERSION is required. Usage: make release VERSION=X.Y.Z GITHUB_TOKEN="; \ + exit 1; \ + fi + @export GITHUB_TOKEN=$(GITHUB_TOKEN); \ + ./hack/release.sh $(VERSION) diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000000..99903c06e8 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,87 @@ +[remote.github] +owner = "kubeflow" +repo = "trainer" + +[changelog] +body = """ +{%- if version %} +## [{{ version }}](https://github.com/kubeflow/trainer/releases/tag/{{ version }}) ({{ timestamp | date(format="%Y-%m-%d") }}) + +This is Kubeflow Trainer {{ version }} release. + +```bash +kubectl apply --server-side -k "https://github.com/kubeflow/trainer.git/manifests/overlays/manager?ref={{ version }}" +kubectl apply --server-side -k "https://github.com/kubeflow/trainer.git/manifests/overlays/runtimes?ref={{ version }}" +``` + +You can now install controller manager with Helm charts 🚀 + +```bash +helm install kubeflow-trainer oci://ghcr.io/kubeflow/charts/kubeflow-trainer --version {{ version | trim_start_matches(pat="v") }} +``` + +For more information, please see [the Kubeflow Trainer docs](https://www.kubeflow.org/docs/components/trainer/overview/) + +{%- else %} +## [Unreleased] +{%- endif %} + +{%- set group_order = ["🚀 Features", "🐛 Bug Fixes", "⚙️ Miscellaneous Tasks", "⏪ Reverts"] -%} + +{%- for group_name in group_order %} +{%- set group_commits = commits | filter(attribute="group", value=group_name) -%} +{%- if group_commits | length > 0 %} +### {{ group_name }} + +{% for commit in group_commits | reverse -%} +{%- set message = commit.message | split(pat="\n") | first | trim -%} +{%- set parts = message | split(pat=" (#") -%} +{%- set author = commit.remote.username | default(value=commit.author.name) -%} + {% if parts | length > 1 and parts | last | trim | split(pat=")") | length > 1 -%} + {%- set pr_part = parts | last | trim -%} + {%- set pr_number = pr_part | replace(from=")", to="") -%} + - {{ parts | slice(end=-1) | join(sep=" (#") }} ([#{{ pr_number }}](https://github.com/kubeflow/trainer/pull/{{ pr_number }}) by @{{ author }}) + {% else -%} + - {{ message }} (@{{ author }}) + {% endif -%} +{% endfor %} + +{%- endif %} +{%- endfor %} + +{%- if github -%} +{%- set new_contributors = github.contributors | filter(attribute="is_first_time", value=true) -%} +{%- if new_contributors | length != 0 %} + +### New Contributors +{%- for contributor in new_contributors %} +* @{{ contributor.username }} made their first contribution in \ + [#{{ contributor.pr_number }}](https://github.com/kubeflow/trainer/pull/{{ contributor.pr_number }}) +{%- endfor %} +{%- endif %} +{%- endif -%} + +{% raw %}\n{% endraw %} +""" + +trim = true + +footer = "" + +[git] +conventional_commits = false +filter_unconventional = false +split_commits = false + +# Only stable release tags +tag_pattern = "^v?[0-9]+\\.[0-9]+\\.[0-9]+$" +ignore_tags = ".*-(alpha|beta|rc).*" + +# Manually define groups based on conventional patterns +commit_parsers = [ + { message = "^feat(\\(.*\\))?:", group = "🚀 Features" }, + { message = "^fix(\\(.*\\))?:", group = "🐛 Bug Fixes" }, + { message = "^chore(\\(.*\\))?:", group = "⚙️ Miscellaneous Tasks" }, + { message = "^revert(\\(.*\\))?:", group = "⏪ Reverts" }, + { message = ".*", skip = true }, +] diff --git a/docs/release/README.md b/docs/release/README.md index ca95996140..11bd565fe0 100644 --- a/docs/release/README.md +++ b/docs/release/README.md @@ -1,201 +1,70 @@ -# Releasing the Kubeflow Trainer +# Releasing Kubeflow Trainer -## Prerequisite +## Prerequisites -- [Write](https://docs.github.com/en/organizations/managing-access-to-your-organizations-repositories/repository-permission-levels-for-an-organization#permission-levels-for-repositories-owned-by-an-organization) - permission for the Kubeflow Trainer repository. +- Docker available locally (required by `hack/release.sh` for changelog generation with `git-cliff`). +- `GITHUB_TOKEN` exported locally (recommended to avoid GitHub API rate limits while generating changelog): -- Maintainer access to [the Kubeflow Trainer API Python modules](https://pypi.org/project/kubeflow-trainer-api/). - -- Create a [GitHub Token](https://docs.github.com/en/github/authenticating-to-github/keeping-your-account-and-data-secure/creating-a-personal-access-token). - -- Install `PyGithub` to generate the [Changelog](./../../CHANGELOG.md): - - ``` - pip install PyGithub>=1.55 - ``` - -- Install `twine` and `build` to publish the SDK package: - - ``` - pip install twine>=6.1.0 - pip install build>=1.3.0 - ``` - - - Create a [PyPI Token](https://pypi.org/help/#apitoken) to publish Training SDK. - - - Add the following config to your `~/.pypirc` file: - - ``` - [pypi] - username = __token__ - password = - ``` - -## Versioning policy - -Kubeflow Trainer version format follows [Semantic Versioning](https://semver.org/). -Kubeflow Trainer 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, Kubeflow Trainer 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 - -Kubeflow Trainer releases are tagged with tags like `vX.Y.Z`, for example `v2.0.0`. - -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, -`v2.0.0` release should be on `release-2.0` 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 Kubeflow Trainer 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. - -1. Fetch the upstream changes into your local directory: - - ``` - git fetch upstream - ``` - -1. Checkout into the release branch: - - ``` - git checkout release-X.Y - git rebase upstream/release-X.Y - ``` - -### Release Kubeflow Trainer API Modules - -1. Update the `API_VERSION` in [the `gen-api.sh` file](../../hack/python-api/gen-api.sh). - - You must follow this semantic `X.Y.ZrcN` for the RC or `X.Y.Z` for the public release. - - For example: - - ```sh - API_VERSION = "2.1.0rc0" - ``` - -1. Generate and publish the Kubeflow Trainer Python API models: - - ``` - make generate - cd api/python_api - rm -rf dist - python -m build - twine upload dist/* - cd ../.. - ``` - -### Release Kubeflow Trainer images - -1. Update the image tag in Kubeflow Trainer manifests: - - - [manager](../../manifests/overlays/manager/kustomization.yaml) - - [runtimes](../../manifests/overlays/runtimes/kustomization.yaml) - - [data-cache](../../manifests/overlays/data-cache/kustomization.yaml) - - `CACHE_IMAGE` in [the torch-distributed-with-cache runtime](../../manifests/base/runtimes/data-cache/torch_distributed_with_cache.yaml) - - The image tags must be equal to the release version, for example: `newTag: v2.0.0-rc.1` - - Additionally, update the public ConfigMap version used by the manager overlay: - - - In `manifests/overlays/manager/kustomization.yaml`, set the `kubeflow_trainer_version` literal - under `configMapGenerator` to the release version with `v` prefix (for example, - `kubeflow_trainer_version=v2.0.0-rc.1`). Update this value whenever cutting a new release. - -1. Update the [Helm charts](../../charts/kubeflow-trainer/Chart.yaml) version. - - Ensure that the version number does not include the `v` prefix. - -1. Commit your changes, tag the commit, and push it to upstream. - - - For the RC tag run the following: - - ```sh - git add . - git commit -s -m "Kubeflow Trainer Official Release vX.Y.Z-rc.N" - git tag vX.Y.Z-rc.N - git push upstream release-X.Y --tags - ``` - - - For the official release run the following: - - ```sh - git add . - git commit -s -m "Kubeflow Trainer Official Release vX.Y.Z" - git tag vX.Y.Z - git push upstream release-X.Y --tags - ``` - -For example, check [this release commit](https://github.com/kubeflow/trainer/commit/332ad3939a000ecf837a37ecb1a56e3b0494562c). - -### Verify the image publish - -Check that all GitHub actions on your release branch is complete and images are published to the -registry. In case of failure, manually restart the GitHub actions. - -For example, you can see the -[completed GitHub actions on the `v2.0.0-rc.1` release](https://github.com/kubeflow/trainer/commit/7122fc1a0f02e3d97b1da2a8eb31148e10b286c9) - -### Update the Master Branch - -Create the PR in the master branch with the following changes: - -1. Update the changelog by running: +```bash +export GITHUB_TOKEN= +``` - ``` - python docs/release/changelog.py --token= --range=.. - ``` +- If you are working from a fork, ensure upstream tags are available locally before running release: - 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-1` branch. - For example: `--range=v2.0.1..v2.1.0` +```bash +git remote add upstream https://github.com/kubeflow/trainer.git # if missing +git fetch upstream --tags +git fetch origin --tags +``` - Otherwise, your `previous-release` is equal to the latest release on the `release-X.Y` branch. - For example: `--range=v2.0.0-rc.0..v2.0.0-rc.1` +## Prepare a release PR - Group PRs in the changelog into features, bug fixes, misc, etc. +Run the release target from your working branch: - Check this example: [v2.0.0-rc.0](https://github.com/kubeflow/trainer/blob/master/CHANGELOG.md#v200-rc0-2025-06-10) +```bash +make release VERSION=X.Y.Z GITHUB_TOKEN= +# or +make release VERSION=X.Y.Z-rc.N GITHUB_TOKEN= +``` -1. Bump the `API_VERSION` in [the `gen-api.sh` file](../../hack/python-api/gen-api.sh) and the - `version` in [the Helm charts](../../charts/kubeflow-trainer/Chart.yaml) to the latest release. +`make release` exports `GITHUB_TOKEN` and invokes `hack/release.sh`. The release script will: -Finally, submit a PR with the updated files. +1. Validate the version format. +2. Verify the tag `vX.Y.Z` (or `vX.Y.Z-rc.N`) does not already exist. +3. Update: + - `VERSION` + - image tags in `manifests/*.yaml` (`newTag` and pinned `ghcr.io/...:latest`) + - `charts/kubeflow-trainer/Chart.yaml` (`version`) + - `CHANGELOG.md` (prepends unreleased section using `git-cliff`) +4. Run `make generate`. +5. Create a signed-off commit: -### Create GitHub Release +```text +Release vX.Y.Z +``` -After the changelog PR is merged, create the GitHub release using your tag. +Push the branch and open a PR to `master`. -Set as a pre-release for the release candidates (e.g. RC.1) or set as the latest release for the -official releases. +## PR validation (`check-release.yaml`) -For the GitHub release description you can use the same PR list as in changelog. You can use this -script to remove links from the GitHub user names (GitHub releases render the user names without -links): +When a PR to `master` changes `VERSION`, CI validates: -```sh -perl -pi -e 's/\[@([^\]]+)\]\(https:\/\/github\.com\/\1\)/@\1/g' changelog.md -``` +1. `VERSION` matches semver format. +2. The tag does not already exist. +3. Every `manifests` `newTag` equals `v`. +4. `charts/kubeflow-trainer/Chart.yaml` version equals `VERSION` without leading `v`. +5. `api/python_api/kubeflow_trainer_api/__init__.py` `__version__` equals `VERSION` without leading `v`. -### Announce the new release +## Release automation after merge (`release.yaml`) -Post the announcement for the new Kubeflow Trainer RC or official release in: +When the `VERSION` change is merged into `master`, the workflow: -- [#kubeflow-trainer](https://www.kubeflow.org/docs/about/community/#slack-channels) Slack channel. -- [kubeflow-discuss](https://www.kubeflow.org/docs/about/community/#kubeflow-mailing-list) mailing list. +1. Re-validates version and manifest tags. +2. Builds and validates Python package artifacts. +3. Publishes the package to PyPI (`kubeflow-trainer-api`). +4. Creates release branch `release-` if it does not exist. +5. Creates and pushes git tag `v`. +6. Creates GitHub Release using generated changelog. +7. Dispatches: + - `build-and-push-images.yaml` for container image publishing + - `publish-helm-charts.yaml` for Helm chart publishing diff --git a/docs/release/changelog.py b/docs/release/changelog.py deleted file mode 100644 index 22c641d9a7..0000000000 --- a/docs/release/changelog.py +++ /dev/null @@ -1,72 +0,0 @@ -import argparse - -from github import Github - -REPO_NAME = "kubeflow/trainer" -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 = list(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 reversed(commits): - # 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 - 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 = [ - "# Changelog" "\n\n", - "# [{}]({}) ({})".format(current_release, release_url, release_date), - "\n\n", - "## TODO: Group PRs into Breaking Changes, New Features, Bug fixes, Misc, etc. " - + "For example: [v1.7.0](https://github.com/kubeflow/trainer/releases/tag/v1.7.0)", - "\n\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[0:0] + 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") diff --git a/hack/release.sh b/hack/release.sh new file mode 100755 index 0000000000..5d3e60d077 --- /dev/null +++ b/hack/release.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash + +# Copyright 2024 The Kubeflow 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. + +# This shell is used to prepare a release commit for X.Y.Z version. + +set -o errexit +set -o nounset +set -o pipefail + +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "You must follow this format: X.Y.Z or X.Y.Z-rc.N" + exit 1 +fi + +NEW_VERSION=$(echo "$1" | tr -d '\n' | tr -d ' ') + +if [[ ! "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Version format is invalid. Use: X.Y.Z or X.Y.Z-rc.N" + exit 1 +fi + +TAG="v$NEW_VERSION" +export TAG + +REPO_ROOT="$(dirname "$0")/.." +VERSION_FILE="$REPO_ROOT/VERSION" +MANIFESTS_DIR="$REPO_ROOT/manifests" +CHART_DIR="$REPO_ROOT/charts/kubeflow-trainer" +CHART_FILE="$CHART_DIR/Chart.yaml" +PYTHON_API_VERSION_FILE="$REPO_ROOT/api/python_api/kubeflow_trainer_api/__init__.py" + +# Verify tag doesn't already exist +git fetch --tags +if git tag --list | grep -q "^${TAG}$"; then + echo "Tag: ${TAG} already exists. Release can't be published." + exit 1 +fi + +echo -e "\nPreparing release commit for ${TAG}\n" + +echo -n "v$NEW_VERSION" > "$VERSION_FILE" +echo "Updated VERSION file to $NEW_VERSION" + +# Update image tags in manifests +find "$MANIFESTS_DIR" -type f -name '*.yaml' -exec sed -i "s/newTag: .*/newTag: $TAG/" {} + +echo "Updated image tags in manifests to $TAG" + +echo "Pinning ghcr.io image references in manifests to $TAG" +CHANGED_FILES=$(grep -REl "ghcr\.io/kubeflow/trainer/[A-Za-z0-9._/-]+:latest" "$MANIFESTS_DIR" || true) +if [ -n "$CHANGED_FILES" ]; then + while IFS= read -r f; do + sed -i -E "s|(ghcr\.io/kubeflow/trainer/[A-Za-z0-9._/-]+):latest|\\1:${TAG}|g" "$f" + echo " Updated ${f#$MANIFESTS_DIR/}" + done <<< "$CHANGED_FILES" +else + echo " No ghcr.io references pinned to :latest found." +fi + +if [ ! -f "$CHART_FILE" ]; then + echo "Helm chart file not found: $CHART_FILE" + exit 1 +fi + +python3 - "$CHART_FILE" "$NEW_VERSION" <<'PYTHON' +import pathlib +import re +import sys + +chart_path = pathlib.Path(sys.argv[1]) +new_version = sys.argv[2] +data = chart_path.read_text() +pattern = re.compile(r"^version:\s*.+$", re.MULTILINE) + +if not pattern.search(data): + print("Unable to locate version field in chart file.") + sys.exit(1) + +chart_path.write_text(pattern.sub(f"version: {new_version}", data, count=1)) +PYTHON +echo "Updated Helm chart version to $NEW_VERSION" + +CHANGELOG_PATH="$REPO_ROOT/CHANGELOG.md" +echo "Generating changelog for $TAG" +ABSOLUTE_REPO_ROOT="$(cd "$REPO_ROOT" && pwd)" +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "WARNING: GITHUB_TOKEN not set. Set it to avoid GitHub API rate limits." + echo "Export GITHUB_TOKEN before running this script: export GITHUB_TOKEN=your_token" +fi + +# Generate and prepend new changelog section +TEMP_FILE=$(mktemp) +docker run --rm -u "$(id -u):$(id -g)" -v "$ABSOLUTE_REPO_ROOT:/app" \ + -e "GITHUB_TOKEN=$GITHUB_TOKEN" -w /app \ + "ghcr.io/orhun/git-cliff/git-cliff:latest" --unreleased --tag "$TAG" -o - > "$TEMP_FILE" + +if [ -f "$CHANGELOG_PATH" ]; then + sed -i "1 r $TEMP_FILE" "$CHANGELOG_PATH" +else + { echo "# Changelog"; cat "$TEMP_FILE"; } > "$CHANGELOG_PATH" +fi +rm "$TEMP_FILE" +echo "Changelog generated at $CHANGELOG_PATH" + +echo "Running make generate" +make -C "$REPO_ROOT" generate +echo "Completed make generate" + +git add "$VERSION_FILE" "$MANIFESTS_DIR" "$CHART_DIR" "$PYTHON_API_VERSION_FILE" "$CHANGELOG_PATH" +git commit -s -m "Release $TAG" + +echo -e "\nRelease commit for $TAG created successfully." +echo "Next steps:" +echo " 1. Push your branch to your fork" +echo " 2. Open a PR against master" +echo " 3. Once merged, GitHub Actions will create the release branch and tag"