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.
158 lines
6.0 KiB
Bash
Executable File
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"
|