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:
1 parent
aea9f9f9d2
commit
7608518d97
4 files changed
+611
-34
No files matched your search
@@ -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."
|
||||
],
|
||||
|
||||
Executable
+157
@@ -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"
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user