Files
ha-nvidia-shield/.gitea/workflows/release.yml
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

222 lines
9.1 KiB
YAML

# 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, 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.
#
# No hand-made secrets are required: Gitea mints a token for every job and
# exposes it as secrets.GITEA_TOKEN.
name: Release
on:
push:
branches: [master]
# Manual runs, for the first release (no tags yet) or to re-publish after a
# partial failure.
workflow_dispatch:
# One release at a time. Two pushes in quick succession would otherwise race to
# tag the same commit; a later run dedupes safely instead of interleaving.
concurrency:
group: release-master
cancel-in-progress: false
permissions:
# 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:
# Tags drive versioning decisions, so the clone must carry them.
fetch-depth: 0
ref: master
token: ${{ secrets.GITEA_TOKEN }}
- name: Determine the release version
id: version
run: |
set -euo pipefail
# 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: 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"
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.RELEASE == 'yes'
run: |
set -euo pipefail
cd custom_components
zip -r "../${ZIP_NAME}" nvidia_shield
- name: Publish the release
if: steps.version.outputs.RELEASE == 'yes'
env:
VERSION: ${{ steps.version.outputs.VERSION }}
TAG: ${{ steps.version.outputs.TAG }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Notes: subject lines of the commits in the released range.
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
if [ -n "$PREV" ]; then
RANGE="${PREV}..${TAG}"
else
RANGE="${TAG}"
fi
{
echo "Release ${TAG}"
echo
git log --pretty=format:'- %s' "$RANGE"
} > /tmp/notes.md
# The body is built by an encoder rather than by string interpolation:
# release notes are multi-line markdown and quoting them into JSON by
# hand is a reliable way to publish a malformed release.
python3 - <<'PY' > /tmp/release.json
import json, os
with open('/tmp/notes.md', encoding='utf-8') as handle:
body = handle.read()
print(json.dumps({
'tag_name': 'v' + os.environ['VERSION'],
'name': 'v' + os.environ['VERSION'],
'body': body,
'draft': False,
'prerelease': False,
}))
PY
status="$(curl -sS -o /tmp/release-response.json -w '%{http_code}' \
-X POST "${SERVER}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/release.json)"
RELEASE_ID="$(jq -r '.id // empty' /tmp/release-response.json)"
# 409 is "a release for this tag already exists": resolve the existing
# release instead of failing, so the zip still gets attached.
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID="$(curl -sS "${SERVER}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
-H "Authorization: token ${TOKEN}" | jq -r '.id')"
else
if [ "$status" != "201" ] && [ "$status" != "200" ]; then
echo "release creation failed (HTTP ${status})" >&2
cat /tmp/release-response.json >&2
exit 1
fi
fi
case "$RELEASE_ID" in
''|*[!0-9]*)
echo "could not resolve a release id for ${TAG} (HTTP ${status})" >&2
cat /tmp/release-response.json >&2
exit 1
;;
esac
echo "release id: ${RELEASE_ID}"
curl -sS -o /tmp/asset-response.json \
-X POST "${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${ZIP_NAME}"
echo "published ${TAG} with ${ZIP_NAME}"