Files
houseplan-card/.github/workflows/release.yml
Codexandclaude[bot] b35551884f Сторонние Actions закреплены SHA, права выданы по job, граница проверяется фикстурами
Три вещи, которые аудит 12.09 назвал в §10.

**Перемещаемые ссылки.** `home-assistant/actions/hassfest@master` и
`hacs/action@main` — это произвольный будущий коммит чужой ветки, а ревьюера с
Read/Write/Bash запускал перемещаемый major `anthropics/claude-code-action@v1`.
Все 116 `uses:` в девяти воркфлоу закреплены полным SHA с комментарием-версией;
`scripts/action-pins.mjs` это проверяет, а предполётный вердикт Validate —
исполняет. Локальная переиспользуемая workflow пина не требует и исключена
явно.

**Права.** Один блок `permissions` на весь конвейер выдавал `issues: write` и
OIDC каждой стадии, включая единственную недоверенную — работу модели. Теперь
права выдаются по job: модели только чтение и OIDC для самой
`claude-code-action`, писать в issue умеют детерминированные стадии.

**Граница.** Разбор запечатанного результата переехал из inline-shell в
`scripts/review-result-gate.mjs` — не ради красоты, а потому что в YAML его
нельзя прогнать ни одним отрицательным случаем. Проверяются те же вещи, что и
раньше, и в том же объёме: точный набор файлов, контрольные суммы, схема
паспорта и совпадение КАЖДОГО из семнадцати полей с тем, что посчитала
детерминированная стадия. Сверху — пятнадцать враждебных фикстур: неполный
набор, лишний файл, подменённое содержимое, чужой run и попытка, устаревший
material_sha и tree, чужие задача, этап, раунд и ветка, вердикт вне словаря,
пустой документ, manifest не о тех файлах, неразбираемый JSON.

Настоящих секретов и привилегированных операций фикстуры не трогают.

Issue: #556
User-Visible: no
2026-09-13 16:24:10 +00:00

468 lines
24 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-latest
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
gate:
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
needs: candidate
runs-on: ubuntu-latest
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-latest
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-latest
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-latest
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.`,
);
}