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.
222 lines
9.1 KiB
YAML
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}"
|