Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 26 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) ;;
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 13 additions & 7 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
69 changes: 69 additions & 0 deletions tools/next_release_version.py
Original file line number Diff line number Diff line change
@@ -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"(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)\Z")
TAG_PATTERN = re.compile(r"refs/tags/v(?P<version>.+)\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())
45 changes: 45 additions & 0 deletions tools/tests/test_release_version.py
Original file line number Diff line number Diff line change
@@ -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()
Loading