From ab75586ca5cfb586737a069616c0a12457a338fc Mon Sep 17 00:00:00 2001 From: Rov Date: Thu, 16 Jul 2026 12:30:22 +0930 Subject: [PATCH] Update release tooling to support next release version calculation --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 ++-- .github/workflows/release.yml | 33 ++++++++++--- README.md | 7 +-- tools/README.md | 20 +++++--- tools/next_release_version.py | 69 +++++++++++++++++++++++++++ tools/tests/test_release_version.py | 45 +++++++++++++++++ 6 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 tools/next_release_version.py create mode 100644 tools/tests/test_release_version.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e927bf9..543bf5f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,14 +10,12 @@ body: and any other private information before attaching logs or screenshots. - - type: dropdown + - type: input id: release attributes: label: ZygorPlus release - description: Select the installed release. Include the ZIP SHA-256 below if you have it. - options: - - v0.1.13 alpha - - Other / self-built version + description: Enter the installed release tag. Include the ZIP SHA-256 below if you have it. + placeholder: e.g. v0.1.14 alpha validations: required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af4b1b6..afea690 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,17 +34,18 @@ jobs: sudo apt-get install --yes lua5.1 sudo ln -sf "$(command -v lua5.1)" /usr/local/bin/lua - - name: Verify release metadata and tag availability + - name: Select the next release version id: release_metadata env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - VERSION="$(python3 -c 'import json; print(json.load(open("tools/release.json", encoding="utf-8"))["version"])')" - if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "tools/release.json must contain an X.Y.Z release version; found '$VERSION'." >&2 + if [[ "$GITHUB_REF" != refs/heads/* ]]; then + echo "Create release must be run from a branch, not '$GITHUB_REF'." >&2 exit 1 fi + BASE_VERSION="$(python3 -c 'import json; print(json.load(open("tools/release.json", encoding="utf-8"))["version"])')" + RELEASE_VERSION="$(git ls-remote --tags --refs origin | python3 tools/next_release_version.py --base-version "$BASE_VERSION")" RELEASE_CHANNEL="$(python3 -c 'import json; print(json.load(open("tools/release.json", encoding="utf-8")).get("release_channel", "stable"))')" case "$RELEASE_CHANNEL" in alpha|stable) ;; @@ -53,17 +54,29 @@ jobs: exit 1 ;; esac - RELEASE_TAG="v$VERSION" + RELEASE_TAG="v$RELEASE_VERSION" if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null; then - echo "The tag $RELEASE_TAG already exists." >&2 + echo "The selected tag $RELEASE_TAG already exists; retry the workflow to select a newer patch." >&2 exit 1 fi if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then echo "A GitHub release already exists for $RELEASE_TAG." >&2 exit 1 fi + python3 - "$RELEASE_VERSION" <<'PY' + import json + import sys + from pathlib import Path + + manifest_path = Path("tools/release.json") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = sys.argv[1] + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + PY + printf 'RELEASE_VERSION=%s\n' "$RELEASE_VERSION" >> "$GITHUB_ENV" printf 'RELEASE_TAG=%s\n' "$RELEASE_TAG" >> "$GITHUB_ENV" printf 'RELEASE_CHANNEL=%s\n' "$RELEASE_CHANNEL" >> "$GITHUB_ENV" + printf 'release_version=%s\n' "$RELEASE_VERSION" >> "$GITHUB_OUTPUT" printf 'release_tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT" printf 'release_channel=%s\n' "$RELEASE_CHANNEL" >> "$GITHUB_OUTPUT" @@ -106,7 +119,13 @@ jobs: fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag --annotate "$RELEASE_TAG" --message "ZygorPlus ${RELEASE_TAG#v} (${RELEASE_CHANNEL})" "$GITHUB_SHA" + git add tools/release.json + if ! git diff --cached --quiet; then + git commit --message "chore(release): $RELEASE_TAG" + git push origin "HEAD:$GITHUB_REF" + fi + RELEASE_COMMIT="$(git rev-parse HEAD)" + git tag --annotate "$RELEASE_TAG" --message "ZygorPlus ${RELEASE_TAG#v} (${RELEASE_CHANNEL})" "$RELEASE_COMMIT" git push origin "refs/tags/$RELEASE_TAG" - name: Publish GitHub release diff --git a/README.md b/README.md index eae06f2..eeef823 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ ZygorPlus is a guide-viewer port for World of Warcraft 3.3.5a (build 12340). Each release contains the viewer plus Common, Alliance, Horde, and legacy talent-settings bridge addons. -`v0.1.13` is an alpha prerelease intended for real-client testing and feedback. -Expect unfinished features; please report reproducible problems with your -client build, enabled addons, active guide, and Zygor diagnostics report. +The current alpha prerelease is intended for real-client testing and feedback. +Expect unfinished features; please report reproducible problems with the exact +release tag, your client build, enabled addons, active guide, and Zygor +diagnostics report. ## Installation diff --git a/tools/README.md b/tools/README.md index c16f8fd..8144b1d 100644 --- a/tools/README.md +++ b/tools/README.md @@ -139,15 +139,21 @@ release. ## Create a GitHub release The **Create release** GitHub Actions workflow is the public release profile. -It is intentionally manual. Before triggering it, commit and push the final -sources with a semantic version (for example `0.1.13`) and release channel in -`release.json`, then select that commit's branch in **Actions → Create release -→ Run workflow**. The workflow derives and checks availability of the matching -`v0.1.13` tag, runs the complete automated suite and strict addon validation, +It is intentionally manual. Commit and push the final sources with a semantic +version (for example `0.1.13`) and release channel in `release.json`, then +select that branch in **Actions → Create release → Run workflow**. The version +sets the release's major/minor stream and minimum patch. The workflow checks +remote tags and uses that version when it is free; otherwise it automatically +selects the next patch after the highest `vX.Y.Z` tag in that stream. It writes +the selected version into `release.json` and commits that change on the chosen +branch before tagging it. + +The workflow runs the complete automated suite and strict addon validation, builds the deterministic ZIP and checksum, preserves them as workflow artifacts, creates and pushes an annotated release tag, then creates the GitHub -Release with generated notes. It refuses an existing release or tag and never -creates a tag until the selected channel's gates have passed. +Release with generated notes. It never creates a tag until the selected +channel's gates have passed. To begin a new minor or major series, update the +version in `release.json` before running the workflow. The workflow requires the repository's Actions `GITHUB_TOKEN` to have **Contents: write** permission. An `alpha` channel creates a GitHub **pre-release** diff --git a/tools/next_release_version.py b/tools/next_release_version.py new file mode 100644 index 0000000..ce4ace6 --- /dev/null +++ b/tools/next_release_version.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Select the next unused patch version in a release series. + +Read ``git ls-remote --tags --refs`` output from standard input and print the +version to publish. The base version sets the major/minor release series and +the lowest patch that may be selected. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections.abc import Iterable + + +VERSION_PATTERN = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\Z") +TAG_PATTERN = re.compile(r"refs/tags/v(?P.+)\Z") + + +def parse_version(value: str) -> tuple[int, int, int]: + """Parse a final semantic version without prerelease/build suffixes.""" + match = VERSION_PATTERN.fullmatch(value) + if match is None: + raise ValueError(f"version must be X.Y.Z; found {value!r}") + return tuple(int(match.group(key)) for key in ("major", "minor", "patch")) + + +def next_release_version(base_version: str, remote_refs: Iterable[str]) -> str: + """Return the first available patch at or above *base_version*. + + Only final ``vX.Y.Z`` tags in the base version's major/minor series count. + This deliberately ignores prerelease-style tags and unrelated release + series, so a maintainer can begin a new major or minor series by updating + ``tools/release.json``. + """ + major, minor, base_patch = parse_version(base_version) + highest_patch: int | None = None + + for line in remote_refs: + ref = line.strip().split()[-1] if line.strip() else "" + tag_match = TAG_PATTERN.fullmatch(ref) + if tag_match is None: + continue + try: + tag_major, tag_minor, tag_patch = parse_version(tag_match.group("version")) + except ValueError: + continue + if (tag_major, tag_minor) != (major, minor): + continue + highest_patch = tag_patch if highest_patch is None else max(highest_patch, tag_patch) + + patch = base_patch if highest_patch is None else max(base_patch, highest_patch + 1) + return f"{major}.{minor}.{patch}" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-version", required=True, help="minimum X.Y.Z version for the release series") + args = parser.parse_args() + try: + print(next_release_version(args.base_version, sys.stdin)) + except ValueError as exc: + parser.error(str(exc)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_release_version.py b/tools/tests/test_release_version.py new file mode 100644 index 0000000..e4074b6 --- /dev/null +++ b/tools/tests/test_release_version.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +import sys + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +from next_release_version import next_release_version, parse_version # noqa: E402 + + +class NextReleaseVersionTests(unittest.TestCase): + def test_uses_base_version_when_the_series_has_no_tag(self) -> None: + refs = [ + "deadbeef\trefs/tags/v0.1.12", + "deadbeef\trefs/tags/v0.2.0", + ] + self.assertEqual(next_release_version("0.1.13", refs), "0.1.13") + + def test_increments_past_the_highest_existing_patch(self) -> None: + refs = [ + "deadbeef\trefs/tags/v0.1.13", + "deadbeef\trefs/tags/v0.1.15", + "deadbeef\trefs/tags/v0.1.14", + ] + self.assertEqual(next_release_version("0.1.13", refs), "0.1.16") + + def test_ignores_other_series_and_non_final_tags(self) -> None: + refs = [ + "deadbeef\trefs/tags/v0.2.999", + "deadbeef\trefs/tags/v1.1.999", + "deadbeef\trefs/tags/v0.1.999-alpha.1", + "deadbeef\trefs/tags/not-a-release", + ] + self.assertEqual(next_release_version("0.1.13", refs), "0.1.13") + + def test_rejects_non_semantic_base_version(self) -> None: + with self.assertRaisesRegex(ValueError, "X.Y.Z"): + parse_version("0.1") + + +if __name__ == "__main__": + unittest.main()