feat(release): same-day versions + manifest write-back so HACS sees every release (t42)

The evidence this fixes: v2026.10.09 was published while master's manifest still
read 2026.10.08, so HACS compared every install against a version that never
moved and offered nobody that update. A second release in the same UTC day was
also a silent no-op: the date tag already existed, the run resolved to the same
version again, and it stopped at "already tagged".

* scripts/determine-release-version.sh owns the decision, so it is tested rather
  than hoped for: the manifest version while it is untagged (bump it to force a
  release); else today's UTC date; else YYYY.MM.DD.N with the LOWEST untagged
  N >= 2 (a gap is reused, not burned). An already-released commit, and any
  version whose tag exists, are an explicit RELEASE=no instead of a failure. The
  script only ever reads the clock with `date -u`, and it emits
  VERSION/TAG/SOURCE/BUMP/RELEASE on stdout and into $GITHUB_OUTPUT.
* the workflow writes the resolved version into the manifest whenever it differs,
  commits it as "chore(release): v<VERSION> [skip ci]" with the existing bot
  identity, pushes HEAD:master, and only then tags THAT commit — so the artifact,
  its tag and the default branch agree. A branch-protection rejection fails the
  job with an instruction to bump the manifest manually; nothing is tagged first.
* post-push verification reads the manifest out of origin/master (the copy HACS
  fetches) and refuses to publish when it does not equal the released version.
* no self-trigger loop: the conventional [skip ci] marker on the bot commit plus a
  job-level `if:` that re-checks github.event.head_commit.message. The guard is the
  load-bearing part — it holds even where a Gitea version ignores the marker.
* preserved: manifest-wins precedence, the concurrency group without
  cancel-in-progress, notes from the commit range, zip asset upload and the
  409-tolerant publish path. RELEASE=no replaces the old `exists` output, so an
  already-tagged version still ends as a clean no-op.

Also fixes the data: master's manifest moves 2026.10.08 -> 2026.10.09, the latest
released tag, so the repo stops contradicting its own releases immediately rather
than only after the next successful run (which resolves to v2026.10.11 either way).

tests/test_release_versioning.py drives all of it: the four scenarios, the
lowest-free-suffix rule, UTC determinism under three timezones, the error paths,
the $GITHUB_OUTPUT handoff, and the workflow's own contract — textual, because the
fast suite has no YAML parser; the verify command parses the file with pyyaml.

Verified: 356 passed / 2 skipped, 357 / 1 with androidtvremote2, ruff clean,
release.yml valid, `bash -n` clean, and the script resolves the real repo to
v2026.10.11 (date, bump=yes). The workflow itself cannot run here (no Gitea
runner): its run: steps were extracted from the YAML and executed against scratch
repos with a bare origin, covering the bump+verify+tag path (commit subject, tag
target and tag manifest all agree), the inconsistent-manifest failure and the
rejected-push failure.
This commit is contained in:
James Mackay committed 2026-10-11 06:32:13 +01:00
1 parent aea9f9f9d2
commit 7608518d97
4 files changed
+611 -34

No files matched your search

+357
View File
@@ -0,0 +1,357 @@
"""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)