"""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_skip_guard_matches_the_subject_not_the_whole_message(workflow_text): """t44 R3: `contains(message, '[skip ci]')` skipped a release silently. A developer commit whose BODY mentions the marker is not a release commit, so the guard is anchored to the release bot's subject prefix instead. """ assert "!startsWith(github.event.head_commit.message || '', 'chore(release):')" in ( workflow_text ) assert "!contains(github.event.head_commit.message" not in workflow_text def test_the_release_notes_exclude_the_write_back_commit(workflow_text): """t44 R1: with a write-back the tag IS the bot commit, so notes must skip it.""" assert 'if [ "$BUMP" = "yes" ]; then' in workflow_text assert 'NOTES_END="${TAG}^"' in workflow_text assert 'RANGE="${PREV}..${NOTES_END}"' in workflow_text assert 'BUMP: ${{ steps.version.outputs.BUMP }}' 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) # ── t44 3c: real repositories, and the workflow's own steps by execution ───── MANIFEST_REL = "custom_components/nvidia_shield/manifest.json" def _git(repo, *args) -> str: return subprocess.run( ["git", *args], cwd=repo, capture_output=True, text=True, check=True ).stdout def _repo(tmp_path, *, version="2026.10.09", commits=("a feature commit",)): """A real repository: the resolver's tag lookups really are git commands.""" repo = tmp_path / "repo" repo.mkdir(parents=True) _git(repo, "init", "-q", "-b", "master") _git(repo, "config", "user.email", "dev@example.com") _git(repo, "config", "user.name", "dev") manifest = repo / MANIFEST_REL manifest.parent.mkdir(parents=True, exist_ok=True) for index, subject in enumerate(commits): manifest.write_text( json.dumps( {"domain": "nvidia_shield", "version": version, "note": index} ) + "\n" ) _git(repo, "add", "-A") _git(repo, "commit", "-qm", subject) return repo def _resolve_in(repo, *, today=TODAY): """Run the resolver inside a real repo — no tag fixtures, the real git paths.""" result = subprocess.run( ["bash", str(SCRIPT), "--manifest", MANIFEST_REL, "--today", today], cwd=repo, capture_output=True, text=True, check=False, ) values = dict( line.split("=", 1) for line in result.stdout.splitlines() if "=" in line ) return result, values class TestRealRepositories: """The tagged-version branches against real git (t44 3c). The fixture-based tests above never exercise `refs/tags/X^{commit}`, and that dereference is the whole reason an annotated tag is recognised: for one, `git rev-parse refs/tags/X` is the TAG OBJECT, not the commit, so a comparison that forgot to dereference would miss a released commit entirely. """ def test_an_annotated_tag_on_head_is_already_released(self, tmp_path): repo = _repo(tmp_path) _git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09") result, values = _resolve_in(repo) assert result.returncode == 0, result.stderr assert values["SOURCE"] == "released" assert values["RELEASE"] == "no" assert "nothing to release" in result.stderr # premise: the tag object really is not the commit assert _git(repo, "rev-parse", "v2026.10.09").strip() != _git( repo, "rev-parse", "HEAD" ).strip() def test_a_lightweight_tag_on_head_is_also_already_released(self, tmp_path): repo = _repo(tmp_path) _git(repo, "tag", "v2026.10.09") _result, values = _resolve_in(repo) assert values["SOURCE"] == "released" assert values["RELEASE"] == "no" def test_a_tag_on_an_older_commit_falls_back_to_the_date(self, tmp_path): repo = _repo(tmp_path, commits=("the released work", "later work")) _git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09", "HEAD~1") _result, values = _resolve_in(repo) assert values["VERSION"] == TODAY assert values["SOURCE"] == "date" assert values["BUMP"] == "yes" assert values["RELEASE"] == "yes" def test_no_tags_at_all_means_the_manifest_wins(self, tmp_path): repo = _repo(tmp_path, version="2026.10.10") _result, values = _resolve_in(repo) assert values["VERSION"] == "2026.10.10" assert values["SOURCE"] == "manifest" assert values["BUMP"] == "no" def test_the_same_day_scan_walks_real_tags(self, tmp_path): repo = _repo(tmp_path, commits=("released", "more")) for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.2"): _git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1") _result, values = _resolve_in(repo) assert values["VERSION"] == f"{TODAY}.3" assert values["SOURCE"] == "same-day" def test_a_gap_in_the_suffixes_is_reused(self, tmp_path): repo = _repo(tmp_path, commits=("released", "more")) for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.3"): _git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1") _result, values = _resolve_in(repo) assert values["VERSION"] == f"{TODAY}.2" def test_the_guard_has_a_script_side_layer(self, tmp_path): """Even with the job guard bypassed, a released commit releases nothing. The job-level `if:` cannot be executed here (no Gitea runner), but the decision it protects can: this is the layer that makes a bypass harmless. """ repo = _repo(tmp_path) _git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09") # a re-run of the same push, guard or no guard for _attempt in range(2): _result, values = _resolve_in(repo) assert values["RELEASE"] == "no" assert values["BUMP"] == "no" def _step_script(name: str) -> str: """The `run:` body of one workflow step, read by hand. The fast suite has no YAML parser (the verify command parses the file with pyyaml instead), and executing these steps is worth more than matching their text, so the block is taken verbatim and dedented. """ lines = WORKFLOW.read_text(encoding="utf-8").splitlines() start = next(i for i, line in enumerate(lines) if line.strip() == f"- name: {name}") run_at = next( i for i in range(start, len(lines)) if lines[i].strip() in {"run: |", "run: >-"} ) indent = len(lines[run_at + 1]) - len(lines[run_at + 1].lstrip()) body = [] for line in lines[run_at + 1 :]: if line.strip() and not line.startswith(" " * indent): break body.append(line[indent:] if len(line) >= indent else "") return "\n".join(body) def _run_step(name: str, cwd, env): return subprocess.run( ["bash", "-euo", "pipefail", "-c", _step_script(name)], cwd=cwd, capture_output=True, text=True, env={**os.environ, **env}, ) class TestWorkflowStepsByExecution: """Run the workflow's own steps against a bare origin (t44 3c). Textual pins cannot tell whether the bump lands on the commit the tag names; these do, and they are what the dry run in the task output was. """ VERSION = "2026.10.11" TAG = "v2026.10.11" def _clone(self, tmp_path, *, protect=False): origin = tmp_path / "origin.git" subprocess.run( ["git", "init", "--bare", "-q", "-b", "master", str(origin)], check=True ) work = tmp_path / "work" subprocess.run(["git", "clone", "-q", str(origin), str(work)], check=True) _git(work, "config", "user.email", "dev@example.com") _git(work, "config", "user.name", "dev") manifest = work / MANIFEST_REL manifest.parent.mkdir(parents=True, exist_ok=True) manifest.write_text( json.dumps({"domain": "nvidia_shield", "version": "2026.10.08"}) + "\n" ) _git(work, "add", "-A") _git(work, "commit", "-qm", "a feature commit") _git(work, "push", "-q", "origin", "master") if protect: hook = origin / "hooks" / "pre-receive" hook.write_text("#!/bin/sh\necho 'protected branch' >&2\nexit 1\n") hook.chmod(0o755) return work def _env(self): return { "MANIFEST": MANIFEST_REL, "VERSION": self.VERSION, "TAG": self.TAG, } def test_the_bump_verify_and_tag_steps_agree_on_one_commit(self, tmp_path): work = self._clone(tmp_path) bump = _run_step("Write the version back into the manifest", work, self._env()) assert bump.returncode == 0, bump.stderr assert _git(work, "log", "-1", "--pretty=%s").strip() == ( f"chore(release): {self.TAG} [skip ci]" ) verify = _run_step("Verify the released manifest on master", work, self._env()) assert verify.returncode == 0, verify.stderr assert f"matching {self.TAG}" in verify.stdout tag = _run_step("Create tag", work, self._env()) assert tag.returncode == 0, tag.stderr head = _git(work, "rev-parse", "HEAD").strip() assert _git(work, "rev-parse", f"{self.TAG}^{{commit}}").strip() == head tagged_manifest = json.loads(_git(work, "show", f"{self.TAG}:{MANIFEST_REL}")) assert tagged_manifest["version"] == self.VERSION assert _git(work, "ls-remote", "--tags", "origin", self.TAG).split()[1] == ( f"refs/tags/{self.TAG}" ) def test_the_verify_step_refuses_an_inconsistent_manifest(self, tmp_path): work = self._clone(tmp_path) _run_step("Write the version back into the manifest", work, self._env()) # someone else's release lands on master before this one is verified (work / MANIFEST_REL).write_text( json.dumps({"domain": "nvidia_shield", "version": "2026.10.99"}) + "\n" ) _git(work, "add", "-A") _git(work, "commit", "-qm", "a competing release") _git(work, "push", "-q", "origin", "master") verify = _run_step("Verify the released manifest on master", work, self._env()) assert verify.returncode == 1 assert "inconsistent" in verify.stderr assert "2026.10.99" in verify.stderr def test_the_notes_range_describes_the_developers_commits(self, tmp_path): """t44 R1, executed: the range the notes use must not open with the bot commit. With a write-back the tag IS the bot's commit, so `PREV..TAG` (the range before this fix) published "- chore(release): vX [skip ci]" as the first line of every release. The range is built from ``BUMP``, and this runs both halves through real git. """ work = self._clone(tmp_path) _git(work, "tag", "-a", "v2026.10.08", "-m", "previous release") _git(work, "commit", "--allow-empty", "-qm", "feat: the real change") _run_step("Write the version back into the manifest", work, self._env()) _run_step("Create tag", work, self._env()) def subjects(rev_range): return _git(work, "log", "--pretty=format:- %s", rev_range) notes_end = f"{self.TAG}^" # BUMP=yes: the write-back commit is skipped prev = _git(work, "describe", "--tags", "--abbrev=0", f"{notes_end}^").strip() assert prev == "v2026.10.08" assert subjects(f"{prev}..{notes_end}") == "- feat: the real change" # ...and the range this replaced really did include the bot's own subject assert "- chore(release):" in subjects(f"{prev}..{self.TAG}") def test_a_rejected_bump_push_fails_and_tags_nothing(self, tmp_path): work = self._clone(tmp_path, protect=True) bump = _run_step("Write the version back into the manifest", work, self._env()) assert bump.returncode == 1 assert "could not push" in bump.stderr assert "manually" in bump.stderr assert MANIFEST_REL in bump.stderr assert _git(work, "tag", "-l").strip() == ""