Files
James Mackay b03cfbae13 fix(release): notes must not open with the bot commit; guard matches the subject (t44 3b/3c)
t45's review of the release versioning found three lows; two touch behaviour here (the
third, README wording, is the next commit).

R1 — with a write-back the tag points at the bot's own commit, so the notes range
`PREV..TAG` published "- chore(release): vX [skip ci]" as the first line of every
release. The range now ends at `${TAG}^` when BUMP=yes (the last commit a developer
wrote) and at `${TAG}` when it is not (there the tag IS the pushed commit, and it
belongs in the notes). `git describe` for the previous tag is unchanged: it already
started from `${TAG}^`, so it keeps naming the previous release.

R3 — the job guard matched the `[skip ci]` marker ANYWHERE in the commit message, so a
developer commit whose body merely mentioned it silently skipped its own release. The
guard is now anchored to the release commit's subject prefix
(`!startsWith(message, 'chore(release):')`), which is a structural property, not a
substring a body can trip. The marker stays in the commit message: it is the
convention Gitea itself may honour to skip the run outright, and the README now says
so, together with the fact that the job guard is the layer that holds when it does not.

3c — the resolver's tests used tag fixtures, so `refs/tags/X^{commit}` was never
exercised, and that dereference is the entire reason an annotated tag is recognised
(for one, `git rev-parse refs/tags/X` is the TAG OBJECT, not the commit, so a
comparison that forgot to dereference would miss a released commit). The resolver now
also runs inside real temporary repositories built with real annotated and lightweight
tags: released-on-HEAD both ways, a tag on an older commit falling back to the date,
no tags at all, the same-day scan walking real tags, and gap reuse. The workflow's own
run: scripts are executed too — extracted from the YAML by hand rather than with a
parser the fast suite does not have — covering the bump/verify/tag path (commit
subject, tag == HEAD, the tag's manifest, the pushed tag), the inconsistent-manifest
refusal, the rejected-push failure, and the notes range: with BUMP=yes the fixed range
yields "- feat: the real change" while the range it replaced demonstrably contains
"- chore(release):". The job-level `if:` itself cannot be executed here (no Gitea
runner) and stays a contract pin; the script-side layer that makes a bypassed guard
harmless is executed instead.

Verified: 414 passed / 2 skipped, ruff clean, release.yml parses.
2026-10-11 06:48:48 +01:00

235 lines
9.9 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/t44 R3: this job pushes the write-back commit itself, and a run starting
# from that commit would release the same version twice. The guard is anchored to
# the release commit's SUBJECT (`chore(release): …`), not to the `[skip ci]`
# marker anywhere in the message: a developer commit whose body happens to
# mention [skip ci] must still release — with contains() it silently did not.
# The marker stays in the commit message because it is the convention Gitea
# itself may honour to skip the run outright (README documents that platform
# layer, and that the job guard is a no-op there rather than the trigger).
# workflow_dispatch has no head_commit, hence the first clause.
if: >-
github.event_name == 'workflow_dispatch' ||
!startsWith(github.event.head_commit.message || '', 'chore(release):')
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 }}
BUMP: ${{ steps.version.outputs.BUMP }}
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 — the
# commits a DEVELOPER wrote. With a write-back (BUMP=yes) the tag points
# at the bot's own commit, so the range has to stop at its parent or every
# release would open with "chore(release): vX [skip ci]". Without one the
# tag is the pushed commit itself and belongs in the notes (t44 R1).
if [ "$BUMP" = "yes" ]; then
NOTES_END="${TAG}^"
else
NOTES_END="${TAG}"
fi
PREV="$(git describe --tags --abbrev=0 "${NOTES_END}^" 2>/dev/null || true)"
if [ -n "$PREV" ]; then
RANGE="${PREV}..${NOTES_END}"
else
RANGE="${NOTES_END}"
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}"