From 7608518d97ae1c2c646c636675657bfb557143f2 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Sun, 11 Oct 2026 06:32:05 +0100 Subject: [PATCH] feat(release): same-day versions + manifest write-back so HACS sees every release (t42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [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. --- .gitea/workflows/release.yml | 129 +++++-- custom_components/nvidia_shield/manifest.json | 2 +- scripts/determine-release-version.sh | 157 ++++++++ tests/test_release_versioning.py | 357 ++++++++++++++++++ 4 files changed, 611 insertions(+), 34 deletions(-) create mode 100755 scripts/determine-release-version.sh create mode 100644 tests/test_release_versioning.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4e20955..e1e4e58 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,10 +1,19 @@ -# Publish a date-based release every time master moves. +# Publish a date-based release every time master moves — and keep HACS able to +# see every one of them (t42). # -# Versioning is date-based: vYYYY.MM.DD. Priority rule — an unreleased `version` -# declared in custom_components/nvidia_shield/manifest.json wins (bump the -# manifest to force a specific release, e.g. after a meaningful change); otherwise -# the version is today's date (UTC), so a plain push to master publishes the -# day's release. Repeated pushes on the same day are deduped by tag existence. +# Versioning is date-based: vYYYY.MM.DD, plus a same-day suffix (YYYY.MM.DD.N) +# for further releases in the same UTC day. The decision lives in +# scripts/determine-release-version.sh so it can be tested locally: +# the manifest `version` is untagged -> that version (bump it to force one) +# else today's UTC date is untagged -> vYYYY.MM.DD +# else -> vYYYY.MM.DD.N, lowest untagged N >= 2 +# +# Why the write-back below exists: the resolved version is written into +# custom_components/nvidia_shield/manifest.json and committed BEFORE the tag, so +# the released artifact and its tag always agree. Without it HACS compares the +# installed version against a manifest that never moves — v2026.10.09 was +# published while master's manifest still said 2026.10.08, so the second release +# of that day was offered to nobody. # # The Gitea release carries a nvidia_shield.zip asset (custom_components/ # nvidia_shield/) so the integration is installable manually from any release. @@ -28,15 +37,25 @@ concurrency: cancel-in-progress: false permissions: - # The job token pushes the tag and creates the release. + # The job token pushes the manifest commit and the tag, and creates the release. contents: write env: ZIP_NAME: nvidia_shield.zip + MANIFEST: custom_components/nvidia_shield/manifest.json jobs: release: runs-on: ubuntu-latest + # t42: this job pushes the write-back commit itself, and a run starting from + # that commit would release the same version twice. Two layers guard it: the + # commit message carries the conventional `[skip ci]` marker, and this + # condition re-checks the pushed message, so the guard still holds if a Gitea + # version stops honouring the marker. workflow_dispatch has no head_commit, + # hence the first clause. + if: >- + github.event_name == 'workflow_dispatch' || + !contains(github.event.head_commit.message || '', '[skip ci]') steps: - uses: actions/checkout@v4 with: @@ -45,49 +64,93 @@ jobs: ref: master token: ${{ secrets.GITEA_TOKEN }} - - name: Determine release version + - name: Determine the release version id: version run: | set -euo pipefail - MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)" - DATE_VERSION="$(date -u +%Y.%m.%d)" - # An unreleased manifest version wins over today's date. - if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then - VERSION="${DATE_VERSION}" - else - VERSION="${MANIFEST_VERSION}" - fi - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then - echo "v${VERSION} is already tagged — nothing to release" - echo "exists=yes" >> "$GITHUB_OUTPUT" - else - echo "releasing v${VERSION}" - echo "exists=no" >> "$GITHUB_OUTPUT" - fi + # VERSION/TAG/SOURCE/BUMP/RELEASE go to stdout and, because GITHUB_OUTPUT + # is set inside a step, to this job's outputs too. RELEASE=no means the + # version already exists: tag and publish nothing. + scripts/determine-release-version.sh - - name: Create tag - if: steps.version.outputs.exists == 'no' + - name: Write the version back into the manifest + id: bump + if: steps.version.outputs.RELEASE == 'yes' && steps.version.outputs.BUMP == 'yes' + env: + VERSION: ${{ steps.version.outputs.VERSION }} run: | set -euo pipefail git config user.name "gitea-actions" git config user.email "actions@notexpectedyet.com" - git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" - git push origin "refs/tags/${{ steps.version.outputs.tag }}" + + python3 - "$MANIFEST" "$VERSION" <<'PY' + import json, pathlib, sys + path, version = pathlib.Path(sys.argv[1]), sys.argv[2] + data = json.loads(path.read_text(encoding="utf-8")) + data["version"] = version + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + PY + + git add "$MANIFEST" + git commit -m "chore(release): v${VERSION} [skip ci]" + + # Never tag a commit whose manifest the default branch does not carry: + # a branch protection rule (or a push that landed underneath this run) + # rejecting this push makes the release impossible, so stop loudly and + # say what to do instead of publishing an inconsistent artifact. + if ! git push origin HEAD:master; then + echo "::error::could not push the release commit for v${VERSION}: the" \ + "default branch rejected it (branch protection, or it moved during" \ + "this run). Nothing was tagged or published. Bump ${MANIFEST} to" \ + "${VERSION} manually, commit it, and re-run this workflow." >&2 + exit 1 + fi + + - name: Verify the released manifest on master + if: steps.version.outputs.RELEASE == 'yes' + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + set -euo pipefail + # Read the manifest from the REMOTE default branch, not from the + # workspace: that is the copy HACS fetches, and it has to already carry + # the version about to be tagged. + git fetch --quiet origin master + REMOTE_VERSION="$(git show "origin/master:${MANIFEST}" | jq -r '.version')" + if [ "$REMOTE_VERSION" != "$VERSION" ]; then + echo "::error::master's manifest says ${REMOTE_VERSION} but v${VERSION}" \ + "is being released — refusing to publish an inconsistent artifact." \ + "Re-run once master settles." >&2 + exit 1 + fi + echo "master's manifest is ${REMOTE_VERSION}, matching v${VERSION}" + + - name: Create tag + if: steps.version.outputs.RELEASE == 'yes' + env: + TAG: ${{ steps.version.outputs.TAG }} + run: | + set -euo pipefail + git config user.name "gitea-actions" + git config user.email "actions@notexpectedyet.com" + # HEAD is the commit the manifest was just verified on: the bumped one + # when a write-back was needed, else the pushed commit itself. The tag + # and the artifact therefore name the same version. + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "refs/tags/${TAG}" - name: Build the release zip - if: steps.version.outputs.exists == 'no' + if: steps.version.outputs.RELEASE == 'yes' run: | set -euo pipefail cd custom_components zip -r "../${ZIP_NAME}" nvidia_shield - name: Publish the release - if: steps.version.outputs.exists == 'no' + if: steps.version.outputs.RELEASE == 'yes' env: - VERSION: ${{ steps.version.outputs.version }} - TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ steps.version.outputs.VERSION }} + TAG: ${{ steps.version.outputs.TAG }} SERVER: ${{ github.server_url }} REPO: ${{ github.repository }} TOKEN: ${{ secrets.GITEA_TOKEN }} diff --git a/custom_components/nvidia_shield/manifest.json b/custom_components/nvidia_shield/manifest.json index b7fc60a..72a8d1a 100644 --- a/custom_components/nvidia_shield/manifest.json +++ b/custom_components/nvidia_shield/manifest.json @@ -13,7 +13,7 @@ "adb-shell>=0.4.0", "androidtvremote2>=0.2.0" ], - "version": "2026.10.08", + "version": "2026.10.09", "zeroconf": [ "_androidtvremote2._tcp.local." ], diff --git a/scripts/determine-release-version.sh b/scripts/determine-release-version.sh new file mode 100755 index 0000000..8341a20 --- /dev/null +++ b/scripts/determine-release-version.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# +# Resolve the version this push should release (t42). +# +# Why a script: this decision is what makes a release visible to HACS, and every +# wrong answer is silent — the tag and the release still exist, only the manifest +# never moves. So the logic lives here, where tests/test_release_versioning.py +# drives every branch locally, instead of inline in a workflow nobody can run. +# +# Resolution (M = manifest version, D = today in UTC, tags = existing v-tags): +# 1. M is tagged AND that tag is the commit being released +# -> RELEASE=no (SOURCE=released): a re-run, this commit is already out +# 2. M is not tagged -> VERSION=M (SOURCE=manifest) +# an unreleased manifest version wins: bump it to force a release +# 3. D is not tagged -> VERSION=D (SOURCE=date) +# the one automatic release per UTC day +# 4. otherwise -> VERSION=D.N (SOURCE=same-day) +# N is the LOWEST untagged N >= 2, so further releases in the same UTC day +# become 2026.10.11.2, 2026.10.11.3, … instead of silently doing nothing +# +# The workflow writes the resolved VERSION back into the manifest whenever it +# differs, then tags THAT commit, so the released artifact and its tag always +# agree. The defect this fixes: v2026.10.09 shipped while master's manifest still +# said 2026.10.08, leaving HACS comparing against a version that never moved. +# +# Usage: scripts/determine-release-version.sh [--manifest PATH] [--today YYYY.MM.DD] +# [--tags-from FILE] [--head SHA] +# +# --manifest manifest to read (default: custom_components/nvidia_shield/manifest.json) +# --today override the UTC date — for tests and dry runs only +# --tags-from read "TAG [SHA]" lines instead of asking git — for tests and dry runs +# --head the commit being released (default: git rev-parse HEAD) +# +# Stdout, and $GITHUB_OUTPUT as well when it is set (which is what the workflow +# consumes): VERSION, TAG, SOURCE, BUMP, RELEASE. Commentary goes to stderr. +# RELEASE=no is the explicit no-op signal: nothing may be tagged or published. +# Exit status is 1 only for a usage or manifest error, so "no release" is not a +# failed job. +set -euo pipefail + +MANIFEST="custom_components/nvidia_shield/manifest.json" +TAGS_FROM="" +HEAD_SHA="" +TODAY="" + +usage() { + sed -n '/^# Usage:/,/^# Exit status/p' "$0" | sed 's/^# \{0,1\}//' +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --manifest) MANIFEST="${2:?--manifest needs a path}"; shift 2 ;; + --today) TODAY="${2:?--today needs YYYY.MM.DD}"; shift 2 ;; + --tags-from) TAGS_FROM="${2:?--tags-from needs a path}"; shift 2 ;; + --head) HEAD_SHA="${2:?--head needs a sha}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 1 ;; + esac +done + +note() { printf '%s\n' "$*" >&2; } + +[[ -f "$MANIFEST" ]] || { note "manifest not found: $MANIFEST"; exit 1; } +command -v jq >/dev/null 2>&1 || { note "jq is required to read the manifest"; exit 1; } + +MANIFEST_VERSION="$(jq -r '.version // empty' "$MANIFEST")" +[[ -n "$MANIFEST_VERSION" ]] || { note "no .version in $MANIFEST"; exit 1; } +# Tolerate a hand-written leading "v" (the tag adds its own); anything else that +# is not YYYY.MM.DD[.N] is a mistake worth failing for. +MANIFEST_VERSION="${MANIFEST_VERSION#v}" +[[ "$MANIFEST_VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] || { + note "manifest version '$MANIFEST_VERSION' is not YYYY.MM.DD[.N]"; exit 1; } + +if [[ -n "$TODAY" ]]; then + [[ "$TODAY" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]] || { note "--today must be YYYY.MM.DD"; exit 1; } + DATE_VERSION="$TODAY" +else + # UTC, always: a runner's local timezone must never decide a version number. + DATE_VERSION="$(date -u +%Y.%m.%d)" +fi + +if [[ -n "$TAGS_FROM" ]]; then + [[ -f "$TAGS_FROM" ]] || { note "tag list not found: $TAGS_FROM"; exit 1; } +else + command -v git >/dev/null 2>&1 || { note "git is required to list tags"; exit 1; } +fi + +tag_exists() { + if [[ -n "$TAGS_FROM" ]]; then + awk -v wanted="$1" '$1 == wanted { found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM" + else + git rev-parse -q --verify "refs/tags/$1" >/dev/null + fi +} + +tag_sha() { + if [[ -n "$TAGS_FROM" ]]; then + awk -v wanted="$1" '$1 == wanted && NF > 1 { print $2; found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM" + else + git rev-parse -q --verify "refs/tags/$1^{commit}" + fi +} + +if [[ -z "$HEAD_SHA" ]] && [[ -z "$TAGS_FROM" ]]; then + HEAD_SHA="$(git rev-parse -q --verify HEAD 2>/dev/null || true)" +fi + +VERSION="" +SOURCE="" +RELEASE="yes" + +if tag_exists "v${MANIFEST_VERSION}" && [[ -n "$HEAD_SHA" ]] \ + && [[ "$(tag_sha "v${MANIFEST_VERSION}" 2>/dev/null || true)" == "$HEAD_SHA" ]]; then + VERSION="$MANIFEST_VERSION"; SOURCE="released"; RELEASE="no" + note "v${VERSION} is already tagged on this commit — nothing to release" +elif ! tag_exists "v${MANIFEST_VERSION}"; then + VERSION="$MANIFEST_VERSION"; SOURCE="manifest" +elif ! tag_exists "v${DATE_VERSION}"; then + VERSION="$DATE_VERSION"; SOURCE="date" +else + N=2 + while tag_exists "v${DATE_VERSION}.${N}"; do N=$((N + 1)); done + VERSION="${DATE_VERSION}.${N}"; SOURCE="same-day" +fi + +if [[ "$RELEASE" == "yes" ]] && tag_exists "v${VERSION}"; then + # Backstop, not a normal path: the resolution above only ever picks an + # untagged version, so reaching this means the tag set changed underneath us + # (a race with a parallel run — the concurrency group is the primary guard). + # Never tag or publish something that already exists. + RELEASE="no"; SOURCE="tagged" + note "v${VERSION} already exists — nothing to release" +fi + +BUMP="no" +if [[ "$VERSION" != "$MANIFEST_VERSION" ]]; then + BUMP="yes" +fi + +if [[ "$RELEASE" == "yes" ]]; then + note "releasing v${VERSION} (${SOURCE}); manifest says ${MANIFEST_VERSION}, bump=${BUMP}" +else + note "no release (${SOURCE})" +fi + +emit() { + printf '%s=%s\n' "$1" "$2" + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT" + fi +} + +emit VERSION "$VERSION" +emit TAG "v${VERSION}" +emit SOURCE "$SOURCE" +emit BUMP "$BUMP" +emit RELEASE "$RELEASE" diff --git a/tests/test_release_versioning.py b/tests/test_release_versioning.py new file mode 100644 index 0000000..9b172a1 --- /dev/null +++ b/tests/test_release_versioning.py @@ -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)