Files
houseplan-card/.github/workflows/publish-prerelease.yml
T
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

336 lines
16 KiB
YAML

name: Публикация пре-релиза (ручная)
run-name: Publish ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: "Exact prerelease tag, for example v1.61.0-beta.4"
required: true
type: string
permissions:
contents: write
actions: read
issues: read
concurrency:
group: publish-prerelease-${{ inputs.tag }}
cancel-in-progress: false
jobs:
gate:
name: "Гейт: зелёная Проверка и релизный контракт"
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- name: Pin the dev candidate or the existing annotated tag
id: candidate
env:
TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
test "$REF_NAME" = "dev" || {
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
exit 1
}
DISPATCHED_SHA=$(git rev-parse HEAD)
git fetch --force origin dev --tags
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
if [ -n "$REMOTE" ]; then
SHA=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
test -n "$SHA" || {
echo "::error::Existing remote tag $TAG is not annotated"
exit 1
}
else
SHA=$DISPATCHED_SHA
test "$(git rev-parse origin/dev)" = "$SHA" || {
echo "::error::The dispatched SHA is no longer the origin/dev tip"
exit 1
}
fi
git checkout --detach "$SHA"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Verify version, changelogs and bilingual release notes
env:
TAG: ${{ inputs.tag }}
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`.
# Зелёный Validate без трейлера означал бы прогон без смоков и golden —
# класс тихого пропуска #171/#207, поэтому трейлер проверяется здесь явно.
- name: Require the Release trailer on the candidate commit
env:
SHA: ${{ steps.candidate.outputs.sha }}
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::Candidate $SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
# #479: свежесть скриншотов на обычном пуше — предупреждение; на
# кандидате она обязана быть доказана строгим режимом.
- name: Documentation screenshots are fresh for the candidate
run: node scripts/check-docs.mjs --screenshots=strict
- name: Require green Validate for this exact SHA
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ steps.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
- name: Bind issue membership to the exact candidate
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.candidate.outputs.tag }}
SHA: ${{ steps.candidate.outputs.sha }}
run: |
set -euo pipefail
mkdir -p release-membership
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--dir release-membership --pattern RELEASE-MEMBERSHIP.json --clobber || true
fi
if [ -s release-membership/RELEASE-MEMBERSHIP.json ]; then
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
--input=release-membership/RELEASE-MEMBERSHIP.json
else
ISSUES=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--label S8-merged --limit 1000 --json number --jq 'map(.number)|join(",")')
node scripts/release-membership.mjs create --tag="$TAG" --candidate="$SHA" \
--issues="$ISSUES" --allow-unmatched \
--output=release-membership/RELEASE-MEMBERSHIP.json
fi
- name: Preserve candidate membership for publication
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-membership
path: release-membership/RELEASE-MEMBERSHIP.json
if-no-files-found: error
retention-days: 7
publish:
name: Публикация тега и релиза
needs: gate
runs-on: ubuntu-latest
outputs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: release-membership
path: release-assets
- name: Build and verify both release assets before publication
env:
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
npm ci
npm run build
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
npm run bundle:budget
VERSION=${TAG#v}
grep -RFq "$VERSION" dist
# #540: тот же способ, что у release.yml и release-prerelease.mjs —
# архив закоммиченного дерева точного коммита, детерминированный.
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"
test -s dist/houseplan-card.js
test -s dist/houseplan-panel.js
test -s houseplan.zip
mkdir -p release-assets
cp dist/houseplan-card.js houseplan.zip release-assets/
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
--input=release-assets/RELEASE-MEMBERSHIP.json
node scripts/release-assets.mjs sums release-assets --include-membership
- name: Create or verify the annotated tag
env:
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.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" || {
echo "::error::Existing remote tag $TAG is not annotated"
exit 1
}
test "$PEELED" = "$SHA" || {
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
exit 1
}
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
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, verify and publish the prerelease
id: release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
fi
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
if [ "$WAS_DRAFT" = "false" ]; then
mkdir -p existing-public
if gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir existing-public \
--pattern houseplan-card.js --pattern houseplan.zip \
--pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber \
&& diff -u release-assets/SHA256SUMS existing-public/SHA256SUMS \
&& node scripts/release-assets.mjs check existing-public release-assets/SHA256SUMS \
&& node scripts/release-membership.mjs verify --tag="$TAG" --candidate="${{ needs.gate.outputs.sha }}" \
--input=existing-public/RELEASE-MEMBERSHIP.json; then
echo "release is already public and byte-identical; publication skipped"
exit 0
fi
echo "existing public assets need recovery; verified files will be uploaded again"
fi
gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \
release-assets/RELEASE-MEMBERSHIP.json release-assets/SHA256SUMS \
--repo "$GITHUB_REPOSITORY" --clobber
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
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', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
- name: Verify the public release and assets
id: verify
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
throw new Error('release is not a public prerelease for the requested tag');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
# #540: публичные байты — ровно те, что собраны и проверены выше.
mkdir -p public
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \
--pattern houseplan-card.js --pattern houseplan.zip \
--pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber
diff -u release-assets/SHA256SUMS public/SHA256SUMS
node scripts/release-assets.mjs check public release-assets/SHA256SUMS
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
--input=public/RELEASE-MEMBERSHIP.json
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
echo "url=$URL" >> "$GITHUB_OUTPUT"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n\n```\n%s```\n' \
"$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
EXPECTED_TAG: ${{ needs.gate.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((release) => release.prerelease && !release.draft);
if (first?.tag_name !== process.env.EXPECTED_TAG) {
core.setFailed(
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
process.env.EXPECTED_TAG,
);
}
# #547: bookkeeping is driven by the immutable candidate manifest, not by the
# mutable S8 queue. It also runs on a verified retry of an already-public beta.
close-merged:
name: Закрытие вошедших issue
needs: [gate, publish]
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
# not start workflows, so removing the label cannot wake the review
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: release-membership
path: release-membership
- name: Finish only the issues proven in the candidate manifest
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ needs.gate.outputs.tag }}
URL: ${{ needs.publish.outputs.url }}
run: |
set -euo pipefail
node scripts/release-bookkeeping.mjs --repo="$REPO" --tag="$TAG" \
--candidate="${{ needs.gate.outputs.sha }}" --url="$URL" \
--membership=release-membership/RELEASE-MEMBERSHIP.json
announce:
name: Комментарий о публикации
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ needs.gate.outputs.tag }}
release_name: ${{ needs.gate.outputs.tag }}
url: ${{ needs.publish.outputs.url }}
prerelease: true
ref: ${{ needs.gate.outputs.tag }}
secrets: inherit