diff --git a/.github/scripts/clean_and_reindex_changelogs.py b/.github/scripts/clean_and_reindex_changelogs.py new file mode 100644 index 0000000000..f88cf3edbc --- /dev/null +++ b/.github/scripts/clean_and_reindex_changelogs.py @@ -0,0 +1,126 @@ +import argparse +import os +import sys + +from packaging.version import Version + +from utilities import unwanted_versions + +CHANGELOG_PREFIX = "changelog_v" +CHANGELOG_TOC_START_MARKER = " # CHANGELOG_TOC_START_MARKER\n" +CHANGELOG_TOC_END_MARKER = " # CHANGELOG_TOC_END_MARKER\n" + + +def _get_changelog_file_names(location): + current_dir = os.path.abspath(os.path.curdir) + working_dir = os.path.join(current_dir, location) + os.chdir(working_dir) + + changelogs = [] + for root, dirs, files in os.walk(".", topdown=False): + for name in files: + parts = os.path.splitext(name) + if len(parts) == 2 and parts[0].startswith(CHANGELOG_PREFIX) and parts[1] == ".rst": + # changelogs.append(os.path.join(root, name)) + changelogs.append(parts[0].replace(CHANGELOG_PREFIX, '')) + + os.chdir(current_dir) + + return changelogs + + +def _remove_changelogs(location, versions): + for version_ in versions: + os.remove(os.path.join(location, f"{CHANGELOG_PREFIX}{version_}.rst")) + + +def _create_text(versions, with_directory=False): + if with_directory: + changelog_dir = "changelogs/" + else: + changelog_dir = "" + + text = [ + "\n", + "Changelogs\n", + "==========\n", + "\n", + ".. toctree::\n", + "\n", + ] + for v in versions: + text.append(f" {changelog_dir}{CHANGELOG_PREFIX}{v}\n") + text.append("\n") + + return text + + +def _write_changelog_index_file(file_name, versions): + text = _create_text(versions) + + with open(file_name, 'w') as f: + for line in text: + f.write(line) + + +def _write_main_index_file(file_name, versions): + with open(file_name) as f: + lines = f.readlines() + + start_index = lines.index(CHANGELOG_TOC_START_MARKER) + # We subtract one line to include the restructured text comment marker. + end_index = lines.index(CHANGELOG_TOC_END_MARKER) - 1 + target_lines = [j for i, j in enumerate(lines) + if not start_index < i < end_index] + + text = _create_text(versions, with_directory=True) + + # Insert our generated changelog toc into current file content. + insert_index = start_index + 1 + target_lines[insert_index:insert_index] = text + + with open(file_name, 'w') as f: + for line in target_lines: + f.write(line) + + +def process_arguments(): + parser = argparse.ArgumentParser(description="Update the table of contents to match existing changelogs.") + parser.add_argument("local_repo", + help="The location of the project repository. Absolute path or relative path relative to the " + "current working directory.") + + return parser + + +def main(): + parser = process_arguments() + args = parser.parse_args() + repo_relative_path = args.local_repo + + cur_dir = os.path.abspath(os.curdir) + + repo_path = os.path.join(cur_dir, repo_relative_path) + if not os.path.isfile(os.path.join(repo_path, 'CMakeLists.txt')): + sys.exit(2) + + if not os.path.isdir(os.path.join(repo_path, 'docs', 'changelogs')): + sys.exit(3) + + changelogs = _get_changelog_file_names(os.path.join(repo_path, 'docs', 'changelogs')) + sorted_changelogs = sorted(changelogs, key=Version) + sorted_changelogs.reverse() + + remove_versions = unwanted_versions(sorted_changelogs, depth=3) + wanted_changelogs = list(filter(lambda x: x not in remove_versions, sorted_changelogs)) + _remove_changelogs(os.path.join(repo_path, 'docs', 'changelogs'), remove_versions) + + main_index_file = os.path.join(repo_path, 'docs', 'index.rst') + _write_main_index_file(main_index_file, wanted_changelogs) + + changelog_index_file = os.path.join(repo_path, 'docs', 'changelogs', 'index.rst') + _write_changelog_index_file(changelog_index_file, wanted_changelogs) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py new file mode 100644 index 0000000000..fbeb6aa653 --- /dev/null +++ b/.github/scripts/generate_changelog.py @@ -0,0 +1,199 @@ +import argparse +import json +import os +import re +import subprocess +import requests +from packaging import version as semver + +GITHUB_API = "https://api.github.com" +IGNORED_CONTRIBUTORS = ['abi-git-user', 'github-actions[bot]'] + +HEADERS = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {os.environ['GH_TOKEN']}" +} + +TAG_PATTERN = re.compile(r"^source-v(\d+\.\d+\.\d+)$") +LABEL_PRIORITY = [] + + +class Contributor: + + def __init__(self, avatar_url, user_url): + self._avatar_url = avatar_url + self._user_url = user_url + + @property + def avatar_url(self): + return self._avatar_url + + @property + def user_url(self): + return self._user_url + + def __eq__(self, other): + return isinstance(other, Contributor) and \ + self.user_url == other.user_url and \ + self.avatar_url == other.avatar_url + + def __hash__(self): + return hash((self.user_url, self.avatar_url)) + + +def run(cmd): + return subprocess.check_output(cmd, text=True).strip() + + +def find_previous_source_tag(end_tag): + tags = run(["git", "tag"]).splitlines() + valid = [] + + m = None if end_tag == "HEAD" else TAG_PATTERN.match(end_tag) + end_version = semver.parse(m.group(1)) if m else None + + for t in tags: + m = TAG_PATTERN.match(t) + if not m: + continue + + v = semver.parse(m.group(1)) + if end_version and v >= semver.parse(end_version): + continue + + valid.append((v, t)) + + if not valid: + raise RuntimeError("No valid source-vX.Y.Z tags found") + + valid.sort(reverse=True) + return valid[0][1] + + +def get_merge_commits(start, end="HEAD"): + log = run([ + "git", "log", + f"{start}..{end}", + "--merges", + "--first-parent", + "--pretty=%s" + ]) + return log.splitlines() + + +def extract_pr_numbers(messages): + prs = [] + for msg in messages: + m = re.search(r"#(\d+)", msg) + if m: + prs.append(int(m.group(1))) + return prs + + +def fetch_pr(org_repo, pr_number): + url = f"{GITHUB_API}/repos/{org_repo}/pulls/{pr_number}" + r = requests.get(url, headers=HEADERS) + r.raise_for_status() + return r.json() + + +def choose_primary_label(labels): + names = [l["name"] for l in labels] + for p in LABEL_PRIORITY: + if p in names: + return p + return names[0] if names else "No category" + + +def extract_summary(pr): + primary_label = choose_primary_label(pr["labels"]) + secondary_labels = [l["name"] for l in pr["labels"] if l["name"] != primary_label] + return { + "title": pr["title"], + "label": primary_label, + "secondary_labels": secondary_labels, + "number": pr["number"], + "url": pr["html_url"], + "user": pr["user"]["login"], + "user_url": pr["user"]["html_url"], + "avatar_url": pr["user"]["avatar_url"], + } + + +def write_out_to_changelog_file(sorted_summaries, tag_end): + current_label = '' + file_name = f'changelog_{tag_end}.rst' + with open(file_name, 'w') as f: + + changelog_title = f'libCellML {tag_end} Changelog' + f.write(f'{changelog_title}\n') + f.write('=' * len(changelog_title)) + f.write('\n') + + contributors = [] + for summary in sorted_summaries: + if current_label != summary['label']: + current_label = summary['label'] + f.write(f'\n{current_label}\n') + f.write('-' * len(current_label)) + f.write('\n\n') + + title = summary['title'][:-1] if summary['title'].endswith('.') else summary['title'] + user_link = f'`@{summary["user"]} <{summary["user_url"]}>`_' + pr_link = f'`#{summary["number"]} <{summary["url"]}>`_' + suffix = "" + if summary.get("secondary_labels"): + suffix = f" (also: {', '.join(summary['secondary_labels'])})" + f.write(f'* {title}{suffix} by {user_link} [{pr_link}].\n') + contributors.append(Contributor(summary['avatar_url'], summary['user_url'])) + + contributors = list(set(contributors)) + if contributors: + section_title = 'Contributors' + f.write(f'\n{section_title}\n') + f.write('-' * len(section_title)) + f.write('\n\n') + for contributor in contributors: + f.write(f'.. image:: {contributor.avatar_url}\n :target: {contributor.user_url}' + f'\n :height: 32\n :width: 32\n') + + print(f'Changelog written to: {file_name}.') + + +def process_arguments(): + parser = argparse.ArgumentParser(description="Create a simple change log from merged pull requests from a GitHub " + "project.") + parser.add_argument("-p", "--project", + help="GitHub project to work with, default 'cellml/libcellml'.", default="cellml/libcellml") + # parser.add_argument("-r", "--local-repo", + # help="The location of the project repository. Absolute path or relative path relative to the " + # "current working directory.", default=None) + parser.add_argument("-t", "--tag-end-display-name", + help="Override the tag end label display name.", default=None) + parser.add_argument("tag_start", nargs='?', default="PREV",) + parser.add_argument("tag_end", nargs='?', default="HEAD") + + return parser.parse_args() + + +if __name__ == "__main__": + args = process_arguments() + previous_source_tag = find_previous_source_tag(args.tag_end) if args.tag_start == "PREV" else args.tag_start + messages = get_merge_commits(previous_source_tag) + pr_numbers = extract_pr_numbers(messages) + + summaries = [] + for pr_number in pr_numbers: + pr = fetch_pr(args.project, pr_number) + + if pr["user"]["login"] in IGNORED_CONTRIBUTORS: + continue + + if pr["merged"]: + summaries.append(extract_summary(pr)) + + sorted_summaries = sorted(summaries, key=lambda x: x["label"]) + + tag_end_label = "latest" if args.tag_end == "HEAD" else f"v{args.tag_end}" + tag_end_label = tag_end_label if args.tag_end_display_name is None else args.tag_end_display_name + write_out_to_changelog_file(sorted_summaries, tag_end=tag_end_label) diff --git a/.github/scripts/set_version.py b/.github/scripts/set_version.py new file mode 100644 index 0000000000..1b2433b396 --- /dev/null +++ b/.github/scripts/set_version.py @@ -0,0 +1,39 @@ +import argparse +import os +import subprocess + +here = os.path.abspath(os.path.dirname(__file__)) + + +def run_set_version(location, core, developer): + current_dir = os.path.abspath(os.path.curdir) + working_dir = os.path.join(current_dir, location) + os.chdir(working_dir) + + subprocess.call(f"{os.path.join(here, 'set_version.sh')} {core} {developer}", shell=True) + + os.chdir(current_dir) + + +def _process_arguments(): + parser = argparse.ArgumentParser(description="Update all version numbers in the libCellML codebase.", + prefix_chars='/') + parser.add_argument("directory", + help="Relative directory from current working directory" + " to directory of libCellML source code.") + parser.add_argument("core", + help="Core version number in the form X.Y.Z where X, Y, and Z are whole numbers.") + parser.add_argument("developer", + nargs="?", default="", + help="Developer version number in the form -{dev|rc}.W where W is a whole number.") + + return parser.parse_args() + + +def main(): + args = _process_arguments() + run_set_version(args.directory, args.core, args.developer) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/set_version.sh b/.github/scripts/set_version.sh new file mode 100755 index 0000000000..ea7b754597 --- /dev/null +++ b/.github/scripts/set_version.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +version=$1 +developer_version=$2 + +IFS='.' read -ra version_array <<< "$version" +numeric_version=$(printf %02d "${version_array[@]}") +version_regex='[[:digit:]]*\.[[:digit:]]*\.[[:digit:]]*' + +sed -i "s@ EXPECT_EQ(\"${version_regex}\", versionString);@ EXPECT_EQ(\"${version}\", versionString);@" tests/version/version.cpp +sed -i 's@ EXPECT_EQ(0x[[:digit:]]*U, version);@ EXPECT_EQ(0x'${numeric_version}'U, version);@' tests/version/version.cpp + +sed -i 's/^set(_PROJECT_VERSION[^)]*)$/set(_PROJECT_VERSION '${version}')/' CMakeLists.txt +sed -i 's/^set(PROJECT_DEVELOPER_VERSION[^)]*)$/set(PROJECT_DEVELOPER_VERSION '${developer_version}')/' CMakeLists.txt + +files=$(grep -rl "^LIBCELLML_VERSION = \"${version_regex}\"" .) +sed -i "s@LIBCELLML_VERSION = \"${version_regex}\"@LIBCELLML_VERSION = \"${version}\"@" $files + +files=$(grep -rl "/\* The content of this file was generated using \(the\|a modified\) C profile of libCellML ${version_regex}\. \*/" *) +sed -i -E "s@/\* The content of this file was generated using (the|a modified) C profile of libCellML ${version_regex}\. \*/@/\* The content of this file was generated using \1 C profile of libCellML ${version}. \*/@" $files + +files=$(grep -rl "# The content of this file was generated using \(the\|a modified\) Python profile of libCellML ${version_regex}\." *) +sed -i -E "s@# The content of this file was generated using (the|a modified) Python profile of libCellML ${version_regex}\.@# The content of this file was generated using \1 Python profile of libCellML ${version}.@" $files + +files=$(grep -rl "const char LIBCELLML_VERSION\[\] = \"${version_regex}\";" *) +sed -i "s@const char LIBCELLML_VERSION\[\] = \"${version_regex}\";@const char LIBCELLML_VERSION\[\] = \"${version}\";@" $files + +files=$(grep -rl " expect(libcellml\.versionString()).toBe('${version_regex}');" *) +sed -i "s@ expect(libcellml\.versionString()).toBe('${version_regex}');@ expect(libcellml\.versionString()).toBe('${version}');@" $files + +exit 0 diff --git a/.github/scripts/utilities.py b/.github/scripts/utilities.py new file mode 100644 index 0000000000..3ca933d3b8 --- /dev/null +++ b/.github/scripts/utilities.py @@ -0,0 +1,36 @@ +from packaging.version import parse + + +def _match_to_depth(version_1, version_2, depth): + major_equal = version_1.major == version_2.major + minor_equal = version_1.minor == version_2.minor + patch_equal = version_1.micro == version_2.micro + if depth == 1: + return major_equal + elif depth == 2: + return major_equal and minor_equal + + return major_equal and minor_equal and patch_equal + + +def unwanted_versions(versions, depth=2): + remove_versions = [] + index = 0 + while index < len(versions): + next_ = index + 1 + v = parse(versions[index]) + v_next = None + if next_ < len(versions): + v_next = parse(versions[next_]) + + if v_next is None: + index += 1 + else: + if _match_to_depth(v, v_next, depth): + remove_versions.append(versions[next_]) + versions.pop(next_) + else: + index += 1 + + return remove_versions + diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml new file mode 100644 index 0000000000..ab82165858 --- /dev/null +++ b/.github/workflows/release-changelog.yml @@ -0,0 +1,63 @@ +name: Generate and Commit Changelog + +on: + workflow_call: + inputs: + changelog_branch: + description: "The target branch for adding the changelog to, a release staging branch" + required: true + type: string + secrets: + GH_TOKEN: + description: "GitHub token with permissions to push to the repository" + required: true + +permissions: + contents: write + pull-requests: read + +jobs: + changelog: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Extract release tag from release staging branch + id: determine_release + run: | + branch=${{ inputs.changelog_branch }} + + # Extract release tag by removing prefix + tag="${branch#${{ vars.RELEASE_STAGING_BRANCH_STEM }}}" + echo "RELEASE_TAG=$tag" >> "$GITHUB_ENV" + + - name: Checkout release staging branch + run: | + git checkout "${{ inputs.changelog_branch }}" + + - uses: actions/setup-python@v6 + if: ${{ env.ACT != 'true' }} + with: + python-version: "3.12" + + - name: Generate changelog + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + run: | + pip install requests packaging + python .github/scripts/generate_changelog.py -p cellml/libcellml PREV ${{ env.RELEASE_TAG }} + mv changelog_*.rst docs/changelogs/ + python .github/scripts/clean_and_reindex_changelogs.py . + + - name: Commit changelog + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add docs/changelogs/changelog_*.rst docs/changelogs/index.rst docs/index.rst + git commit -m "Add changelog for release ${RELEASE_TAG}" || echo "No changes to commit" + git push diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000000..5a7a148645 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,122 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + source_branch: + description: "Base branch (main or tag)" + default: 'main' + required: true + version: + description: "Release version (e.g. 0.7.1, 0.7.0-rc.1)" + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: release + path: release-repo + + - name: Set GitHub Actions bot identity + run: | + cd release-repo + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_branch }} + path: source-repo + + - name: Create staging branch + run: | + cd release-repo + git checkout -b ${{ vars.RELEASE_STAGING_BRANCH_STEM }}${{ inputs.version }} + + - name: Copy release changes to release repo + run: | + rsync -avW --delete --exclude='.git' source-repo/ release-repo/ + + - name: Commit changes + run: | + cd release-repo + + git add -A + git commit -m "Changes to update the release branch to ${{ inputs.version }}." + + - name: Push branch + run: | + cd release-repo + git push origin ${{ vars.RELEASE_STAGING_BRANCH_STEM }}${{ inputs.version }} + + generate_changelog: + needs: prepare + uses: ./.github/workflows/release-changelog.yml + with: + changelog_branch: ${{ vars.RELEASE_STAGING_BRANCH_STEM }}${{ inputs.version }} + secrets: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + set_version: + needs: generate_changelog + uses: ./.github/workflows/set-version.yml + with: + target_branch: ${{ vars.RELEASE_STAGING_BRANCH_STEM }}${{ inputs.version }} + version: ${{ inputs.version }} + secrets: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + open_pr: + runs-on: ubuntu-latest + needs: set_version + steps: + - uses: actions/checkout@v6 + + - name: Open PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Automated release PR for v${{ inputs.version }}." > pr_body.txt + echo "" >> pr_body.txt + echo "**Do not** merge this PR, it is only for review and testing." >> pr_body.txt + echo "The release process will deal with it when the release is finalised." >> pr_body.txt + + gh pr create \ + --base release \ + --head ${{ vars.RELEASE_STAGING_BRANCH_STEM }}${{ inputs.version }} \ + --title "Proposed changes to create release v${{ inputs.version }} of libCellML" \ + --body-file pr_body.txt \ + --draft + + delete_release_branch: + runs-on: ubuntu-latest + needs: + - prepare + - generate_changelog + - set_version + - open_pr + if: failure() + + steps: + - uses: actions/checkout@v6 + + - name: Delete remote release staging branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + branch="${{ vars.RELEASE_STAGING_BRANCH_STEM }}${{ inputs.version }}" + echo "Cleaning up failed release branch: $branch" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git push origin --delete "$branch" || echo "Branch already deleted" diff --git a/.github/workflows/set-version.yml b/.github/workflows/set-version.yml new file mode 100644 index 0000000000..9cb7798e56 --- /dev/null +++ b/.github/workflows/set-version.yml @@ -0,0 +1,64 @@ +name: Set Version + +on: + workflow_dispatch: + inputs: + target_branch: + description: "Target branch (main or release staging branch)" + default: 'main' + required: true + version: + description: "Version to set (e.g. 0.7.1, 0.7.0-rc.1)" + required: true + + workflow_call: + inputs: + target_branch: + description: "Target branch (main or release staging branch)" + type: string + required: true + version: + description: "Version to set (e.g. 0.7.1, 0.7.0-rc.1)" + type: string + required: true + secrets: + GH_TOKEN: + description: "GitHub token with permissions to push to the repository" + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + set-version: + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.target_branch }} + + + - name: Set GitHub Actions bot identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Update version numbers + run: | + version=${{ inputs.version }} + core=$(sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/' <<< "$version") + dev="${version#"$core"}" + echo "Core version: $core, Dev suffix: $dev" + python3 .github/scripts/set_version.py . $core $dev + + - name: Commit version change + run: | + git add -A + git commit -m "Set the version number throughout the codebase to ${{ inputs.version }}." + + - name: Push branch + run: | + git push diff --git a/.gitignore b/.gitignore index df4e47f0e5..57613db322 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ CMakeLists.txt.user # VS Code system files .vscode/ + +# Act resource files +.actrc +act-payload.json diff --git a/docs/images/release_process/prepare_release_interface.png b/docs/images/release_process/prepare_release_interface.png new file mode 100644 index 0000000000..25b1f30a95 Binary files /dev/null and b/docs/images/release_process/prepare_release_interface.png differ diff --git a/docs/release_process.rst b/docs/release_process.rst index e914473d63..9fd92455b9 100644 --- a/docs/release_process.rst +++ b/docs/release_process.rst @@ -4,69 +4,95 @@ Release process for *libCellML* =============================== -The target audience of this document are the developers of *libCellML*, who have write authority to the `cellml/libcellml `__ repository. -Releases are made using builders from the Buildbot Continuous Integration (CI). -There are four steps in making a release. +The purpose of this document is to describe the process for preparing, reviewing, creating, and finalising releases of *libCellML* using GitHub Actions Continuous Integration (CI). -1. `Step 1 - Setting the version number`_ -2. `Step 2 - Preparing the release`_ +The target audience of this document are the developers of *libCellML* who have write authority to the `cellml/libcellml `_ repository. + +Releases are made using GitHub Actions CI and consist of four steps: + +1. `Step 1 - Preparing the release`_ +2. `Step 2 - Checking the release`_ 3. `Step 3 - Creating the release`_ 4. `Step 4 - Finalising the Release`_ -Each section has further details on what actions are required for a particular step. -Each step must be done in order from step 1 through to step 4. +Each step must be performed in order. Later steps assume that all previous steps have completed successfully. + +Prerequisites +============= -For all the steps in creating a release, you must be logged in to the Buildbot CI and be in the *admin* group. +For all steps in the release process, you must: + +- Be a maintainer (have write access) of the *cellml/libcellml* GitHub repository. +- Be familiar with semantic versioning. +- Ensure that no other release is currently in progress. .. note:: - Merging in pull requests when a release is under way is not recommended and more importantly has not been tested. - To determine if a release is under way check the repository for the presence of a branch named *version_change* or *release_staging_*. + Merging pull requests while a release is under way is not recommended and has not been tested. + Before starting a new release, check the repository for the presence of a branch named *release-staging-v*. + The presence of such a branch indicates that a release is already in progress. -Step 1 - Setting the version number -=================================== +Branch model overview +===================== -The version number for the project can be set using the *Set Version Builder* (:numref:`libcellml_release_process_set_version_builder`). -The *Set Version Builder* sets the version that is entered into the interface, it does not increment the version. -The version that you set in the interface will be applied as is to the codebase. +The *main* branch is the primary development branch where normal feature development occurs. +The *release* branch represents the base for all released versions of *libCellML*. +Release staging branches, named *release-staging-v*, are temporary branches used to assemble, review, and validate a candidate release before it is finalised. -.. figure:: ./images/release_process/set_version_builder.png - :align: center - :alt: Buildbot set version builder. - :name: libcellml_release_process_set_version_builder +Step 1 - Preparing the release +============================== + +The *Prepare Release* GitHub Actions workflow prepares a release candidate using information supplied when the workflow is triggered. + +This workflow performs the following tasks: + +- Collects all changes that will make up the release from a specified source branch. +- Generates a changelog summarising the merged pull requests included in the release. +- Updates version numbers throughout the codebase. - *Set Version Builder* on Buildbot. +These changes are separated into multiple commits (for example, content changes, changelog generation, and version updates) to make reviewing the release easier. -libCellML uses semantic versioning as a versioning system, see `Semantic versioning `_ for further information. -As such, each part of the version number carries a specific meaning and when setting a version number you need to make sure you are following semantic versioning rules. -There are no checks to determine if semantic versioning is being followed. -The version number is split into two parts: the core version, made up of the major, minor, and patch version identifiers; and the developer version. -An official release is created by leaving the developer version input empty. -The main difference between an official release and a developer release is the assets built by the developer release process are not uploaded or published to public registries or attached to an associated GitHub release. +The workflow creates a staging branch in the repository that contains all proposed release changes. +The source branch is typically the *main* branch (which is the default), but it may be any branch or tag in the repository. +The staging branch is named *release-staging-v*, where is the version number specified when triggering the workflow. -.. figure:: ./images/release_process/set_version_builder_interface.png +The workflow can be triggered from the *Actions* tab of the `cellml/libcellml `__ repository. + +This workflow is manually triggered and requires a version number to be provided using the GitHub Actions user interface. + +.. figure:: ./images/release_process/prepare_release_interface.png :align: center - :alt: Buildbot set version builder interface. - :name: libcellml_release_process_set_version_builder_interface + :alt: GitHub Actions prepare release workflow interface. + :name: libcellml_release_process_prepare_release_interface - *Set Version Builder* interface. + *Prepare Release* interface on GitHub. -When the *Start Build* button is pressed (:numref:`libcellml_release_process_set_version_builder_interface`) Buildbot will create an internal pull request on the `cellml/libcellml `__ GitHub repository. -The pull request will be made from the *version_change* branch to the *main* branch. -The creation of the pull request will trigger a CI build, wait for the CI to finish its checks before merging the pull request. -If, for some reason, the CI checks fail changes may be required. -Changes can be made directly to the *vesion_change* branch but quite likely any such changes will need to be propagated to the CI for a permanent fix. -How changes are propagated to the CI is outside the scope of this document. -When merging the pull request the *version_change* branch will be automatically deleted. +Version numbers must follow semantic versioning rules. See `Semantic Versioning `_ for further information. +Examples of valid version numbers include: -.. note:: +- 1.0.0 +- 1.0.0-alpha +- 1.0.0-alpha.1 +- 1.0.0-beta +- 1.0.0-beta.2 +- 1.0.0-rc.1 +- 1.0.0-rc.1+build.1 +- 1.0.0-post.1 - The merging of a *version_change* pull request created by the CI system is exempt from the 'two reviews' required rule. +There are no explicit checks to ensure that version numbers strictly follow semantic versioning conventions; however, the workflow will fail if the supplied version number cannot be parsed as a semantic version. -When the version number has been set in the *main* branch the preparation of the release can start. +The version number is split into two components: a core version (major, minor, and patch numbers) and an optional developer suffix. +An official release is created by omitting the developer suffix. +Developer releases include a suffix and differ from official releases in that the resulting assets are not published to public registries or attached to a GitHub release. -Step 2 - Preparing the release -============================== +On successful completion, this workflow creates a pull request on the `cellml/libcellml `_ repository with *release* as the base branch and the newly created staging branch as the head branch. + +If the workflow fails, the pull request will not be created. Depending on how far the workflow progressed before failure, the staging branch may still exist and may need to be cleaned up manually or reused after resolving the issue. + +Once the pull request has been created, the checking of the release can begin (see `Step 2 - Checking the release`_). + +Step 2 - Checking the release +============================= A release is prepared using the *Prepare Release Builder* (:numref:`libcellml_release_process_prepare_release`). The *Prepare Release Builder* will create a new branch named *release_staging_* (where is an actual semantic version number set in the first step) and generate a changelog.