From c3f7b03513cea9377cc5aa4f9b6ef8aeaa22492f Mon Sep 17 00:00:00 2001 From: PerdixOfMars Date: Sat, 18 Apr 2026 10:37:21 +0200 Subject: [PATCH] Automated Release Process --- .github/workflows/release.yml | 138 +++++++++++++++++++ CMakeLists.txt | 14 +- README.md | 14 ++ cmake/Modules/function-git_version.cmake | 106 +++++++++++++++ cmake/Modules/function-parse_version.cmake | 95 +++++++++++++ scripts/next_release.py | 149 +++++++++++++++++++++ 6 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 cmake/Modules/function-git_version.cmake create mode 100644 cmake/Modules/function-parse_version.cmake create mode 100644 scripts/next_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ec91ae8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,138 @@ +name: release + +on: + workflow_run: + workflows: ["build"] + types: [completed] + branches: + - master + +permissions: + contents: write + pull-requests: read + +concurrency: + group: release-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + release: + if: >- + ${{ + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'master' + }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Skip if head is already tagged + id: points_at_tag + shell: bash + run: | + tags="$(git tag --points-at "${{ github.event.workflow_run.head_sha }}")" + if [ -n "$tags" ]; then + echo "already_tagged=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "already_tagged=false" >> "$GITHUB_OUTPUT" + + - name: Find associated pull request + if: steps.points_at_tag.outputs.already_tagged != 'true' + id: pr + uses: actions/github-script@v7 + with: + script: | + const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.payload.workflow_run.head_sha, + }); + + const mergedPulls = response.data + .filter((pull) => pull.merged_at) + .sort((left, right) => new Date(right.merged_at) - new Date(left.merged_at)); + + if (!mergedPulls.length) { + core.setOutput("found", "false"); + return; + } + + const pull = mergedPulls[0]; + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pull.number, + per_page: 100, + }); + + core.setOutput("found", "true"); + core.setOutput("number", String(pull.number)); + core.setOutput("title", pull.title); + core.setOutput("body", pull.body || ""); + core.setOutput("labels", JSON.stringify(pull.labels.map((label) => label.name))); + core.setOutput("commits", JSON.stringify(commits.map((commit) => commit.commit.message.split("\n")[0]))); + + - name: Skip if no merged pull request was found + if: >- + ${{ + steps.points_at_tag.outputs.already_tagged != 'true' && + steps.pr.outputs.found != 'true' + }} + shell: bash + run: | + echo "The push to master is not associated with a merged pull request, so no release will be created." + + - name: Read latest release tag + if: >- + ${{ + steps.points_at_tag.outputs.already_tagged != 'true' && + steps.pr.outputs.found == 'true' + }} + id: latest_tag + shell: bash + run: | + latest_tag="$(git tag --sort=-version:refname | head -n 1)" + if [ -z "$latest_tag" ]; then + latest_tag="v0.0.0" + fi + echo "value=$latest_tag" >> "$GITHUB_OUTPUT" + + - name: Compute next version + if: >- + ${{ + steps.points_at_tag.outputs.already_tagged != 'true' && + steps.pr.outputs.found == 'true' + }} + id: version + env: + LABELS_JSON: ${{ steps.pr.outputs.labels }} + PR_TITLE: ${{ steps.pr.outputs.title }} + PR_BODY: ${{ steps.pr.outputs.body }} + COMMITS_JSON: ${{ steps.pr.outputs.commits }} + shell: bash + run: | + python3 scripts/next_release.py \ + --current-version "${{ steps.latest_tag.outputs.value }}" \ + --labels-json "$LABELS_JSON" \ + --title "$PR_TITLE" \ + --body "$PR_BODY" \ + --commits-json "$COMMITS_JSON" >> "$GITHUB_OUTPUT" + + - name: Create GitHub release + if: >- + ${{ + steps.points_at_tag.outputs.already_tagged != 'true' && + steps.pr.outputs.found == 'true' + }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.next_tag }} + target_commitish: ${{ github.event.workflow_run.head_sha }} + generate_release_notes: true + name: ${{ steps.version.outputs.next_tag }} diff --git a/CMakeLists.txt b/CMakeLists.txt index ef510ec..ef26d14 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,5 @@ cmake_minimum_required(VERSION 3.22 FATAL_ERROR) cmake_policy(VERSION 3.22) -project(CONSOLEPP VERSION 0.1.1) option(CONOSOLEPP_COVERAGE "Build with code coverage options") option(CONSOLEPP_SANITIZE "Build using sanitizers" "") @@ -13,6 +12,19 @@ message("Building Console++ with sanitizers: ${CONSOLEPP_SANITIZE}") message("Building Console++ with tests: ${CONSOLEPP_WITH_TESTS}") message("Building Console++ with only documentation: ${CONSOLEPP_DOC_ONLY}") +set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Modules") + +if (CONSOLEPP_VERSION) + include(function-parse_version) + parse_version("${CONSOLEPP_VERSION}" "CONSOLEPP_") +else() + include(function-git_version) + git_version(CONSOLEPP) +endif() + +message(STATUS "Console++ Version: ${CONSOLEPP_VERSION}") +project(CONSOLEPP VERSION ${CONSOLEPP_VERSION}) + # For producing automatically-generated documentation, we use Doxygen. find_package(Doxygen) diff --git a/README.md b/README.md index 2e73365..1d7cf93 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,20 @@ cmake -S . -B build \ When using manifest mode (`vcpkg.json` in this repository), configure will trigger dependency installation automatically. +## Automated Releases + +Pushes to `master` that come from a merged pull request and pass the `build` +workflow automatically publish a GitHub Release. The release version is chosen +using the following precedence: + +- pull request labels `semver:major`, `semver:minor`, `semver:patch` +- Conventional Commit cues from the PR title or commit subjects +- contextual hints in the PR title/body such as `breaking` or `feature` +- `patch` when nothing else matches + +Downstream consumers continue to get the standard GitHub release experience, +including the automatically generated source archives for each tag. + ## Consume From CMake (Installed Package) ```cmake diff --git a/cmake/Modules/function-git_version.cmake b/cmake/Modules/function-git_version.cmake new file mode 100644 index 0000000..47a3444 --- /dev/null +++ b/cmake/Modules/function-git_version.cmake @@ -0,0 +1,106 @@ +# A function to parse verson numbers into their parts +include("${CMAKE_CURRENT_LIST_DIR}/function-parse_version.cmake") + +# A function to detect a components version number from git tags. +function( GIT_VERSION VAR_PREFIX) + +set( GIT_INFO ID AUTHOR DATE BRANCH DESC ) + +set( GIT_COMMIT_ID_DEFAULT "UNKNOWN" ) +set( GIT_COMMIT_AUTHOR_DEFAULT "UNKNOWN" ) +set( GIT_COMMIT_DATE_DEFAULT "UNKNOWN" ) +set( GIT_COMMIT_BRANCH_DEFAULT "UNKNOWN" ) +set( GIT_COMMIT_DESC_DEFAULT "None" ) + +# we need git of course... +find_package( Git ) +if( GIT_FOUND ) + # make sure that this is actually a git repo + execute_process( COMMAND ${GIT_EXECUTABLE} status + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + RESULT_VARIABLE IsGitRepo + OUTPUT_VARIABLE OutputTrash + ERROR_VARIABLE ErrorTrash) + + if( ${IsGitRepo} EQUAL 0 ) + if( "${GIT_COMMIT_ID}" STREQUAL "" ) + execute_process( COMMAND ${GIT_EXECUTABLE} rev-parse --sq HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GIT_COMMIT_ID + ERROR_VARIABLE Trash) + endif() + if( "${GIT_COMMIT_AUTHOR}" STREQUAL "" ) + execute_process( COMMAND ${GIT_EXECUTABLE} log -n1 --pretty="%an" HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GIT_COMMIT_AUTHOR + ERROR_VARIABLE Trash) + endif() + if( "${GIT_COMMIT_DATE}" STREQUAL "" ) + execute_process( COMMAND ${GIT_EXECUTABLE} log -n1 --pretty="%aD" HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GIT_COMMIT_DATE + ERROR_VARIABLE Trash) + endif() + if( "${GIT_COMMIT_BRANCH}" STREQUAL "" ) + execute_process( COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GIT_COMMIT_BRANCH + ERROR_VARIABLE Trash) + endif() + if( "${GIT_COMMIT_DESC}" STREQUAL "" ) + # check to see if there are any tags + execute_process( COMMAND ${GIT_EXECUTABLE} tag + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE Tmp + ERROR_VARIABLE Trash) + if( NOT "${Tmp}" STREQUAL "" ) + # at least one tag (appears to) exist + execute_process( COMMAND ${GIT_EXECUTABLE} describe --tags HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE GIT_COMMIT_DESC + ERROR_VARIABLE Trash) + else() + # no tag found. assume 0.0 + set( GIT_COMMIT_DESC "0.0" ) + endif() + endif() + else() + message( WARNING "Source directory is not a git repo." ) + set( GIT_COMMIT_DESC "0.0.0" ) + set( GIT_COMMIT_BRANCH "none" ) + endif() +else() + message( WARNING "Could not find git command" ) + set( GIT_COMMIT_DESC "0.0" ) + set( GIT_COMMIT_BRANCH "none" ) +endif() + +# set defaults for any items that were not found +foreach( item ${GIT_INFO} ) + if( "${GIT_COMMIT_${item}}" STREQUAL "" ) + set( GIT_COMMIT_${item} ${GIT_COMMIT_${item}_DEFAULT} ) + endif() +endforeach( item ) + +# remove quotes, backslashes, and new lines +# these cause problems when trying to embed version numbers into source files. +foreach( item ${GIT_INFO} ) + string( REPLACE "'" "" GIT_COMMIT_${item} ${GIT_COMMIT_${item}} ) + string( REPLACE "\"" "" GIT_COMMIT_${item} ${GIT_COMMIT_${item}} ) + string( REPLACE "\n" "" GIT_COMMIT_${item} ${GIT_COMMIT_${item}} ) +endforeach( item ) + +# set full version string +set(VERSION_FULL "${GIT_COMMIT_DESC}-${GIT_COMMIT_BRANCH}" ) + +parse_version( ${VERSION_FULL} "" ) + +# now set version information in the parent scope +set( ${VAR_PREFIX}_VERSION_FULL ${VERSION_FULL} PARENT_SCOPE ) +set( ${VAR_PREFIX}_VERSION ${VERSION} PARENT_SCOPE ) +set( ${VAR_PREFIX}_VERSION_MAJOR ${VERSION_MAJOR} PARENT_SCOPE ) +set( ${VAR_PREFIX}_VERSION_MINOR ${VERSION_MINOR} PARENT_SCOPE ) +set( ${VAR_PREFIX}_VERSION_PATCH ${VERSION_PATCH} PARENT_SCOPE ) + +endfunction(GIT_VERSION) + diff --git a/cmake/Modules/function-parse_version.cmake b/cmake/Modules/function-parse_version.cmake new file mode 100644 index 0000000..e07b2ff --- /dev/null +++ b/cmake/Modules/function-parse_version.cmake @@ -0,0 +1,95 @@ +# A function to parse verson numbers into their parts +function( PARSE_VERSION VERSION PREFIX ) + +# look for major, minor, and patch numbers in the version string +# this is required if the developer uses CPack for example. +# need to consider two possible formats: +# Major.Minor.Patch +# or +# Major.Minor-PatchStr +# the first format is the standard format used for version numbers, +# but would have to be set by the developer as a tag name. +# however, if the developer just sets a major and minor version with +# a tag, then git will add the number of commits since the tag. for example +# 0.1-10-devel +# indicates that this is the 10th commit on the devel branch after 0.1 patch. +# +# so, we assume +# version components may be separated by '.' or '-' +# version components must be numerical (this is what cmake supoorts) +# +# we parse by matching a version component and then removing it from the +# version string. + +# set full version string to the raw string passed in +set( VERSION_FULL ${VERSION} ) + +# first number in version string will be the major version number +# version string may be preceeded by a 'v' +set( RE "^v*([0-9]+)[\\.\\-](.*)$" ) +if( ${VERSION} MATCHES ${RE} ) + set( VERSION_MAJOR ${CMAKE_MATCH_1} ) + string( REGEX REPLACE ${RE} "\\2" VERSION "${VERSION}" ) +else() + set( VERSION_MAJOR "" ) +endif() + +# second number in version string will be minor version number +set( RE "^([0-9]+)[\\.\\-](.*)$" ) +if( ${VERSION} MATCHES ${RE} ) + set( VERSION_MINOR ${CMAKE_MATCH_1} ) + string( REGEX REPLACE ${RE} "\\2" VERSION "${VERSION}" ) +else() + set( VERSION_MINOR "" ) +endif() + +# third number in version string will be patch version number +set( RE "^([0-9]+)[\\.\\-](.*)$" ) +if( ${VERSION} MATCHES ${RE} ) + set( VERSION_PATCH ${CMAKE_MATCH_1} ) + string( REGEX REPLACE ${RE} "\\2" VERSION "${VERSION}" ) +else() + set( VERSION_PATCH "" ) +endif() + +# fourth number in version string will be teak version number +# teak number should be the last number +set( RE "^([0-9]+)(.*)$" ) +if( ${VERSION} MATCHES ${RE} ) + set( VERSION_TWEAK ${CMAKE_MATCH_1} ) + string( REGEX REPLACE ${RE} "\\2" VERSION "${VERSION}" ) +else() + set( VERSION_TWEAK "" ) +endif() + + +# if the version string passed in coudl be parsed, +# construct a cmake-compatible version string +# to put in ${PREFIX}VERSION. otherwise, just +# set ${PREFIX}VERSION to the string that was passed in. +if( NOT ${VERSION_MAJOR} EQUAL "" ) + set(VERSION ${VERSION_MAJOR}) +else() + set(VERSION ${VERSION_FULL}) +endif() +if( NOT ${VERSION_MINOR} EQUAL "" ) + set(VERSION ${VERSION}.${VERSION_MINOR}) +endif() +if( NOT ${VERSION_PATCH} EQUAL "" ) + set(VERSION ${VERSION}.${VERSION_PATCH}) +endif() +if( NOT ${VERSION_TWEAK} EQUAL "" ) + set(VERSION ${VERSION}.${VERSION_TWEAK}) +endif() + +# set variables in parent's scope +set( ${PREFIX}VERSION_FULL ${VERSION_FULL} PARENT_SCOPE ) +set( ${PREFIX}VERSION ${VERSION} PARENT_SCOPE ) +set( ${PREFIX}VERSION_MAJOR ${VERSION_MAJOR} PARENT_SCOPE ) +set( ${PREFIX}VERSION_MINOR ${VERSION_MINOR} PARENT_SCOPE ) +set( ${PREFIX}VERSION_PATCH ${VERSION_PATCH} PARENT_SCOPE ) +set( ${PREFIX}VERSION_TWEAK ${VERSION_TWEAK} PARENT_SCOPE ) + + +endfunction(PARSE_VERSION) + diff --git a/scripts/next_release.py b/scripts/next_release.py new file mode 100644 index 0000000..997f59a --- /dev/null +++ b/scripts/next_release.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass + + +SEMVER_LABELS = { + "semver:major": "major", + "semver:minor": "minor", + "semver:patch": "patch", +} + + +BREAKING_PATTERNS = ( + r"\bbreaking change\b", + r"\bbreaking\b", + r"\bbackward incompatible\b", + r"\bbackwards incompatible\b", +) + + +MINOR_PATTERNS = ( + r"\bnew feature\b", + r"\bfeature\b", + r"\benhancement\b", + r"\badds? support\b", +) + + +CONVENTIONAL_MAJOR = re.compile(r"^[a-z]+(?:\([^)]+\))?!:", re.IGNORECASE) +CONVENTIONAL_MINOR = re.compile(r"^feat(?:\([^)]+\))?:", re.IGNORECASE) +CONVENTIONAL_PATCH = re.compile(r"^(?:fix|perf)(?:\([^)]+\))?:", re.IGNORECASE) + + +@dataclass(frozen=True, order=True) +class Version: + major: int + minor: int + patch: int + + @classmethod + def parse(cls, value: str) -> "Version": + match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", value.strip()) + if not match: + raise ValueError(f"Unsupported semantic version: {value!r}") + + return cls(*(int(part) for part in match.groups())) + + def bump(self, release_type: str) -> "Version": + if release_type == "major": + return Version(self.major + 1, 0, 0) + if release_type == "minor": + return Version(self.major, self.minor + 1, 0) + if release_type == "patch": + return Version(self.major, self.minor, self.patch + 1) + + raise ValueError(f"Unknown release type: {release_type}") + + def tag(self) -> str: + return f"v{self.major}.{self.minor}.{self.patch}" + + +def choose_release_type(labels: list[str], title: str, body: str, commits: list[str]) -> tuple[str, str]: + normalized_labels = {label.strip().lower() for label in labels if label.strip()} + + for label, release_type in SEMVER_LABELS.items(): + if label in normalized_labels: + return release_type, f"label:{label}" + + combined_text = "\n".join([title, body, *commits]).lower() + + if "!" in title and CONVENTIONAL_MAJOR.match(title): + return "major", "title:conventional-breaking" + + if CONVENTIONAL_MINOR.match(title): + return "minor", "title:conventional-feat" + + if CONVENTIONAL_PATCH.match(title): + return "patch", "title:conventional-fix" + + for commit in commits: + if CONVENTIONAL_MAJOR.match(commit): + return "major", f"commit:{commit}" + + for pattern in BREAKING_PATTERNS: + if re.search(pattern, combined_text): + return "major", f"text:{pattern}" + + for commit in commits: + if CONVENTIONAL_MINOR.match(commit): + return "minor", f"commit:{commit}" + + if any(re.search(pattern, combined_text) for pattern in MINOR_PATTERNS): + return "minor", "text:feature" + + for commit in commits: + if CONVENTIONAL_PATCH.match(commit): + return "patch", f"commit:{commit}" + + return "patch", "default:patch" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Compute the next Console++ release version") + parser.add_argument("--current-version", required=True, help="Current semantic version or tag") + parser.add_argument("--labels-json", default="[]", help="JSON array of pull request labels") + parser.add_argument("--title", default="", help="Pull request title") + parser.add_argument("--body", default="", help="Pull request body") + parser.add_argument( + "--commits-json", + default="[]", + help="JSON array of commit subjects associated with the release", + ) + args = parser.parse_args() + + try: + current_version = Version.parse(args.current_version) + labels = json.loads(args.labels_json) + commits = json.loads(args.commits_json) + except (ValueError, json.JSONDecodeError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + if not isinstance(labels, list) or not all(isinstance(item, str) for item in labels): + print("--labels-json must decode to a list of strings", file=sys.stderr) + return 1 + + if not isinstance(commits, list) or not all(isinstance(item, str) for item in commits): + print("--commits-json must decode to a list of strings", file=sys.stderr) + return 1 + + release_type, reason = choose_release_type(labels, args.title, args.body, commits) + next_version = current_version.bump(release_type) + + print(f"release_type={release_type}") + print(f"reason={reason}") + print(f"current_tag={current_version.tag()}") + print(f"next_tag={next_version.tag()}") + print(f"next_version={next_version.major}.{next_version.minor}.{next_version.patch}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())