"""tests/test_release_versioning.py — the release version decision (t42). Why this file exists: the version the workflow picks decides whether HACS ever offers a user the update. Every wrong answer is silent — the tag and the release still appear, only the manifest never moves — and the real evidence is in the repo history: v2026.10.09 was published while master's manifest still read 2026.10.08. The decision therefore lives in ``scripts/determine-release-version.sh`` and every branch below is driven directly, rather than trusted to a CI run. The four scenarios the task names, plus the boundary cases that made the old inline shell wrong: (a) manifest version untagged -> manifest wins (BUMP=no) (b) manifest tagged elsewhere, date free -> date (c) manifest AND date tagged -> same-day CALVER suffix, lowest free N (d) the commit is already released -> RELEASE=no, explicit no-op signal The script is also the only place that reads the clock, and it reads it in UTC. """ import json import os import re import subprocess from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPT = REPO_ROOT / "scripts" / "determine-release-version.sh" WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "release.yml" MANIFEST = REPO_ROOT / "custom_components" / "nvidia_shield" / "manifest.json" HEAD = "a" * 40 OTHER = "b" * 40 TODAY = "2026.10.11" def _resolve( tmp_path: Path, *, manifest_version: str, tags=(), today: str | None = TODAY, head: str | None = HEAD, env: dict | None = None, ): """Run the resolver against a fixture manifest and tag list.""" tmp_path.mkdir(parents=True, exist_ok=True) manifest = tmp_path / "manifest.json" manifest.write_text( json.dumps({"domain": "nvidia_shield", "version": manifest_version}) + "\n", encoding="utf-8", ) tag_list = tmp_path / "tags" tag_list.write_text( "".join(f"{tag} {sha}\n" for tag, sha in tags), encoding="utf-8" ) argv = [ "bash", str(SCRIPT), "--manifest", str(manifest), "--tags-from", str(tag_list), ] if today is not None: argv += ["--today", today] if head is not None: argv += ["--head", head] result = subprocess.run( argv, capture_output=True, text=True, env={**os.environ, **(env or {})}, check=False, ) values = dict( line.split("=", 1) for line in result.stdout.splitlines() if "=" in line ) return result, values # ── (a) the manifest wins while its version is untagged ────────────────────── def test_an_untagged_manifest_version_is_released_as_is(tmp_path): result, values = _resolve( tmp_path, manifest_version="2026.10.11", tags=[("v2026.10.09", OTHER)] ) assert result.returncode == 0, result.stderr assert values["VERSION"] == "2026.10.11" assert values["TAG"] == "v2026.10.11" assert values["SOURCE"] == "manifest" assert values["RELEASE"] == "yes" # the manifest already says it: no write-back commit, so the tag lands on # the pushed commit itself assert values["BUMP"] == "no" def test_a_leading_v_in_the_manifest_is_tolerated(tmp_path): """The tag adds the `v`; a hand-written one must not become the version.""" _result, values = _resolve(tmp_path, manifest_version="v2026.10.11") assert values["VERSION"] == "2026.10.11" assert values["TAG"] == "v2026.10.11" # ── (b) once the manifest version is tagged, the date takes over ───────────── def test_a_tagged_manifest_version_falls_back_to_the_utc_date(tmp_path): result, values = _resolve( tmp_path, manifest_version="2026.10.09", tags=[("v2026.10.09", OTHER)] ) assert result.returncode == 0, result.stderr assert values["VERSION"] == TODAY assert values["SOURCE"] == "date" assert values["RELEASE"] == "yes" # the manifest still says the old version: the workflow must write the new # one back, or HACS keeps comparing against 2026.10.09 forever assert values["BUMP"] == "yes" # ── (c) a second release in one UTC day ───────────────────────────────────── def test_a_tagged_date_falls_back_to_a_same_day_suffix(tmp_path): result, values = _resolve( tmp_path, manifest_version="2026.10.09", tags=[("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)], ) assert result.returncode == 0, result.stderr assert values["VERSION"] == f"{TODAY}.2" assert values["SOURCE"] == "same-day" assert values["RELEASE"] == "yes" assert values["BUMP"] == "yes" def test_the_suffix_is_the_lowest_untagged_n_from_two(tmp_path): """Not "highest + 1": a gap is filled, so a failed run does not burn a number.""" _result, values = _resolve( tmp_path, manifest_version="2026.10.09", tags=[ ("v2026.10.09", OTHER), (f"v{TODAY}", OTHER), (f"v{TODAY}.2", OTHER), ], ) assert values["VERSION"] == f"{TODAY}.3" _result, values = _resolve( tmp_path / "second", manifest_version="2026.10.09", tags=[ ("v2026.10.09", OTHER), (f"v{TODAY}", OTHER), (f"v{TODAY}.2", OTHER), (f"v{TODAY}.3", OTHER), (f"v{TODAY}.5", OTHER), # a gap at .4 must be reused ], ) assert values["VERSION"] == f"{TODAY}.4" # ── (d) nothing to do ─────────────────────────────────────────────────────── def test_an_already_released_commit_is_an_explicit_no_op(tmp_path): """A re-run (manual dispatch, a retried job) must not invent a new version.""" result, values = _resolve( tmp_path, manifest_version="2026.10.09", tags=[("v2026.10.09", HEAD)], # this very commit is the release head=HEAD, ) assert result.returncode == 0, result.stderr assert values["RELEASE"] == "no" assert values["VERSION"] == "2026.10.09" assert values["SOURCE"] == "released" assert values["BUMP"] == "no" assert "nothing to release" in result.stderr def test_a_release_is_never_planned_for_a_tag_that_exists(tmp_path): """The property the backstop defends, over a spread of tag sets. RELEASE=yes must always mean "this version is not tagged yet": that is what makes the workflow's tag step safe without re-checking. """ scenarios = [ ("2026.10.09", []), ("2026.10.09", [("v2026.10.09", OTHER)]), ("2026.10.09", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]), ("2026.10.11", [("v2026.10.08", OTHER)]), (f"{TODAY}.2", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]), ("2026.10.09", [("v2026.10.09", HEAD)]), # already released ] for index, (manifest_version, tags) in enumerate(scenarios): _result, values = _resolve( tmp_path / f"case{index}", manifest_version=manifest_version, tags=tags ) tagged = {tag for tag, _sha in tags} if values["RELEASE"] == "yes": assert values["TAG"] not in tagged, (manifest_version, tags, values) else: assert values["SOURCE"] in {"released", "tagged"} # ── (e) UTC determinism ───────────────────────────────────────────────────── def test_the_date_is_utc_whatever_the_runner_timezone_is(tmp_path): """A version number must not depend on where the job happened to run.""" tags = [("v2026.09.30", OTHER)] # the manifest version is tagged -> date runs = [] for timezone in ("Pacific/Kiritimati", "Etc/GMT+12", "America/Anchorage"): _result, values = _resolve( tmp_path / timezone.replace("/", "_"), manifest_version="2026.09.30", tags=tags, today=None, # exercise the real clock env={"TZ": timezone}, ) runs.append(values["VERSION"]) utc_today = subprocess.run( ["date", "-u", "+%Y.%m.%d"], capture_output=True, text=True, check=True ).stdout.strip() assert len(set(runs)) == 1, runs assert runs[0] == utc_today # ── usage and error handling ──────────────────────────────────────────────── def test_a_missing_manifest_fails_loudly(tmp_path): result = subprocess.run( ["bash", str(SCRIPT), "--manifest", str(tmp_path / "nope.json"), "--today", TODAY], capture_output=True, text=True, check=False, ) assert result.returncode == 1 assert "manifest not found" in result.stderr def test_a_non_calver_manifest_version_fails_loudly(tmp_path): result, _values = _resolve(tmp_path, manifest_version="1.2.3") assert result.returncode == 1 assert "not YYYY.MM.DD" in result.stderr def test_an_unknown_argument_is_a_usage_error(tmp_path): result = subprocess.run( ["bash", str(SCRIPT), "--nonsense"], capture_output=True, text=True, check=False ) assert result.returncode == 1 assert "unknown argument" in result.stderr def test_github_output_receives_the_same_values(tmp_path): """The workflow consumes $GITHUB_OUTPUT, so the script must fill it too.""" output = tmp_path / "github_output" output.write_text("", encoding="utf-8") result, values = _resolve( tmp_path, manifest_version="2026.10.11", env={"GITHUB_OUTPUT": str(output)} ) assert result.returncode == 0 written = dict( line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines() if "=" in line ) assert written == values assert set(written) == {"VERSION", "TAG", "SOURCE", "BUMP", "RELEASE"} # ── the workflow contract (textual: the fast suite has no YAML parser) ─────── @pytest.fixture(scope="module") def workflow_text() -> str: return WORKFLOW.read_text(encoding="utf-8") def test_the_workflow_uses_the_tested_script(workflow_text): assert "scripts/determine-release-version.sh" in workflow_text def test_the_workflow_writes_the_manifest_back_and_tags_that_commit(workflow_text): # the exact commit message the task pins, with the skip-ci marker assert "chore(release): v${VERSION} [skip ci]" in workflow_text # the push happens before the tag, so the tag names the bumped commit assert "git push origin HEAD:master" in workflow_text assert 'git push origin "refs/tags/${TAG}"' in workflow_text def test_a_rejected_bump_push_fails_with_a_manual_instruction(workflow_text): assert "could not push" in workflow_text assert "bump" in workflow_text.lower() and "manually" in workflow_text.lower() def test_the_workflow_verifies_the_pushed_manifest_before_publishing(workflow_text): assert "origin/master:${MANIFEST}" in workflow_text assert "inconsistent" in workflow_text.lower() def test_the_bot_commit_cannot_trigger_another_release(workflow_text): # two layers: the conventional skip-ci marker on the commit, and a job-level # guard that re-checks the message (the guard is what must hold even if a # future Gitea stops honouring the marker) assert "[skip ci]" in workflow_text assert "github.event.head_commit.message" in workflow_text assert "workflow_dispatch" in workflow_text def test_the_preserved_release_behaviour_is_still_there(workflow_text): # one release at a time, never cancelled mid-flight assert "group: release-master" in workflow_text assert "cancel-in-progress: false" in workflow_text # notes from the commit range assert "git log --pretty=format" in workflow_text # the 409-tolerant publish path assert "409 is" in workflow_text or "409" in workflow_text assert "/releases/tags/" in workflow_text # the zip asset assert "attachment=@${ZIP_NAME}" in workflow_text def test_the_script_reads_the_manifest_the_workflow_bumps(): """One path, so a moved manifest cannot silently release a stale version.""" assert MANIFEST.is_file() assert "custom_components/nvidia_shield/manifest.json" in SCRIPT.read_text( encoding="utf-8" ) manifest_version = json.loads(MANIFEST.read_text(encoding="utf-8"))["version"] # the script rejects anything else, so a typo here would fail every release assert re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?", manifest_version)