diff --git a/.github/workflows/update-pre-release-branches.yaml b/.github/workflows/update-pre-release-branches.yaml index 8a86e28f2e..7a27ab62f1 100755 --- a/.github/workflows/update-pre-release-branches.yaml +++ b/.github/workflows/update-pre-release-branches.yaml @@ -32,46 +32,14 @@ jobs: python-version: '3.10' - name: Install Python dependencies run: pip3 install packaging requests - - name: Determine outstanding pre-release - id: determine - run: | - preRelease=`python3 ./build-scripts/k8s_releases.py get_outstanding_prerelease` - echo "preRelease=$preRelease" >> "$GITHUB_OUTPUT" - - if [[ -n "$preRelease" ]]; then - branch="autoupdate/$preRelease" - fi - echo "branch=$branch" >> "$GITHUB_OUTPUT" - name: Define git credentials run: | # Needed to create commits. git config --global user.name "Github Actions" git config --global user.email "worker@org.com" - - name: 'Update k8s component version: ${{ steps.determine.outputs.preRelease }}' - if: ${{ steps.determine.outputs.preRelease }} != '' - run: | - echo ${{ steps.determine.outputs.preRelease }} > ./build-scripts/components/kubernetes/version - git add ./build-scripts/components/kubernetes/version - git commit -m "Update k8s version to ${{ steps.determine.outputs.preRelease }}" - - name: Create pre-release branch ${{ steps.determine.outputs.branch }} - if: ${{ steps.determine.outputs.preRelease }} != '' + - name: 'Prepare pre-release git branch' run: | - git checkout -b ${{ steps.determine.outputs.branch }} - git push origin --force ${{ steps.determine.outputs.branch }} + python3 ./build-scripts/k8s_release.py prepare_prerelease_git_branch - name: Clean obsolete branches run: | - git fetch origin - - # Log the latest release for reference. - latestRelease=`python3 ./build-scripts/k8s_releases.py get_latest_release` - echo "Latest k8s release: $latestRelease" - - for outstandingPreRelease in `python3 ./build-scripts/k8s_releases.py get_obsolete_prereleases`; do - branch="autoupdate/${outstandingPreRelease}" - if git branch -r | grep "origin/$branch"; then - echo "Cleaning up obsolete pre-release branch: $branch" - git push origin --delete $branch - else - echo "Obsolete branch not found, skpping: $branch" - fi - done + python3 ./build-scripts/k8s_release.py clean_obsolete_git_branches diff --git a/build-scripts/k8s_release.py b/build-scripts/k8s_release.py new file mode 100755 index 0000000000..156cf9131b --- /dev/null +++ b/build-scripts/k8s_release.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 + +import argparse +import json +import logging +import os +import re +import subprocess +from typing import List, Optional + +import requests +from packaging.version import Version + +K8S_TAGS_URL = "https://api.github.com/repos/kubernetes/kubernetes/tags" +EXEC_TIMEOUT = 60 + +LOG = logging.getLogger(__name__) + + +def _url_get(url: str) -> str: + r = requests.get(url, timeout=5) + r.raise_for_status() + return r.text + + +def get_k8s_tags() -> List[str]: + """Retrieve semantically ordered k8s releases, newest to oldest.""" + response = _url_get(K8S_TAGS_URL) + tags_json = json.loads(response) + if len(tags_json) == 0: + raise ValueError("No k8s tags retrieved.") + tag_names = [tag["name"] for tag in tags_json] + # Github already sorts the tags semantically but let's not rely on that. + tag_names.sort(key=lambda x: Version(x), reverse=True) + return tag_names + + +# k8s release naming: +# * alpha: v{major}.{minor}.{patch}-alpha.{version} +# * beta: v{major}.{minor}.{patch}-beta.{version} +# * rc: v{major}.{minor}.{patch}-rc.{version} +# * stable: v{major}.{minor}.{patch} +def is_stable_release(release: str): + return "-" not in release + + +def get_latest_stable() -> str: + k8s_tags = get_k8s_tags() + for tag in k8s_tags: + if is_stable_release(tag): + return tag + raise ValueError("Couldn't find stable release, received tags: %s" % k8s_tags) + + +def get_latest_release() -> str: + k8s_tags = get_k8s_tags() + return k8s_tags[0] + + +def get_outstanding_prerelease() -> Optional[str]: + latest_release = get_latest_release() + if not is_stable_release(latest_release): + return latest_release + # The latest release is a stable release, no outstanding pre-release. + return None + + +def get_obsolete_prereleases() -> List[str]: + """Return obsolete K8s pre-releases. + + We only keep the latest pre-release if there is no corresponding stable + release. All previous pre-releases are discarded. + """ + k8s_tags = get_k8s_tags() + if not is_stable_release(k8s_tags[0]): + # Valid pre-release + k8s_tags = k8s_tags[1:] + # Discard all other pre-releases. + return [tag for tag in k8s_tags if not is_stable_release(tag)] + + +def _exec(cmd: List[str], check=True, timeout=EXEC_TIMEOUT, cwd=None): + """Run the specified command and return the stdout/stderr output as a tuple.""" + LOG.debug("Executing: %s, cwd: %s.", cmd, cwd) + proc = subprocess.run( + cmd, check=check, timeout=timeout, cwd=cwd, capture_output=True, text=True + ) + return proc.stdout, proc.stderr + + +def _branch_exists( + branch_name: str, remote=True, project_basedir: Optional[str] = None +): + cmd = ["git", "branch"] + if remote: + cmd += ["-r"] + + stdout, stderr = _exec(cmd, cwd=project_basedir) + return branch_name in stdout + + +def get_prerelease_git_branch(prerelease: str): + """Retrieve the name of the k8s-snap git branch for a given k8s pre-release.""" + prerelease_re = r"v\d+\.\d+\.\d-(?:alpha|beta|rc)\.\d+" + if not re.match(prerelease_re, prerelease): + raise ValueError("Unexpected k8s pre-release name: %s", prerelease) + + # Use a single branch for all pre-releases of a given risk level, + # e.g. v1.33.0-alpha.0 -> autoupdate/v1.33.0-alpha + branch = f"autoupdate/{prerelease}" + return re.sub(r"(-[a-zA-Z]+)\.[0-9]+", r"\1", branch) + + +def _update_prerelease_k8s_component(project_basedir: str, k8s_version: str): + if not project_basedir: + raise ValueError("Project base directory unspecified.") + k8s_component_path = os.path.join( + project_basedir, "build-scripts", "components", "kubernetes", "version" + ) + with open(k8s_component_path, "w") as f: + f.write(k8s_version) + + +def prepare_prerelease_git_branch(project_basedir: str, remote: str = "origin"): + prerelease = get_outstanding_prerelease() + if not prerelease: + LOG.info("No outstanding k8s pre-release.") + + _update_prerelease_k8s_component(project_basedir, str(prerelease)) + + _exec( + ["git", "add", "./build-scripts/components/kubernetes/version"], + cwd=project_basedir, + ) + _exec( + ["git", "commit", "-m", f"Update k8s version to {prerelease}"], + cwd=project_basedir, + ) + + branch = get_prerelease_git_branch(str(prerelease)) + _exec(["git", "checkout", "-b", branch]) + _exec(["git", "push", remote, branch, "--force"]) + + +def clean_obsolete_git_branches(project_basedir: str, remote="origin"): + """Remove obsolete pre-release git branches.""" + latest_release = get_latest_release() + LOG.info("Latest k8s release: %s", latest_release) + + _exec(["git", "fetch", remote], cwd=project_basedir) + + obsolete_prereleases = get_obsolete_prereleases() + for outstanding_prerelease in obsolete_prereleases: + branch = get_prerelease_git_branch(outstanding_prerelease) + + if _branch_exists( + f"{remote}/{branch}", remote=True, project_basedir=project_basedir + ): + LOG.info("Cleaning up obsolete pre-release branch: %s", branch) + _exec(["git", "push", remote, "--delete", branch], cwd=project_basedir) + else: + LOG.info("Obsolete branch not found, skpping: %s", branch) + + +if __name__ == "__main__": + logging.basicConfig(format="%(asctime)s %(message)s", level=logging.DEBUG) + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="subparser", required=True) + + cmd = subparsers.add_parser("clean_obsolete_git_branches") + cmd.add_argument( + "--project-basedir", + dest="project_basedir", + help="The k8s-snap project base directory.", + default=os.getcwd(), + ) + cmd.add_argument("--remote", dest="remote", help="Git remote.", default="origin") + + cmd = subparsers.add_parser("prepare_prerelease_git_branch") + cmd.add_argument( + "--project-basedir", + dest="project_basedir", + help="The k8s-snap project base directory.", + default=os.getcwd(), + ) + cmd.add_argument("--remote", dest="remote", help="Git remote.", default="origin") + + subparsers.add_parser("get_outstanding_prerelease") + subparsers.add_parser("remove_obsolete_prereleases") + + kwargs = vars(parser.parse_args()) + f = locals()[kwargs.pop("subparser")] + out = f(**kwargs) + if isinstance(out, (list, tuple)): + for item in out: + print(item) + else: + print(out or "") diff --git a/build-scripts/k8s_releases.py b/build-scripts/k8s_releases.py deleted file mode 100755 index ddec88bbd3..0000000000 --- a/build-scripts/k8s_releases.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 - -import json -import sys -from typing import List, Optional - -import requests -from packaging.version import Version - -K8S_TAGS_URL = "https://api.github.com/repos/kubernetes/kubernetes/tags" - - -def _url_get(url: str) -> str: - r = requests.get(url, timeout=5) - r.raise_for_status() - return r.text - - -def get_k8s_tags() -> List[str]: - """Retrieve semantically ordered k8s releases, newest to oldest.""" - response = _url_get(K8S_TAGS_URL) - tags_json = json.loads(response) - if len(tags_json) == 0: - raise Exception("No k8s tags retrieved.") - tag_names = [tag['name'] for tag in tags_json] - # Github already sorts the tags semantically but let's not rely on that. - tag_names.sort(key=lambda x: Version(x), reverse=True) - return tag_names - - -# k8s release naming: -# * alpha: v{major}.{minor}.{patch}-alpha.{version} -# * beta: v{major}.{minor}.{patch}-beta.{version} -# * rc: v{major}.{minor}.{patch}-rc.{version} -# * stable: v{major}.{minor}.{patch} -def is_stable_release(release: str): - return "-" not in release - - -def get_latest_stable() -> str: - k8s_tags = get_k8s_tags() - for tag in k8s_tags: - if is_stable_release(tag): - return tag - raise Exception( - "Couldn't find stable release, received tags: %s" % k8s_tags) - - -def get_latest_release() -> str: - k8s_tags = get_k8s_tags() - return k8s_tags[0] - - -def get_outstanding_prerelease() -> Optional[str]: - latest_release = get_latest_release() - if not is_stable_release(latest_release): - return latest_release - # The latest release is a stable release, no outstanding pre-release. - return None - - -def get_obsolete_prereleases() -> List[str]: - """Return obsolete K8s pre-releases. - - We only keep the latest pre-release if there is no corresponding stable - release. All previous pre-releases are discarded. - """ - k8s_tags = get_k8s_tags() - if not is_stable_release(k8s_tags[0]): - # Valid pre-release - k8s_tags = k8s_tags[1:] - # Discard all other pre-releases. - return [tag for tag in k8s_tags if not is_stable_release(tag)] - - -# Rudimentary CLI that exposes these functions to shell scripts or GH actions. -if __name__ == "__main__": - if len(sys.argv) != 2: - sys.stderr.write(f"Usage: {sys.argv[0]} \n") - sys.exit(1) - f = locals()[sys.argv[1]] - out = f() - if isinstance(out, (list, tuple)): - for item in out: - print(item) - else: - print(out or "")