Files
houseplan-card/.github/workflows/release.yml
T
Claude bdac4b4fdc ci: закрепить образ раннера, таймауты job и некруглые cron (#658)
`ubuntu-latest` с 19.10.2026 переезжает на Ubuntu 26, а golden, скриншоты
документации и перф-бюджеты сняты на текущем образе: все 43 job на раннере
теперь явно на `ubuntu-24.04`, один образ на все workflow. 26 job получили
`timeout-minutes` по наблюдённой длительности с запасом; гейт релиза — 180,
больше суммы собственных ожиданий (60 + 60 + 45). Расписания ушли с круглых
минут (ночь 02:17, мутанты 00:43, метрики 05:23, полный перф 04:11), ночь
пишет в summary сдвиг старта и предупреждает, если он больше часа.

test/workflow-hygiene.test.mjs держит все три правила по тексту workflow
(разбор `parseJobSettings` в scripts/workflow-jobs.mjs) и исполняет шаг
сдвига старта настоящим bash; порядок осознанного подъёма образа —
docs/DEVELOPMENT.md.

Issue: #658
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 13:57:21 +03:00

505 lines
26 KiB
YAML
Executable File

name: "Релиз: проверка, сборка и публикация ассетов"
run-name: "Release ${{ inputs.tag || github.event.release.tag_name }}"
# #540: единственный путь, по которому установочные ассеты стабильного релиза
# (`houseplan.zip` для HACS и `houseplan-card.js` для ручной установки) попадают
# наружу. До этого публикаторов было четыре, и `release-zip.yml` выкладывал ZIP
# в ту же секунду, когда релиз становился публичным, — до Validate, Full
# Performance и E2E. Порядок теперь один: закрепить SHA → релиз в черновике →
# гейты на этом SHA → одна сборка и `SHA256SUMS` → загрузка в черновик →
# публикация → сверка публичных байтов с паспортом → анонс.
#
# Два входа, один порядок:
# • `workflow_dispatch(tag)` — штатный выпуск и ремонт. Тега ещё нет — он
# ставится на вершину ветки, с которой запущен workflow (main для
# стабильного, dev для беты). Тег есть — берётся его коммит.
# • `release: published` — человек опубликовал стабильный релиз руками.
# Fail-closed: релиз немедленно возвращается в черновик и проходит тот же
# путь; снаружи ничего установочного не остаётся, пока идут проверки.
# Беты это событие пропускают — у них свой staged-путь
# (`publish-prerelease.yml`, `release-prerelease.mjs`).
#
# Ремонт публичного релиза (dispatch на существующий тег): недостающие ассеты
# догружаются только при зелёных гейтах; присутствующий ассет с другим хешем —
# отказ без правок, публичные байты не подменяются молча.
#
# Событие `release` исполняет workflow с коммита тега: новая редакция файла
# действует для стабильных тегов только после того, как она есть на main.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Exact release tag, for example v1.75.1; created on the dispatched branch tip when missing"
required: true
type: string
permissions:
contents: write
actions: read
concurrency:
group: release-${{ inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false
jobs:
candidate:
name: "Кандидат: точный SHA, режим и черновик"
# Публикация беты руками — не наш случай: у бет свой staged-путь.
if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.prerelease }}
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
sha: ${{ steps.resolve.outputs.sha }}
tag: ${{ steps.resolve.outputs.tag }}
version: ${{ steps.resolve.outputs.version }}
prerelease: ${{ steps.resolve.outputs.prerelease }}
mode: ${{ steps.release.outputs.mode }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Resolve the tag to its exact commit
id: resolve
env:
EVENT: ${{ github.event_name }}
TAG: ${{ inputs.tag || github.event.release.tag_name }}
run: |
set -euo pipefail
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || {
echo "::error::$TAG is not a release tag (vX.Y.Z or vX.Y.Z-pre)"
exit 1
}
VERSION=${TAG#v}
case "$TAG" in *-*) PRERELEASE=true ;; *) PRERELEASE=false ;; esac
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null; then
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
# Peeled commit both for annotated and lightweight tags (a release
# form makes lightweight ones). Neither target_commitish nor the
# event SHA is trusted: the former may be a branch name.
SHA=$(git rev-list -n 1 "refs/tags/$TAG")
echo "tag $TAG exists → $SHA"
else
test "$EVENT" = "workflow_dispatch" || {
echo "::error::release event for a tag that does not exist: $TAG"
exit 1
}
SHA=$(git rev-parse HEAD)
echo "tag $TAG is new → dispatched branch tip $SHA"
fi
if [ "$PRERELEASE" = "false" ]; then
git fetch origin main
git merge-base --is-ancestor "$SHA" origin/main || {
echo "::error::stable candidate $SHA is not on main"
exit 1
}
else
git fetch origin dev
git merge-base --is-ancestor "$SHA" origin/dev || {
echo "::error::prerelease candidate $SHA is not on dev"
exit 1
}
fi
{
echo "sha=$SHA"
echo "tag=$TAG"
echo "version=$VERSION"
echo "prerelease=$PRERELEASE"
} >> "$GITHUB_OUTPUT"
- name: Take the release off the public surface until it is verified
id: release
env:
GH_TOKEN: ${{ github.token }}
EVENT: ${{ github.event_name }}
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft > /tmp/is-draft 2>/dev/null; then
echo "mode=fresh" >> "$GITHUB_OUTPUT"
echo "no release for $TAG yet: it will be created as a draft after the gates"
elif [ "$(cat /tmp/is-draft)" = "true" ]; then
echo "mode=staged" >> "$GITHUB_OUTPUT"
echo "release $TAG is a draft: staging into it"
elif [ "$EVENT" = "release" ]; then
# Published by hand: nothing here has been verified. Back to draft
# first, gates second — the order is the whole point (#540).
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft
echo "mode=event" >> "$GITHUB_OUTPUT"
echo "::notice::$TAG was published by hand and is a draft again until the gates pass"
else
echo "mode=repair" >> "$GITHUB_OUTPUT"
echo "release $TAG is public: repair mode — only missing assets may be added"
fi
independent-review:
name: "Независимое ревью линии (не блокирует выпуск)"
# #638, PROCESS.md §11.5. Решение владельца 2026-09-25: ревью идёт
# параллельно гейтам и выпуск не ждёт и не останавливает. Поэтому этот job
# только ставит в очередь `release-review.yml` на `dev` и ни один job
# выпуска от него не зависит (`needs` на него запрещён тестом
# release-workflow); его отказ — предупреждение, а не красный релиз.
# Беты пропускаются: ревью линии — перед стабильным.
needs: candidate
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
actions: write
steps:
- name: Поставить в очередь ревью линии
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
SHA: ${{ needs.candidate.outputs.sha }}
run: |
if gh workflow run release-review.yml --repo "${{ github.repository }}" --ref dev \
-f tag="$TAG" -f candidate="$SHA"; then
echo "Независимое ревью $TAG поставлено в очередь: release-review.yml на dev" >> "$GITHUB_STEP_SUMMARY"
else
echo "::warning::ревью линии $TAG не запущено — выпуск продолжается; запустить руками: gh workflow run release-review.yml --ref dev -f tag=$TAG -f candidate=$SHA"
exit 1
fi
gate:
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
needs: candidate
runs-on: ubuntu-24.04
# Больше суммы собственных ожиданий job: Validate и Full Performance ждутся
# до 60 мин каждый (release-gate.mjs), E2E — до 45 (e2e-gate.mjs): 165 < 180 (#658).
timeout-minutes: 180
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`;
# без него зелёный Validate — прогон без смоков и golden. Трейлер обязан
# называть ровно этот тег: кандидат сам объявляет, чем он выпускается.
- name: Require the Release trailer naming this exact tag
env:
SHA: ${{ needs.candidate.outputs.sha }}
TAG: ${{ needs.candidate.outputs.tag }}
run: |
set -euo pipefail
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
if ! grep -Fxq "Release: $TAG" /tmp/head-message.txt; then
echo "::error::$SHA declares $(grep -E '^Release:' /tmp/head-message.txt | head -1), not $TAG"
exit 1
fi
- name: Verify version, changelogs and bilingual release notes
env:
TAG: ${{ needs.candidate.outputs.tag }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$PRERELEASE" = "true" ]; then
node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
else
node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY" --stable
fi
- name: Require a green Validate for this exact commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ needs.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ needs.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
# #514/#540: единственная проверка на настоящем Home Assistant. Раньше
# houseplan-e2e ставил `houseplan.zip` из публичного релиза — то есть
# релиз должен был быть публичным ДО проверки. Теперь он ставит дерево
# `custom_components/houseplan` коммита-кандидата (tarball codeload),
# а ZIP строится `git archive` из того же дерева: тождество «что
# тестировали = что публикуем» — хеш дерева, он печатается на сборке.
# Cross-repository dispatch needs a token with Actions: write on
# houseplan-e2e; HP_PROCESS_TOKEN (classic, repo scope) has it,
# E2E_DISPATCH_TOKEN is the fallback for a fine-grained token.
- name: Require green E2E on a real Home Assistant for a stable release
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
env:
GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}
SHA: ${{ needs.candidate.outputs.sha }}
TAG: ${{ needs.candidate.outputs.tag }}
run: node scripts/e2e-gate.mjs --ref="$SHA" --tag="$TAG"
stage:
name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"
needs: [candidate, gate]
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- name: Build once and verify both installable assets
id: build
env:
SHA: ${{ needs.candidate.outputs.sha }}
VERSION: ${{ needs.candidate.outputs.version }}
run: |
set -euo pipefail
npm ci
npm run build
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
npm run bundle:budget
grep -RFq "$VERSION" dist
test -s dist/houseplan-card.js
test -s dist/houseplan-panel.js
# The ZIP is the committed integration tree of the exact commit —
# the same tree E2E installed from the codeload tarball. `git archive`
# is deterministic for a commit, so a repair rebuilds identical bytes.
git -c core.autocrlf=false archive --format=zip --output=houseplan.zip \
"$SHA:custom_components/houseplan"
node scripts/verify-houseplan-zip.mjs houseplan.zip \
custom_components/houseplan/frontend "$VERSION"
mkdir -p release-assets
cp dist/houseplan-card.js houseplan.zip release-assets/
node scripts/release-assets.mjs sums release-assets
TREE=$(git rev-parse "$SHA:custom_components/houseplan")
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
printf '### Staged assets for %s\n\n- exact commit: `%s`\n- `custom_components/houseplan` tree (what E2E installed): `%s`\n\n```\n%s```\n' \
"$VERSION" "$SHA" "$TREE" "$(cat release-assets/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
- name: Verify compositor frame continuity for a stable release
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
run: |
npx playwright install --with-deps chromium
node scripts/bundle-sync.mjs
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && needs.candidate.outputs.prerelease != 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- name: Keep the passport for the publication step
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-assets-${{ needs.candidate.outputs.tag }}
path: release-assets/SHA256SUMS
if-no-files-found: error
# Also for a draft made in the release form: its tag may not exist yet,
# and publishing such a draft would let GitHub tag target_commitish —
# not necessarily the verified commit. The tag is pinned here, first.
- name: Create or verify the tag at the exact commit
env:
TAG: ${{ needs.candidate.outputs.tag }}
SHA: ${{ needs.candidate.outputs.sha }}
run: |
set -euo pipefail
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
if [ -n "$REMOTE" ]; then
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
test -n "$PEELED" || PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG" '$2 == ref {print $1}')
test "$PEELED" = "$SHA" || {
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
exit 1
}
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" "$SHA" -m "$TAG"
git push origin "$TAG"
fi
- name: Stage the verified assets into the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
MODE: ${{ needs.candidate.outputs.mode }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$MODE" = "fresh" ]; then
FLAG="--prerelease=false"
if [ "$PRERELEASE" = "true" ]; then FLAG="--prerelease"; fi
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --draft "$FLAG" \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
fi
if [ "$MODE" = "repair" ]; then
# Public release: what is already outside must be the bytes we just
# rebuilt; anything else is a finding, not a --clobber. Only missing
# assets are added, and only now — after the gates.
mkdir -p public
for name in $(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name'); do
case "$name" in
houseplan-card.js|houseplan.zip|SHA256SUMS)
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public --pattern "$name" --clobber ;;
esac
done
if [ -f public/SHA256SUMS ]; then
diff -u public/SHA256SUMS release-assets/SHA256SUMS || {
echo "::error::public SHA256SUMS of $TAG differ from the rebuilt assets"
exit 1
}
fi
node scripts/release-assets.mjs check public release-assets/SHA256SUMS --allow-missing
missing=""
for name in houseplan-card.js houseplan.zip SHA256SUMS; do
[ -f "public/$name" ] || missing="$missing release-assets/$name"
done
if [ -z "$missing" ]; then
echo "nothing to repair: every asset of $TAG is present and matches"
else
echo "adding missing assets:$missing"
# shellcheck disable=SC2086
gh release upload "$TAG" $missing --repo "$GITHUB_REPOSITORY"
fi
else
# Draft: whatever a hand-made publication put here was never
# verified, so the verified bytes replace it.
gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \
release-assets/SHA256SUMS --repo "$GITHUB_REPOSITORY" --clobber
fi
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json tagName,isDraft,assets)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip', 'SHA256SUMS']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
publish:
name: "Публикация и сверка публичных байтов"
needs: [candidate, gate, stage]
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
url: ${{ steps.verify.outputs.url }}
name: ${{ steps.verify.outputs.name }}
newly_published: ${{ steps.flip.outputs.newly_published }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: release-assets-${{ needs.candidate.outputs.tag }}
path: passport
- name: Publish the verified draft
id: flip
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
MODE: ${{ needs.candidate.outputs.mode }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$MODE" = "repair" ]; then
echo "newly_published=false" >> "$GITHUB_OUTPUT"
echo "repair of a public release: nothing to publish"
exit 0
fi
FLAG="--prerelease=false"
if [ "$PRERELEASE" = "true" ]; then FLAG="--prerelease"; fi
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false "$FLAG" \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
echo "newly_published=true" >> "$GITHUB_OUTPUT"
- name: Verify the public release against the passport
id: verify
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
SHA: ${{ needs.candidate.outputs.sha }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
run: |
set -euo pipefail
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,name,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG PRERELEASE
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG || release.isDraft) throw new Error('release is not public for the requested tag');
if (String(release.isPrerelease) !== process.env.PRERELEASE) throw new Error('release prerelease flag does not match the tag');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip', 'SHA256SUMS']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
# The bytes anyone downloads now are the bytes the gates saw.
mkdir -p public
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \
--pattern houseplan-card.js --pattern houseplan.zip --pattern SHA256SUMS --clobber
diff -u passport/SHA256SUMS public/SHA256SUMS
node scripts/release-assets.mjs check public passport/SHA256SUMS
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git rev-list -n 1 "refs/tags/$TAG")" = "$SHA"
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
NAME=$(node -p "JSON.parse(process.env.RELEASE_JSON).name || process.env.TAG")
{
echo "url=$URL"
echo "name=$NAME"
} >> "$GITHUB_OUTPUT"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub release](%s)\n\n```\n%s```\n' \
"$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
announce:
# #538: анонс — последнее звено, а не параллельное. Пока он висел на самом
# событии `release: published`, он обгонял гейт: 12.09 v1.75.0 объявили в
# канале в ту же минуту, когда проверка отказала выкладывать ассеты.
# `needs: publish` означает, что молчание — это тоже ответ: красный гейт или
# несостоявшаяся выкладка сообщения не рождают. Ремонт не анонсируется.
name: Оповещение о релизе после выкладки
needs: [candidate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ needs.candidate.outputs.tag }}
release_name: ${{ needs.publish.outputs.name }}
url: ${{ needs.publish.outputs.url }}
prerelease: ${{ needs.candidate.outputs.prerelease == 'true' }}
ref: ${{ needs.candidate.outputs.tag }}
secrets: inherit
hacs-discovery:
name: HACS-видимость пре-релиза (порядок бет)
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
# sorting SemVer. A valid asset can therefore be invisible to beta users
# (beta.10 appeared after beta.9). Keep the release asset, but
# make that distribution failure impossible to miss in the release run.
if: ${{ needs.candidate.outputs.prerelease == 'true' }}
needs: [candidate, publish]
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
EXPECTED_TAG: ${{ needs.candidate.outputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((r) => r.prerelease && !r.draft);
const expected = process.env.EXPECTED_TAG;
if (first?.tag_name !== expected) {
core.setFailed(
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
`Use an rc/new version line or correct the release ordering before announcing the update.`,
);
}