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

+96 -33
View File
@@ -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 }}
@@ -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."
],
+157
View File
@@ -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"
+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)