diff --git a/.github/scripts/make_release.py b/.github/scripts/make_release.py new file mode 100644 index 000000000..01d166713 --- /dev/null +++ b/.github/scripts/make_release.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""This script is part of the GitHub CI make-release pipeline + +It reads the version number from climada/_version.py and then uses the `gh` cli +to create the new release. + +""" +import glob +import re +import subprocess + + +def get_version() -> str: + """Return the current version number, based on the _version.py file.""" + [version_file] = glob.glob("climada*/_version.py") + with open(version_file, 'r', encoding="UTF-8") as vfp: + content = vfp.read() + regex = r'^__version__\s*=\s*[\'\"](.*)[\'\"]\s*$' + mtch = re.match(regex, content) + return mtch.group(1) + + +def make_release(): + """run `gh release create vX.Y.Z""" + version_number = get_version() + subprocess.run( + ["gh", "release", "create", "--generate-notes", f"v{version_number}"], + check=True, + ) + + +if __name__ == "__main__": + make_release() diff --git a/.github/scripts/prepare_release.py b/.github/scripts/prepare_release.py new file mode 100644 index 000000000..a18c823c3 --- /dev/null +++ b/.github/scripts/prepare_release.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""This script is part of the GitHub CI make-release pipeline + +The following preparation steps are executed: + +- update version numbers in _version.py and setup.py +- purge the "Unreleased" section of CHANGELOG.md and rename it to the new version number +- copy the README.md file to doc/misc/README.md, + but without the badges as they interfere with the sphinx doc builder + +All changes are immediately commited to the repository. +""" + +import glob +import json +import re +import subprocess +import time + + +def get_last_version() -> str: + """Return the version number of the last release.""" + json_string = ( + subprocess.run( + ["gh", "release", "view", "--json", "tagName"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + .stdout.decode("utf8") + .strip() + ) + + return json.loads(json_string)["tagName"] + + +def bump_version_number(version_number: str, level: str) -> str: + """Return a copy of `version_number` with one level number incremented.""" + major, minor, patch = version_number.split(".") + if level == "major": + major = str(int(major)+1) + elif level == "minor": + minor = str(int(minor)+1) + elif level == "patch": + patch = str(int(patch)+1) + else: + raise ValueError(f"level should be 'major', 'minor' or 'patch', not {level}") + return ".".join([major, minor, patch]) + + +def update_readme(_nvn): + """align doc/misc/README.md with ./README.md but remove the non-markdown header lines from """ + with open("README.md", 'r', encoding="UTF-8") as rmin: + lines = [line for line in rmin.readlines() if not line.startswith('[![')] + while not lines[0].strip(): + lines = lines[1:] + with open("doc/misc/README.md", 'w', encoding="UTF-8") as rmout: + rmout.writelines(lines) + return GitFile('doc/misc/README.md') + + +def update_changelog(nvn): + """Rename the "Unreleased" section, remove unused subsections and the code-freeze date, + set the release date to today""" + releases = [] + release_name = None + release = [] + section_name = None + section = [] + with open("CHANGELOG.md", 'r', encoding="UTF-8") as changelog: + for line in changelog.readlines(): + if line.startswith('#'): + if line.startswith('### '): + if section: + release.append((section_name, section)) + section_name = line[4:].strip() + section = [] + #print("tag:", section_name) + elif line.startswith('## '): + if section: + release.append((section_name, section)) + if release: + releases.append((release_name, release)) + release_name = line[3:].strip() + release = [] + section_name = None + section = [] + #print("release:", release_name) + else: + section.append(line) + if section: + release.append((section_name, section)) + if release: + releases.append((release_name, release)) + + with open("CHANGELOG.md", 'w', encoding="UTF-8") as changelog: + changelog.write("# Changelog\n\n") + for release_name, release in releases: + if release_name: + if release_name.lower() == "unreleased": + release_name = nvn + changelog.write(f"## {release_name}\n") + for section_name, section in release: + if any(ln.strip() for ln in section): + if section_name: + changelog.write(f"### {section_name}\n") + lines = [ln.strip() for ln in section if "code freeze date: " not in ln.lower()] + if not section_name and release_name.lower() == nvn: + print("setting date") + for i, line in enumerate(lines): + if "release date: " in line.lower(): + today = time.strftime("%Y-%m-%d") + lines[i] = f"Release date: {today}" + changelog.write("\n".join(lines).replace("\n\n", "\n")) + changelog.write("\n") + return GitFile('CHANGELOG.md') + + +def update_version(nvn): + """Update the _version.py file""" + [file_with_version] = glob.glob("climada*/_version.py") + regex = r'(^__version__\s*=\s*[\'\"]).*([\'\"]\s*$)' + return update_file(file_with_version, regex, nvn) + + +def update_setup(new_version_number): + """Update the setup.py file""" + file_with_version = "setup.py" + regex = r'(^\s+version\s*=\s*[\'\"]).*([\'\"]\s*,\s*$)' + return update_file(file_with_version, regex, new_version_number) + + +def update_file(file_with_version, regex, new_version_number): + """Replace the version number(s) in a file, based on a rgular expression.""" + with open(file_with_version, 'r', encoding="UTF-8") as curf: + lines = curf.readlines() + successfully_updated = False + for i, line in enumerate(lines): + mtch = re.match(regex, line) + if mtch: + lines[i] = f"{mtch.group(1)}{new_version_number}{mtch.group(2)}" + successfully_updated = True + if not successfully_updated: + raise RuntimeError(f"cannot determine version of {file_with_version}") + with open(file_with_version, 'w', encoding="UTF-8") as newf: + for line in lines: + newf.write(line) + return GitFile(file_with_version) + + +class GitFile(): + """Helper class for `git add`.""" + def __init__(self, path): + self.path = path + + def gitadd(self): + """run `git add`""" + _gitadd = subprocess.run( + ["git", "add", self.path], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.decode("utf8") + + +class Git(): + """Helper class for `git commit`.""" + def __init__(self): + _gitname = subprocess.run( + ["git", "config", "--global", "user.name", "'climada'"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.decode("utf8") + _gitemail = subprocess.run( + ["git", "config", "--global", "user.email", "'test.climada@gmail.com'"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.decode("utf8") + + def commit(self, new_version): + """run `git commit`.""" + try: + _gitcommit = subprocess.run( + ["git", "commit", "-m", f"'Automated update v{new_version}'"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.decode("utf8") + _gitpush = subprocess.run( + ["git", "push"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).stdout.decode("utf8") + except subprocess.CalledProcessError as err: + message = err.stdout.decode("utf8") + print("message:", message) + if "nothing to commit" in message: + print("repo already up to date with new version number") + else: + raise RuntimeError(f"failed to run: {message}") from err + + +def prepare_new_release(level): + """Prepare files for a new release on GitHub.""" + try: + last_version_number = get_last_version().strip("v") + except subprocess.CalledProcessError as err: + if "release not found" in err.stderr.decode("utf8"): + # The project doesn't have any releases yet. + last_version_number = "0.0.0" + else: + raise + new_version_number = bump_version_number(last_version_number, level) + + update_setup(new_version_number).gitadd() + update_version(new_version_number).gitadd() + update_changelog(new_version_number).gitadd() + update_readme(new_version_number).gitadd() + + Git().commit(new_version_number) + + +if __name__ == "__main__": + from sys import argv + try: + LEVEL = argv[1] + except IndexError: + LEVEL = "patch" + prepare_new_release(LEVEL) diff --git a/.github/scripts/setup_devbranch.py b/.github/scripts/setup_devbranch.py new file mode 100644 index 000000000..96ab60dbb --- /dev/null +++ b/.github/scripts/setup_devbranch.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""This script is part of the GitHub CI postrelease-setup-devbranch pipeline + +The following preparation steps are executed: + +- update version numbers in _version.py and setup.py: append a -dev suffix +- insert a vanilla "unreleased" section on top of CHANGELOG.md + +The changes are not commited to the repository. This is dealt with in the bash script +`setup_devbranch.sh` (which is also the caller of this script). +""" +import glob +import json +import re +import subprocess + + +def get_last_version() -> str: + """Return the version number of the last release.""" + json_string = ( + subprocess.run( + ["gh", "release", "view", "--json", "tagName"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + .stdout.decode("utf8") + .strip() + ) + + return json.loads(json_string)["tagName"] + + +def update_changelog(): + """Insert a vanilla "Unreleased" section on top.""" + with open("CHANGELOG.md", 'r', encoding="UTF-8") as changelog: + lines = changelog.readlines() + + if "## Unreleased" in lines: + return + + with open("CHANGELOG.md", 'w', encoding="UTF-8") as changelog: + changelog.write("""# Changelog + +## Unreleased + +Release date: YYYY-MM-DD + +Code freeze date: YYYY-MM-DD + +### Description + +### Dependency Changes + +### Added + +### Changed + +### Fixed + +### Deprecated + +### Removed + +""") + changelog.writelines(lines[2:]) + + +def update_version(nvn): + """Update the _version.py file""" + [file_with_version] = glob.glob("climada*/_version.py") + regex = r'(^__version__\s*=\s*[\'\"]).*([\'\"]\s*$)' + return update_file(file_with_version, regex, nvn) + + +def update_setup(new_version_number): + """Update the setup.py file""" + file_with_version = "setup.py" + regex = r'(^\s+version\s*=\s*[\'\"]).*([\'\"]\s*,\s*$)' + return update_file(file_with_version, regex, new_version_number) + + +def update_file(file_with_version, regex, new_version_number): + """Replace the version number(s) in a file, based on a rgular expression.""" + with open(file_with_version, 'r', encoding="UTF-8") as curf: + lines = curf.readlines() + successfully_updated = False + for i, line in enumerate(lines): + mtch = re.match(regex, line) + if mtch: + lines[i] = f"{mtch.group(1)}{new_version_number}{mtch.group(2)}" + successfully_updated = True + if not successfully_updated: + raise RuntimeError(f"cannot determine version of {file_with_version}") + with open(file_with_version, 'w', encoding="UTF-8") as newf: + for line in lines: + newf.write(line) + + +def setup_devbranch(): + """Adjust files after a release was published, i.e., + apply the canonical deviations from main in develop. + + Just changes files, all `git` commands are in the setup_devbranch.sh file. + """ + main_version = get_last_version().strip('v') + + dev_version = f"{main_version}-dev" + + update_setup(dev_version) + update_version(dev_version) + update_changelog() + + print(f"v{dev_version}") + + +if __name__ == "__main__": + setup_devbranch() diff --git a/.github/scripts/setup_devbranch.sh b/.github/scripts/setup_devbranch.sh new file mode 100644 index 000000000..1076a2d19 --- /dev/null +++ b/.github/scripts/setup_devbranch.sh @@ -0,0 +1,29 @@ +#!/usr/bin/bash + +set -e + +git config --global user.name 'climada' +git config --global user.email 'test.climada@gmail.com' + +git fetch --unshallow || echo cannot \"git fetch --unshallow \" +git checkout develop +git pull + +git checkout origin/main \ + setup.py \ + doc/misc/README.md \ + CHANGELOG.md \ + */_version.py + +release=`python .github/scripts/setup_devbranch.py` + +git add \ + setup.py \ + doc/misc/README.md \ + CHANGELOG.md \ + */_version.py + +git commit -m "setup develop branch for $release" + +git push || echo cannot \"git push\" +git checkout main diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml new file mode 100644 index 000000000..c8dab5790 --- /dev/null +++ b/.github/workflows/make-release.yml @@ -0,0 +1,37 @@ +# This workflow will create a new release from main + +# the new version number is the old one increased by 1 in the given level [major, minor, patch] +# first, some files are modified (_version.py, setup.py, CHANGELOG.md and doc/misc/README.md) +# then, a new release is created on GitHub, tagged with v[new version number] + +name: Create a new release + +on: + workflow_dispatch: + inputs: + level: + description: 'Release Level' + required: true + default: 'patch' + type: choice + options: + - major + - minor + - patch + +jobs: + github: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@master + with: + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + - name: Prepare new release + run: .github/scripts/prepare_release.py ${{ inputs.level }} + env: + GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + - name: Publish + run: .github/scripts/make_release.py + env: + GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/postrelease-setup-devbranch.yml b/.github/workflows/postrelease-setup-devbranch.yml new file mode 100644 index 000000000..176a07644 --- /dev/null +++ b/.github/workflows/postrelease-setup-devbranch.yml @@ -0,0 +1,24 @@ +# This workflow will re-initialize the develop branch after a release was published + +# the version number of setup.py and _version.py will be taken from main, but have a "-dev" appendix +# the CHANGELOG.md file will be updated and have a vanila "Unreleased" section +# the doc/misc/README.md file is taken over from main + +name: Post-release develop setup + +on: + release: + types: [published] + +jobs: + github: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@master + with: + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + - name: Update files + run: .github/scripts/setup_devbranch.sh + env: + GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 000000000..bdaab28a4 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }}