From f6a1f6ebdd17051a20e6ff94d8f2d1c014a36024 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Mon, 31 Aug 2026 03:08:16 +1000 Subject: [PATCH] Switch to semantic versioning, add --version The binary had no idea what version it was: no --version flag and no version constant anywhere in src/. The release number was invented inside CI from the build date and github.run_number, so it existed only in the git tag and the asset names, and a bug reporter could not say which build they were running. That scheme was also unsound. github.run_number resets when a workflow is recreated, which already happened here: 2026.01.31.204 was published before 2026.01.31.3, so a newer release carries a lower number and no ordering comparison can work. - src/version.py holds VERSION as the single source of truth - pyproject.toml declares the version dynamic instead of duplicating it - --version prints it, and works with no subcommand and outside a game directory - CI reads the version from src/version.py, uses it for the tag, the release name and the asset names, and skips the release entirely when that tag already exists The last point also fixes releases being cut on every push to master, including commits that change nothing a user can see. Existing tags are left alone. Reconstructing the history against the nine published releases puts the current state at 1.3.2, so this ships as 1.4.0 for the new flag. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 41 +++++++++++++++++++++------ pyproject.toml | 2 +- src/snapjaw.py | 2 ++ src/version.py | 1 + tests/test_version.py | 60 ++++++++++++++++++++++++++++++++++++++++ uv.lock | 1 - 6 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 src/version.py create mode 100644 tests/test_version.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 939e704..844de13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,25 +144,48 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Get version + id: version + env: + PYTHONPATH: src + run: python -c "from version import VERSION; print(f'version={VERSION}')" >> "$GITHUB_OUTPUT" + + - name: Check tag + id: tag + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/${VERSION}" > /dev/null; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + status=$? + if [ "$status" -ne 2 ]; then + exit "$status" + fi + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + - name: Download artifacts + if: steps.tag.outputs.exists == 'false' uses: actions/download-artifact@v8 - - name: Get date - id: date - run: echo "date=$(date +%Y.%m.%d)" >> "$GITHUB_OUTPUT" - - name: Rename artifacts + if: steps.tag.outputs.exists == 'false' env: - VERSION: ${{ steps.date.outputs.date }}.${{ github.run_number }} + VERSION: ${{ steps.version.outputs.version }} run: | mv snapjaw-windows-x86_64/snapjaw-windows-x86_64.zip snapjaw-${VERSION}-windows-x86_64.zip mv snapjaw-linux-x86_64/snapjaw-linux-x86_64.tar.gz snapjaw-${VERSION}-linux-x86_64.tar.gz - name: Release + if: steps.tag.outputs.exists == 'false' uses: softprops/action-gh-release@v3 with: - tag_name: ${{ steps.date.outputs.date }}.${{ github.run_number }} - name: ${{ steps.date.outputs.date }}.${{ github.run_number }} + tag_name: ${{ steps.version.outputs.version }} + name: ${{ steps.version.outputs.version }} files: | - snapjaw-${{ steps.date.outputs.date }}.${{ github.run_number }}-windows-x86_64.zip - snapjaw-${{ steps.date.outputs.date }}.${{ github.run_number }}-linux-x86_64.tar.gz + snapjaw-${{ steps.version.outputs.version }}-windows-x86_64.zip + snapjaw-${{ steps.version.outputs.version }}-linux-x86_64.tar.gz diff --git a/pyproject.toml b/pyproject.toml index 30bd7d3..f907a76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "tabulate>=0.9.0", ] name = "snapjaw" -version = "1.0" +dynamic = ["version"] description = "Vanilla WoW AddOn Manager" [tool.ruff] diff --git a/src/snapjaw.py b/src/snapjaw.py index c430643..86c514d 100644 --- a/src/snapjaw.py +++ b/src/snapjaw.py @@ -24,6 +24,7 @@ import mygit import signature import toc +import version class CliError(RuntimeError): @@ -79,6 +80,7 @@ def main(): def parse_args(): parser = argparse.ArgumentParser() + parser.add_argument("--version", action="version", version=f"snapjaw {version.VERSION}") parser.add_argument( "--addons-dir", diff --git a/src/version.py b/src/version.py new file mode 100644 index 0000000..af63e4a --- /dev/null +++ b/src/version.py @@ -0,0 +1 @@ +VERSION = "1.4.0" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..014f605 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,60 @@ +"""Tests for the application version and CLI version flag.""" + +import re + +import pytest + +import version +from snapjaw import parse_args + + +class TestVersion: + """Tests for the single source of truth version constant.""" + + def test_has_three_numeric_components(self): + """VERSION uses semantic versioning's three-component numeric form.""" + assert re.fullmatch(r"\d+\.\d+\.\d+", version.VERSION) + + +class TestVersionFlag: + """Tests for the top-level --version flag.""" + + @staticmethod + def assert_version_output(monkeypatch, capsys, argv): + monkeypatch.setattr("sys.argv", argv) + + with pytest.raises(SystemExit) as error: + parse_args() + + assert error.value.code == 0 + captured = capsys.readouterr() + assert captured.out == f"snapjaw {version.VERSION}\n" + assert captured.err == "" + + def test_prints_exact_version(self, monkeypatch, capsys, tmp_path): + """--version prints the application name and VERSION exactly.""" + self.assert_version_output( + monkeypatch, + capsys, + [ + "snapjaw", + "--addons-dir", + str(tmp_path), + "--game-version", + "vanilla", + "--version", + "status", + ], + ) + + def test_works_without_subcommand(self, monkeypatch, capsys): + """--version exits before required subcommand validation.""" + self.assert_version_output(monkeypatch, capsys, ["snapjaw", "--version"]) + + def test_works_outside_game_directory(self, monkeypatch, capsys, tmp_path): + """--version exits before attempting to resolve an addons directory.""" + isolated_directory = tmp_path / "isolated" + isolated_directory.mkdir() + monkeypatch.chdir(isolated_directory) + + self.assert_version_output(monkeypatch, capsys, ["snapjaw", "--version"]) diff --git a/uv.lock b/uv.lock index 90e45d8..cfee933 100644 --- a/uv.lock +++ b/uv.lock @@ -309,7 +309,6 @@ wheels = [ [[package]] name = "snapjaw" -version = "1.0" source = { virtual = "." } dependencies = [ { name = "colorama" },