diff --git a/.github/scripts/semantic_release.py b/.github/scripts/semantic_release.py new file mode 100755 index 0000000..1c98265 --- /dev/null +++ b/.github/scripts/semantic_release.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 + +# Copyright 2026 Khalil Estell and the libhal contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Computes the next release of one version line of a repository. + +A version line is one major version that lives in its own directory (e.g. +4.x.y in v4/). The line only looks at its own tags and at commits that change +its directory. + + 1. The line's last release is its newest .X.Y tag. Until one exists, + it is the newest .0.0-alpha.K tag instead. A line with neither is + not started yet; major versions are started by hand. + 2. The subject lines of the commits since that tag that change the + directory are scanned for (minor) and (patch). The highest one wins: + - released line: (minor) -> .X+1.0, (patch) -> .X.Y+1 + - pre-release line: either -> .0.0-alpha.K+1 + 3. (major) is an error: a commit can't start a major version. + +A commit that changes several directories counts for each of their lines. +Only subject lines count, so a commit body can mention the keywords (e.g. a +squash merge listing its commits, or a message describing these rules). + +Prints the result as key=value lines, or appends them to the file given with +--output (e.g. $GITHUB_OUTPUT): + + tag=4.3.0 (empty when there is nothing to release) + previous=4.2.7 (the tag the release notes start from) + prerelease=false + +Usage: + python3 semantic_release.py --major 4 --dir v4 + python3 semantic_release.py --major 4 --dir v4 --output "$GITHUB_OUTPUT" +""" + +import argparse +import re +import subprocess +import sys + +KEYWORD = re.compile(r"\((major|minor|patch)\)") + + +class ReleaseError(Exception): + pass + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], check=True, capture_output=True, + text=True).stdout + + +def last_release(major: int) -> tuple[str, bool] | None: + """Returns (tag, is_prerelease) for the line's newest tag, or None.""" + releases = [] + alphas = [] + for tag in git("tag", "--list", f"{major}.*").split(): + if match := re.fullmatch(rf"{major}\.(\d+)\.(\d+)", tag): + releases.append((int(match[1]), int(match[2]), tag)) + elif match := re.fullmatch(rf"{major}\.0\.0-alpha\.(\d+)", tag): + alphas.append((int(match[1]), tag)) + if releases: + return max(releases)[-1], False + if alphas: + return max(alphas)[-1], True + return None + + +def keywords_since(tag: str, directory: str, ref: str) -> set[str]: + """Returns the release keywords in the subject lines of commits after + `tag` that change `directory`.""" + log = git("log", "--format=%H %s", f"{tag}..{ref}", "--", directory) + found = set() + for line in log.splitlines(): + commit, _, subject = line.partition(" ") + words = set(KEYWORD.findall(subject)) + if "major" in words: + raise ReleaseError( + f"commit {commit[:12]} asks for a (major) release. Major " + "versions are started by hand: publish the first tag of the " + "new line yourself. Remove (major) from the commit subject.") + found |= words + return found + + +def next_release(major: int, directory: str, ref: str = "HEAD") -> dict: + """Returns {"tag", "previous", "prerelease"}; "tag" is empty when there + is nothing to release.""" + nothing = {"tag": "", "previous": "", "prerelease": False} + + last = last_release(major) + if last is None: + print(f"No {major}.* tag yet, so the {major}.x line isn't started. " + f"Publish {major}.0.0-alpha.1 or {major}.0.0 by hand to start " + "it.", file=sys.stderr) + return nothing + previous, prerelease = last + + words = keywords_since(previous, directory, ref) + if not words: + print(f"No commit since {previous} that changes {directory} asks " + "for a release.", file=sys.stderr) + return nothing + + if prerelease: + alpha = int(previous.rsplit(".", 1)[1]) + tag = f"{major}.0.0-alpha.{alpha + 1}" + else: + _, minor, patch = (int(part) for part in previous.split(".")) + if "minor" in words: + tag = f"{major}.{minor + 1}.0" + else: + tag = f"{major}.{minor}.{patch + 1}" + return {"tag": tag, "previous": previous, "prerelease": prerelease} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--major", type=int, required=True, + help="Major version of the line, e.g. 4") + parser.add_argument("--dir", required=True, + help="Directory of the line, e.g. v4 (or .)") + parser.add_argument("--ref", default="HEAD", + help="Commit to release (default: HEAD)") + parser.add_argument("--output", + help="Append the result to this file instead of " + "printing it") + args = parser.parse_args() + + try: + result = next_release(args.major, args.dir, args.ref) + except ReleaseError as error: + print(f"::error::{error}") + return 1 + + lines = (f"tag={result['tag']}\n" + f"previous={result['previous']}\n" + f"prerelease={str(result['prerelease']).lower()}\n") + if args.output: + with open(args.output, "a", encoding="utf-8") as output: + output.write(lines) + print(lines, end="") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_semantic_release.py b/.github/scripts/test_semantic_release.py new file mode 100644 index 0000000..f6c964f --- /dev/null +++ b/.github/scripts/test_semantic_release.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 + +# Copyright 2026 Khalil Estell and the libhal contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Tests for semantic_release.py. Each test builds a throwaway git repository. + +Usage: + python3 -m unittest discover -s .github/scripts -p "test_*.py" +""" + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import semantic_release +from semantic_release import ReleaseError, next_release + + +class SemanticReleaseTest(unittest.TestCase): + def setUp(self): + self._temp = tempfile.TemporaryDirectory() + self.repo = Path(self._temp.name) + self._cwd = os.getcwd() + os.chdir(self.repo) + self.git("init", "-q", "-b", "main") + self.git("config", "user.name", "Test") + self.git("config", "user.email", "test@example.com") + self.git("config", "commit.gpgsign", "false") + self.git("config", "tag.gpgsign", "false") + self.commit("Initial commit", "README.md") + + def tearDown(self): + os.chdir(self._cwd) + self._temp.cleanup() + + def git(self, *args): + subprocess.run(["git", *args], check=True, capture_output=True) + + def commit(self, message, *paths): + for path in paths: + file = self.repo / path + file.parent.mkdir(parents=True, exist_ok=True) + with file.open("a") as stream: + stream.write(f"{message}\n") + self.git("add", path) + self.git("commit", "-q", "-m", message) + + def tag(self, *names): + for name in names: + self.git("tag", name) + + # ========================== Line not started =========================== + + def test_line_without_tags_is_not_released(self): + # Setup + self.commit(":sparkles: Add api (minor)", "v5/a.cpp") + + # Exercise + result = next_release(5, "v5") + + # Verify + self.assertEqual("", result["tag"]) + + # ============================ Pre-releases ============================= + + def test_alpha_line_cuts_next_alpha(self): + # Setup + self.commit(":sparkles: Start v5", "v5/a.cpp") + self.tag("5.0.0-alpha.1") + self.commit(":bug: Fix thing (patch)", "v5/a.cpp") + + # Exercise + result = next_release(5, "v5") + + # Verify + self.assertEqual({"tag": "5.0.0-alpha.2", "previous": "5.0.0-alpha.1", + "prerelease": True}, result) + + def test_alpha_numbers_compare_as_numbers(self): + # Setup + self.commit(":sparkles: Start v5", "v5/a.cpp") + self.tag("5.0.0-alpha.9", "5.0.0-alpha.10") + self.commit(":sparkles: Add api (minor)", "v5/a.cpp") + + # Exercise + result = next_release(5, "v5") + + # Verify + self.assertEqual("5.0.0-alpha.11", result["tag"]) + self.assertEqual("5.0.0-alpha.10", result["previous"]) + + def test_alpha_line_without_keyword_is_not_released(self): + # Setup + self.commit(":sparkles: Start v5", "v5/a.cpp") + self.tag("5.0.0-alpha.1") + self.commit(":memo: Reword comment", "v5/a.cpp") + + # Exercise + result = next_release(5, "v5") + + # Verify + self.assertEqual("", result["tag"]) + + def test_alpha_tags_are_ignored_once_released(self): + # Setup + self.commit(":sparkles: Start v5", "v5/a.cpp") + self.tag("5.0.0-alpha.3", "5.0.0") + self.commit(":bug: Fix thing (patch)", "v5/a.cpp") + + # Exercise + result = next_release(5, "v5") + + # Verify + self.assertEqual({"tag": "5.0.1", "previous": "5.0.0", + "prerelease": False}, result) + + # ============================== Releases =============================== + + def test_patch_bumps_patch(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.2.3") + self.commit(":bug: Fix thing (patch)", "v4/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual({"tag": "4.2.4", "previous": "4.2.3", + "prerelease": False}, result) + + def test_highest_keyword_wins(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.2.3") + self.commit(":bug: Fix one (patch)", "v4/a.cpp") + self.commit(":sparkles: Add api (minor)", "v4/b.cpp") + self.commit(":bug: Fix two (patch)", "v4/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual("4.3.0", result["tag"]) + + def test_several_patches_bump_once(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.2.3") + self.commit(":bug: Fix one (patch)", "v4/a.cpp") + self.commit(":bug: Fix two (patch)", "v4/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual("4.2.4", result["tag"]) + + def test_versions_compare_as_numbers(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.9.0", "4.10.0") + self.commit(":bug: Fix thing (patch)", "v4/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual("4.10.1", result["tag"]) + + def test_keyword_in_commit_body_is_ignored(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.2.3") + self.commit(":memo: Document releases\n\n(major), (minor) and " + "(patch) ask for releases.", "v4/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual("", result["tag"]) + + def test_squash_merge_uses_its_subject(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.2.3") + self.commit(":sparkles: (minor) Add api (#12)\n\n" + "* :bug: (patch) Fix typo\n* :sparkles: (minor) Add api", + "v4/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual("4.3.0", result["tag"]) + + def test_keywords_before_last_release_are_ignored(self): + # Setup + self.commit(":sparkles: Add api (minor)", "v4/a.cpp") + self.tag("4.3.0") + self.commit(":memo: Reword comment", "v4/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual("", result["tag"]) + + def test_root_directory_line(self): + # Setup + self.tag("1.0.0") + self.commit(":bug: Fix thing (patch)", "src/a.cpp") + + # Exercise + result = next_release(1, ".") + + # Verify + self.assertEqual("1.0.1", result["tag"]) + + # ========================== Separate lines ============================= + + def test_other_directories_are_ignored(self): + # Setup + self.commit(":sparkles: Both", "v4/a.cpp", "v5/a.cpp") + self.tag("4.2.3", "5.0.0-alpha.1") + self.commit(":sparkles: Add v5 api (minor)", "v5/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual("", result["tag"]) + + def test_other_lines_tags_are_ignored(self): + # Setup + self.commit(":sparkles: Both", "v4/a.cpp", "v5/a.cpp") + self.tag("4.2.3", "5.0.0-alpha.7", "40.1.0") + self.commit(":bug: Fix both (patch)", "v4/a.cpp", "v5/a.cpp") + + # Exercise + v4 = next_release(4, "v4") + v5 = next_release(5, "v5") + + # Verify + self.assertEqual("4.2.4", v4["tag"]) + self.assertEqual("5.0.0-alpha.8", v5["tag"]) + + def test_commit_changing_both_lines_releases_both(self): + # Setup + self.commit(":sparkles: Both", "v4/a.cpp", "v5/a.cpp") + self.tag("4.2.3", "5.0.0-alpha.1") + self.commit(":sparkles: Add api to both (minor)", "v4/a.cpp", + "v5/a.cpp") + + # Exercise + v4 = next_release(4, "v4") + v5 = next_release(5, "v5") + + # Verify + self.assertEqual("4.3.0", v4["tag"]) + self.assertEqual("5.0.0-alpha.2", v5["tag"]) + + # =============================== (major) =============================== + + def test_major_keyword_is_an_error(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.2.3") + self.commit(":boom: Break api (major)", "v4/a.cpp") + + # Exercise + Verify + with self.assertRaises(ReleaseError): + next_release(4, "v4") + + def test_major_keyword_before_last_release_is_ignored(self): + # Setup + self.commit(":boom: Break api (major)", "v4/a.cpp") + self.tag("4.0.0") + self.commit(":bug: Fix thing (patch)", "v4/a.cpp") + + # Exercise + result = next_release(4, "v4") + + # Verify + self.assertEqual("4.0.1", result["tag"]) + + # ================================ CLI ================================== + + def test_cli_appends_to_output_file(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.2.3") + self.commit(":bug: Fix thing (patch)", "v4/a.cpp") + output = self.repo / "github_output" + output.write_text("existing=1\n") + script = Path(semantic_release.__file__) + + # Exercise + run = subprocess.run( + ["python3", str(script), "--major", "4", "--dir", "v4", + "--output", str(output)], capture_output=True, text=True) + + # Verify + self.assertEqual(0, run.returncode, run.stdout + run.stderr) + self.assertEqual("existing=1\ntag=4.2.4\nprevious=4.2.3\n" + "prerelease=false\n", output.read_text()) + + def test_cli_fails_on_major(self): + # Setup + self.commit(":sparkles: v4", "v4/a.cpp") + self.tag("4.2.3") + self.commit(":boom: Break api (major)", "v4/a.cpp") + script = Path(semantic_release.__file__) + + # Exercise + run = subprocess.run( + ["python3", str(script), "--major", "4", "--dir", "v4"], + capture_output=True, text=True) + + # Verify + self.assertEqual(1, run.returncode) + self.assertIn("::error::", run.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/self_check.yml b/.github/workflows/self_check.yml index 174eedc..90f6d4f 100644 --- a/.github/workflows/self_check.yml +++ b/.github/workflows/self_check.yml @@ -36,6 +36,7 @@ jobs: package_and_upload_all: ${{ steps.filter.outputs.package_and_upload_all }} app_builder2: ${{ steps.filter.outputs.app_builder2 }} tests: ${{ steps.filter.outputs.tests }} + semantic_release: ${{ steps.filter.outputs.semantic_release }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -58,6 +59,31 @@ jobs: - '.github/workflows/package_and_upload.yml' app_builder2: - '.github/workflows/app_builder2.yml' + semantic_release: + - '.github/workflows/semantic_release.yml' + - '.github/scripts/semantic_release.py' + - '.github/scripts/test_semantic_release.py' + + semantic_release_tests: + name: ๐Ÿงช semantic_release.py tests + needs: changes + if: ${{ needs.changes.outputs.semantic_release == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - run: python3 -m unittest discover -v -s .github/scripts -p "test_semantic_release.py" + + # Line 5 uses this repository's 5.x.y tags; line 99 has no tags, so it isn't + # started. Dry run: nothing is created. + semantic_release: + needs: changes + if: ${{ needs.changes.outputs.semantic_release == 'true' }} + uses: ./.github/workflows/semantic_release.yml + with: + lines: '[{"major": 5, "dir": "."}, {"major": 99, "dir": "."}]' + dry_run: true + ci_ref: ${{ github.head_ref || github.ref_name }} api_strong_ptr: needs: changes diff --git a/.github/workflows/semantic_release.yml b/.github/workflows/semantic_release.yml new file mode 100644 index 0000000..97bc205 --- /dev/null +++ b/.github/workflows/semantic_release.yml @@ -0,0 +1,130 @@ +# Copyright 2026 Khalil Estell and the libhal contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: ๐Ÿท๏ธ Semantic Release + +# Releases each version line of a repository from its own directory. +# +# A repository can hold several major versions side by side, each in its own +# directory (e.g. v4/ and v5/). Each line is released independently by +# .github/scripts/semantic_release.py: +# +# - It only sees its own tags (.*), and only counts commits that +# change its directory since its last release. A commit that changes +# several directories counts for each of their lines. +# - A commit subject asks for a release with (minor) or (patch). The +# highest keyword since the last release wins, so several (patch) commits +# make one patch release. Commit bodies are ignored, so a squash merge's +# list of commits or a description of these rules doesn't count. +# - (major) fails the run. Major versions are started by hand: publish the +# line's first tag yourself and add the line to `lines`. +# - A line whose only tags are .0.0-alpha.K makes pre-releases: +# either keyword cuts .0.0-alpha.K+1. Publishing .0.0 by +# hand switches the line to normal releases. +# +# Usage, from a workflow that runs on pushes to main: +# +# concurrency: +# group: semantic-release +# cancel-in-progress: false +# jobs: +# release: +# uses: libhal/ci/.github/workflows/semantic_release.yml@5.x.y +# with: +# lines: '[{"major": 4, "dir": "v4"}, {"major": 5, "dir": "v5"}]' +# secrets: +# release_token: ${{ secrets.SEMANTIC_RELEASE }} +# +# The concurrency group keeps two pushes from computing the same version. The +# release token must be a personal access token: a release created with the +# workflow's own GITHUB_TOKEN doesn't trigger the repository's deploy workflow. + +on: + workflow_call: + inputs: + lines: + type: string + required: true + description: >- + JSON list of version lines, each {"major": , "dir": }. + A single-directory repository uses one line with "dir": ".". + dry_run: + type: boolean + default: false + description: Print the releases that would be created, but don't create them. + ci_ref: + type: string + default: "5.x.y" + description: Git ref (branch, tag, or SHA) of libhal/ci to use for scripts + secrets: + release_token: + required: false + description: >- + Personal access token used to create the releases. Required unless + dry_run is true. + +permissions: + contents: read + +jobs: + release: + name: ๐Ÿท๏ธ Release ${{ matrix.major }}.x + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.lines) }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + path: target-repo + + - name: ๐Ÿ“ฅ Checkout CI repository for scripts + uses: actions/checkout@v4 + with: + repository: libhal/ci + ref: ${{ inputs.ci_ref }} + path: ci-repo + + - name: ๐Ÿ”ข Next ${{ matrix.major }}.x version + id: next + working-directory: target-repo + run: | + python3 ../ci-repo/.github/scripts/semantic_release.py \ + --major "${{ matrix.major }}" \ + --dir "${{ matrix.dir }}" \ + --output "$GITHUB_OUTPUT" + + - name: ๐Ÿท๏ธ Release ${{ steps.next.outputs.tag }} + if: steps.next.outputs.tag != '' + working-directory: target-repo + env: + GITHUB_TOKEN: ${{ secrets.release_token }} + TAG: ${{ steps.next.outputs.tag }} + PREVIOUS: ${{ steps.next.outputs.previous }} + PRERELEASE: ${{ steps.next.outputs.prerelease }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + command=(gh release create "$TAG" --target "$GITHUB_SHA" + --notes-start-tag "$PREVIOUS" --generate-notes) + if [ "$PRERELEASE" = "true" ]; then + command+=(--prerelease) + fi + if [ "$DRY_RUN" = "true" ]; then + echo "Dry run: ${command[*]}" + else + "${command[@]}" + fi diff --git a/README.md b/README.md index c026225..72069d0 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,12 @@ The repository contains the following callable workflows: platforms and architectures that libhal supports. Replaces the deprecated `deploy_all.yml` workflow. -8. `tests.yml`: Runs unit tests for a library on multiple platforms (Linux, +8. `semantic_release.yml`: Creates releases from `(minor)` and `(patch)` + keywords in commit subjects. Releases each major version directory (e.g. + `v4/` and `v5/`) of a repository independently, including + `.0.0-alpha.N` pre-releases. + +9. `tests.yml`: Runs unit tests for a library on multiple platforms (Linux, macOS, and optionally Windows) with optional code coverage reporting. ### Internal Workflows @@ -402,6 +407,84 @@ jobs: conan_remote_password: ${{ secrets.CONAN_PASSWORD }} ``` +### semantic_release.yml + +Creates GitHub releases from keywords in commit subjects. A repository can +hold several major versions side by side, each in its own directory (a +"version line", e.g. `v4/` and `v5/`), and each line is released +independently. The version logic is in +[`.github/scripts/semantic_release.py`](.github/scripts/semantic_release.py). + +**Rules:** + +- A line only sees its own tags (`.*`) and only counts commits that + change its directory since its last release. A commit that changes several + directories counts for each of their lines. +- `(minor)` or `(patch)` in a commit **subject** asks for a release. Commit + bodies are ignored, so a squash merge's list of commits doesn't count. +- The highest keyword since the last release wins: `(minor)` makes + `.X+1.0`, `(patch)` makes `.X.Y+1`. Several `(patch)` commits + make one patch release. +- `(major)` fails the run. Major versions are started by hand: publish the + line's first tag (`.0.0-alpha.1` or `.0.0`) yourself and add + the line to `lines`. +- A line whose only tags are `.0.0-alpha.K` makes pre-releases: either + keyword cuts `.0.0-alpha.K+1`. Publishing `.0.0` by hand + switches the line to normal releases. +- Releases are computed from the last tag, not from the commits in one push, + so a skipped or failed run is picked up by the next push. + +**Inputs:** + +- `lines` (string, **required**): JSON list of version lines, each + `{"major": , "dir": }`. A single-directory repository uses one + line with `"dir": "."`. +- `dry_run` (boolean): Print the releases that would be created instead of + creating them. Default: `false` +- `ci_ref` (string): Git ref of libhal/ci to take the script from. Default: + "5.x.y" + +**Secrets:** + +- `release_token`: Personal access token used to create the releases. + Required unless `dry_run` is true. A release created with the workflow's own + `GITHUB_TOKEN` doesn't trigger the repository's deploy workflow. + +**Usage:** + +```yaml +name: ๐Ÿท๏ธ Semantic Release + +on: + push: + branches: + - main + +# Keeps two pushes from computing the same version +concurrency: + group: semantic-release + cancel-in-progress: false + +jobs: + release: + uses: libhal/ci/.github/workflows/semantic_release.yml@5.x.y + with: + lines: '[{"major": 4, "dir": "v4"}, {"major": 5, "dir": "v5"}]' + secrets: + release_token: ${{ secrets.SEMANTIC_RELEASE }} +``` + +**Testing the script locally:** + +```bash +# Unit tests (build throwaway git repositories, no network) +python3 -m unittest discover -s .github/scripts -p "test_*.py" + +# What would be released for a line of a local clone +cd path/to/libhal +python3 path/to/ci/.github/scripts/semantic_release.py --major 4 --dir v4 +``` + ### tests.yml Runs unit tests on multiple platforms.