Files
ha-nvidia-shield/scripts/determine-release-version.sh
T
James Mackay 7608518d97 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.
2026-10-11 06:32:13 +01:00

158 lines
6.0 KiB
Bash
Executable File

#!/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"