From 956ff0f71b5061d4e73aefa8f5b67262696cfcbf Mon Sep 17 00:00:00 2001 From: PerdixOfMars Date: Sat, 18 Apr 2026 10:38:54 +0200 Subject: [PATCH] Automated Release Process --- .github/workflows/release.yml | 138 +++++++++++++++++++++++++++++++ CMakeLists.txt | 5 +- README.md | 14 ++++ scripts/next_release.py | 149 ++++++++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml 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..5c9fc44 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,138 @@ +name: release + +on: + workflow_run: + workflows: ["CMake"] + 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 31cb822..bb3fdfe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,10 @@ message("Building Server++ with sanitizers: ${SERVERPP_SANITIZE}") set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_CURRENT_SOURCE_DIR}/cmake/Modules") -if (NOT SERVERPP_VERSION) +if (SERVERPP_VERSION) + include(function-parse_version) + parse_version("${SERVERPP_VERSION}" "SERVERPP_") +else() include(function-git_version) git_version(SERVERPP) endif() diff --git a/README.md b/README.md index 95ed216..bb55fdd 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,20 @@ Server++ can be installed from source using CMake. This requires Boost and any cmake --build . sudo cmake --install . +# Automated Releases + +Pushes to `master` that come from a merged pull request and pass the `CMake` +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. + # Features / Roadmap / Progress 1. [X] TCP server and sockets. diff --git a/scripts/next_release.py b/scripts/next_release.py new file mode 100644 index 0000000..e618cd0 --- /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 Server++ 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())