ci: единый staged→tested→published путь установочных ассетов (#540)

`release-zip.yml` выкладывал `houseplan.zip` в ту же секунду, когда релиз
становился публичным — до Validate, Full Performance и E2E; `release.yml`
параллельно пересобирал `houseplan-card.js`, а E2E требовал публичного ZIP,
чтобы вообще начаться. Публикаторов было четыре, порядок — ни одного.

Теперь публикатор стабильных один — `release.yml`: закрепить SHA → релиз в
черновике (опубликованный руками немедленно возвращается в черновик) → гейты
на SHA (трейлер `Release: <tag>`, контракт `--stable`, Validate, Full
Performance, E2E на коммите-кандидате через tarball codeload) → одна сборка,
`git archive` ZIP из того же дерева, `SHA256SUMS` → загрузка в черновик →
публикация → скачать публичное и сверить с паспортом → анонс. Dispatch на
публичный тег — ремонт: догружается только недостающее, расходящийся хеш —
отказ. Беты кладут тот же паспорт; локальный публикатор больше не ждёт
републикаторов — их нет.

- `.github/workflows/release-zip.yml` удалён
- `scripts/release-assets.mjs` — паспорт ассетов (`sums`/`check`), чистые
  функции под юнитами
- `scripts/e2e-gate.mjs --ref=<sha>` — под тестом кандидат, `--tag` только
  для выбора `upgrade_from`
- `scripts/release-contract.mjs --stable`
- мутанты: независимый публикатор, снятая зависимость от гейта, релиз без
  возврата в черновик, `--clobber` в ремонте, E2E на теге, слепой паспорт

Issue: #540
User-Visible: no
This commit is contained in:
Claude
2026-09-13 07:42:23 +03:00
parent c53f9ffc02
commit eb77224e0c
18 changed files with 948 additions and 278 deletions
+20 -7
View File
@@ -95,6 +95,7 @@ jobs:
- 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
@@ -103,12 +104,18 @@ jobs:
npm run bundle:budget
VERSION=${TAG#v}
grep -RFq "$VERSION" dist
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
# #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-assets.mjs sums release-assets
- name: Create or verify the annotated tag
env:
TAG: ${{ needs.gate.outputs.tag }}
@@ -147,8 +154,8 @@ jobs:
fi
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
--repo "$GITHUB_REPOSITORY" --clobber
gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \
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
@@ -156,7 +163,7 @@ jobs:
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']) {
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
@@ -178,15 +185,21 @@ jobs:
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']) {
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
# #540: публичные байты — ровно те, что собраны и проверены выше.
mkdir -p public
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \
--pattern houseplan-card.js --pattern houseplan.zip --pattern SHA256SUMS --clobber
diff -u release-assets/SHA256SUMS public/SHA256SUMS
node scripts/release-assets.mjs check public release-assets/SHA256SUMS
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- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
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@v9
env:
-41
View File
@@ -1,41 +0,0 @@
name: HACS-zip к релизу
# hacs.json declares zip_release + filename=houseplan.zip, so every release
# (prereleases included) must carry the asset — HACS installs from it and
# GitHub's public download counter becomes a free per-version install metric
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
# dev, stable tags once it reaches main.
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
# once for the latest stable after the hacs.json change reaches main).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach the zip to"
required: true
permissions:
contents: write
jobs:
zip:
name: Собрать houseplan.zip и приложить к релизу
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: node scripts/verify-houseplan-zip.mjs houseplan.zip custom_components/houseplan/frontend
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
+391 -66
View File
@@ -1,130 +1,455 @@
name: "Релиз: ассеты после зелёной проверки"
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:
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
# workflow only built and uploaded, so an asset shipped while both Validate
# runs for the very same commit were red. The asset now waits for a green
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
#
# Needs a push with a token that has the `workflow` scope (the ordinary
# Personal Access Token used for `git push` refuses workflow file updates).
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@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: "Гейт: зелёная Проверка точного SHA тега"
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
needs: candidate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@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 }}
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# HEAD is the peeled commit even when TAG is annotated. Do not trust
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
# #479: тяжёлые job Validate идут только на коммите с трейлером
# `Release:`; без него зелёный Validate прогона без смоков не доказывает.
if ! git log -1 --format=%B "$SHA" | grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
node scripts/release-gate.mjs "$SHA"
SHA: ${{ needs.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ !github.event.release.prerelease }}
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
SHA=$(git rev-parse HEAD)
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
# #514: the only check on a real Home Assistant. houseplan-e2e installs
# the release's houseplan.zip — the bytes HACS ships — into HA in docker
# and walks the sidebar page, dashboards, roles, PDF, restart and the
# stable→tag upgrade. A red, missing or cancelled run withholds the
# assets exactly like Full Performance. 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.
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: ${{ !github.event.release.prerelease }}
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
env:
GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: node scripts/e2e-gate.mjs --tag="$TAG"
build:
name: Сборка бандла и загрузка ассетов
needs: gate
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@v7
with:
ref: ${{ github.event.release.tag_name }}
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
with: { node-version: 22 }
- run: npm ci && npm run build
- 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: ${{ !github.event.release.prerelease }}
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() && !github.event.release.prerelease }}
if: ${{ failure() && needs.candidate.outputs.prerelease != 'true' }}
uses: actions/upload-artifact@v7
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- name: Verify the complete committed frontend tree
run: |
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
test -s dist/houseplan-card.js
test -s dist/houseplan-panel.js
- name: Attach card to release
uses: softprops/action-gh-release@v3
- name: Keep the passport for the publication step
uses: actions/upload-artifact@v7
with:
files: dist/houseplan-card.js
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@v7
with:
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
with: { node-version: 22 }
- uses: actions/download-artifact@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: build` означает, что молчание — это тоже ответ: красный гейт или
# несостоявшаяся выкладка сообщения не рождают.
# `needs: publish` означает, что молчание — это тоже ответ: красный гейт или
# несостоявшаяся выкладка сообщения не рождают. Ремонт не анонсируется.
name: Оповещение о релизе после выкладки
needs: build
needs: [candidate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ github.event.release.tag_name }}
release_name: ${{ github.event.release.name }}
url: ${{ github.event.release.html_url }}
prerelease: ${{ github.event.release.prerelease }}
ref: ${{ github.event.release.tag_name }}
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: ${{ github.event.release.prerelease }}
needs: build
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@v9
env:
EXPECTED_TAG: ${{ needs.candidate.outputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
@@ -133,7 +458,7 @@ jobs:
per_page: 100,
});
const first = releases.find((r) => r.prerelease && !r.draft);
const expected = context.payload.release.tag_name;
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}. ` +
+5 -2
View File
@@ -470,8 +470,11 @@ is a service path-filter, not a gate. `docs` is a real blocker: it checks the
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
went red after the #113 merge. A stable release additionally waits for Full
Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`,
dispatched on the tag by `release.yml`, #514); betas and the development cycle
never run E2E.
dispatched on the candidate SHA by `release.yml`, #514/#540); betas and the
development cycle never run E2E. Installable assets (`houseplan.zip`,
`houseplan-card.js`, `SHA256SUMS`) reach the public release only from
`release.yml` after those gates; a release published by hand is turned back into
a draft first (#540).
**"Verified" without a named command and its result is not evidence.**
+4 -2
View File
@@ -704,8 +704,10 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant:
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на теге и ждёт его
зелёного (#514); статусов issue не касается.
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на SHA кандидата и ждёт
его зелёного (#514, #540); установочные ассеты публикуются только после всех
гейтов и только этим workflow — релиз, опубликованный руками, возвращается в
черновик до их прохождения (#540); статусов issue не касается.
---
+34 -18
View File
@@ -378,11 +378,12 @@ operation; it does not modify the developer's Git configuration.
It creates or verifies an
annotated exact-SHA tag, builds `houseplan.zip` directly from that committed
tree, verifies its manifest and embedded frontend against the candidate hash,
stages a draft prerelease and uploads both assets. Only then does it make the
release public. It locates the Release, HACS-zip and Telegram runs by workflow
file plus exact tag/SHA, verifies the downloaded public asset contents and
stages a draft prerelease and uploads both assets plus their `SHA256SUMS`
passport. Only then does it make the release public. It verifies the downloaded
public asset contents against the candidate and the passport, checks the
paginated HACS prerelease order, and finally closes only the explicitly supplied
issues and strips their status label. Re-running the same command
issues and strips their status label. Nothing else re-uploads assets after
publication (#540): the bytes it verified are the bytes that stay. Re-running the same command
after a partial failure is safe when local/remote tags still resolve to the same
SHA: stale public assets are replaced and verified rather than accepted or left
for manual deletion. ZIP inspection is implemented in Node and does not depend
@@ -401,21 +402,36 @@ the workflow file exists on the default branch; until the next promotion to
closing them is the release manager's call, and the `close-merged` job does it
from the beta itself (#120).
The older release-event workflows remain supported as a recovery/manual
fallback. They still gate assets on the exact tagged SHA, so adopting the new
path does not weaken releases created through the old path.
Tag `vX.Y.Z` + GitHub Release → `.github/workflows/release.yml` resolves that
tag to its exact commit, waits for the latest non-cancelled Validate run of the
**Stable releases** go through `.github/workflows/release.yml`, the only
publisher of installable assets (#540). Run it with `workflow_dispatch` on
`main` with the exact tag: when the tag does not exist yet it is created on the
`main` tip; when it exists, its commit is the candidate. The workflow resolves
the tag to its exact commit, requires the `Release: <tag>` trailer on it,
checks the release contract (`release-contract.mjs --stable`), waits for the
latest non-cancelled Validate run of the
SHA to complete successfully (#511: a cancelled run is not a verdict, a later
re-run or another-baseline comparison refreshes an older result), then builds
and attaches `houseplan-card.js`. A missing, failed or one-hour-timed-out latest
Validate withholds the asset; stable releases additionally need the same for
Full Performance and a green E2E run on a real Home Assistant: `release.yml`
dispatches `e2e.yml` in `Matysh/houseplan-e2e` with the tag (the suite installs
the release's `houseplan.zip` into HA in docker) and waits for it (#514). A red
E2E withholds the assets — open the linked run, the Playwright traces and
screenshots are in its artifacts; fix, then cut a new tag. Bump the version
re-run or another-baseline comparison refreshes an older result), requires Full
Performance and a green E2E run on a
real Home Assistant — `e2e-gate.mjs --ref=<sha>` dispatches `e2e.yml` in
`Matysh/houseplan-e2e` on the **candidate commit**, whose
`custom_components/houseplan` tree the suite installs from the codeload tarball
(#514, #540) — then builds once, archives `houseplan.zip` from that same tree
(`git archive <sha>:custom_components/houseplan`, deterministic), writes
`SHA256SUMS`, uploads everything into a draft, publishes, downloads the public
assets back and checks them against the passport, and only then announces. The
tree hash printed in the run summary is the identity between what E2E installed
and what HACS downloads.
Publishing a stable release by hand in the GitHub form still works, but
fail-closed: `release: published` starts the same workflow, which immediately
turns the release back into a draft and walks the same path; nothing installable
is public while the gates run. A red gate leaves the draft in place — open the
linked run, the Playwright traces and screenshots are in its artifacts; fix,
then cut a new tag. Re-dispatching the workflow on an already public tag is a
**repair**: the gates run again on the SHA, missing assets are added, and an
existing asset whose hash differs from the rebuilt one fails the run instead of
being replaced. Hand-published betas are ignored by this workflow — prereleases
have their own staged path above. Bump the version
everywhere in sync: `src/houseplan-card.ts` (CARD_VERSION), `package.json`,
`custom_components/houseplan/manifest.json`, `custom_components/houseplan/const.py`.
+3 -3
View File
@@ -17,14 +17,14 @@ change must pass through a published beta/RC before stable. Stable release
commits are promotion-only (versions, generated bundles and release/changelog
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
## Snapshot (2026-09-12)
## Snapshot (2026-09-13)
| Item | State |
|---|---|
| Version | **v1.75.0** everywhere (manifest, const.py, package.json, package-lock in both places, CARD_VERSION in the card and the editor runtime) |
| Current local cycle | **Stable v1.75.0** — `main` is fast-forwarded to the tested `dev` SHA and the GitHub Release is public. The line aggregates from the stable v1.74.0 and carries one user-visible fix: the House Plan sidebar page could serve a previous version of the card for hours, because the panel entry fetched it through `./houseplan-card.js` — a relative specifier, and relative resolution does not inherit the `?v=` a dashboard gets from its Lovelace resource, while the entry files carry no `Cache-Control` at all. The panel now imports the implementation by its content-hashed name, so either the matching card arrives or the panel says out loud that the page is stale (#535). Internal in the line: #536 — the version banner asks the host to repaint when it drops the notice on disconnect, instead of relying on an unrelated update. Shipped as v1.75.0-beta.1 the same day; the stable is promotion-only on top of it. Known contradiction found while publishing: `scripts/release-contract.mjs` requires the grouped "small fixes" bullet unconditionally, while `npm run release:notes -- <tag> --verify` rejects it when every user-visible issue of the range is already itemised — the two rules deadlock any stable with a single user-visible issue. The contract won here because it is the automated gate; the verifier was run and its objection recorded rather than silenced. The banner still offers only "reload the page", which cannot help when the frontend is newer than the backend (files updated, Home Assistant not restarted) — not yet an issue. |
| Current local cycle | **Stable v1.75.0** — `main` is fast-forwarded to the tested `dev` SHA and the GitHub Release is public. The line aggregates from the stable v1.74.0 and carries one user-visible fix: the House Plan sidebar page could serve a previous version of the card for hours, because the panel entry fetched it through `./houseplan-card.js` — a relative specifier, and relative resolution does not inherit the `?v=` a dashboard gets from its Lovelace resource, while the entry files carry no `Cache-Control` at all. The panel now imports the implementation by its content-hashed name, so either the matching card arrives or the panel says out loud that the page is stale (#535). Internal in the line: #536 — the version banner asks the host to repaint when it drops the notice on disconnect, instead of relying on an unrelated update. Shipped as v1.75.0-beta.1 the same day; the stable is promotion-only on top of it. Known contradiction found while publishing: `scripts/release-contract.mjs` requires the grouped "small fixes" bullet unconditionally, while `npm run release:notes -- <tag> --verify` rejects it when every user-visible issue of the range is already itemised — the two rules deadlock any stable with a single user-visible issue. The contract won here because it is the automated gate; the verifier was run and its objection recorded rather than silenced. The banner still offers only "reload the page", which cannot help when the frontend is newer than the backend (files updated, Home Assistant not restarted) — not yet an issue. **Known gap of the public v1.75.0:** it carries `houseplan.zip` only — `release-zip.yml` uploaded the ZIP the moment the release was published, while `release.yml` withheld `houseplan-card.js` because Full Performance was red on that SHA (#537). The next patch release delivers the card asset and is the first live run of the single publisher from #540; a repair of v1.75.0 itself is impossible by design — the gates on `2c6410bb` stay red. |
| Hidden Alpha Stage | #89 Stage 1 ships in v1.63.0-beta.1, #122 Stage 2 in v1.64.0 and #160 Stage 3 in v1.73.0-beta.1. The same hidden `iso` view uses the fixed 4° camera, raised/tethered device-room-lock overlays, deeper openings and bounded theme materials; #471 removes the overlay plates from paint while retaining their safety geometry. Since #448 the experiment is enabled only through the single indefinite browser-local `hp_alpha` gate; it is not expiring and has no per-stage key. Flat remains default; editors, `houseplan-space-card`, floor effects, stored coordinates and HA actions remain unchanged. Stage 3 stays internal and is absent from public changelog/user documentation. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the stable release is produced by `release.yml` (`workflow_dispatch` on `main` with the tag) — the only publisher of installable assets since #540: gates on the exact SHA (Validate, Full Performance, E2E on the candidate commit), one build, `houseplan.zip` archived from the committed tree, `SHA256SUMS`, draft → publish → read-back verification; a release published by hand in the GitHub form is turned back into a draft and walked through the same path, and a re-dispatch on a public tag is a repair that adds only missing assets. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
| HACS | **In the default catalog since 2026-08-25** (hacs/default#9004 merged). Install = plain HACS search. `houseplan.zip` is attached to stable tags automatically (verified on v1.72.0); forum/4pda announcement still pending |
+8 -5
View File
@@ -68,11 +68,14 @@ dispatch-прогон на точном SHA; зелёный push-прогон и
## E2E на реальном Home Assistant (#514)
Репозиторий `Matysh/houseplan-e2e`: настоящий HA в docker, House Plan из
`houseplan.zip` релиза, 13 сценариев Playwright (боковая панель, дашборды,
роли, телефон, PDF, рестарт HA, первый запуск, обновление). Ночью — по
расписанию на последней бете; для **стабильного** релиза `release.yml`
запускает его на теге и ждёт зелёного (`scripts/e2e-gate.mjs`): красный —
ассеты не публикуются. В цикле разработки и на бетах не участвует.
релиза или из дерева коммита, 13 сценариев Playwright (боковая панель,
дашборды, роли, телефон, PDF, рестарт HA, первый запуск, обновление). Ночью —
по расписанию на последней бете; для **стабильного** релиза `release.yml`
запускает его на **SHA кандидата** (`scripts/e2e-gate.mjs --ref=<sha>`, #540:
ставится `custom_components/houseplan` из tarball коммита — то же дерево, из
которого `git archive` строит `houseplan.zip`) и ждёт зелёного: красный —
релиз остаётся черновиком, ассеты не публикуются. В цикле разработки и на
бетах не участвует.
## Версия в кадрах и попиксельная приёмка (#512)
+37 -30
View File
@@ -4,11 +4,17 @@
*
* Стабильный релиз проходил Validate и Full Performance на точном SHA, но ни
* разу не запускался в настоящем HA. Репозиторий houseplan-e2e ставит House
* Plan из `houseplan.zip` релиза — те же байты, что скачивает HACS, — и гоняет
* 13 сценариев Playwright. Этот скрипт запускает его workflow на теге и ждёт
* зелёного; `release.yml` вызывает его для `!prerelease` после Full Performance.
* Plan и гоняет 13 сценариев Playwright. Этот скрипт запускает его workflow и
* ждёт зелёного; `release.yml` вызывает его для стабильных после Full Performance.
*
* node scripts/e2e-gate.mjs --tag=<vX.Y.Z> [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml]
* #540: под тестом — коммит-кандидат (`--ref=<sha>`), а не публичный релиз.
* install-houseplan.mjs для ветки/коммита ставит `custom_components/houseplan`
* из tarball codeload — то же дерево, из которого `git archive` строит
* `houseplan.zip`. Так релиз проверяется ДО того, как станет публичным; раньше
* гейт качал ZIP из релиза, то есть требовал публикации до проверки. `--tag`
* при этом остаётся: он исключает выпускаемый тег из выбора `upgrade_from`.
*
* node scripts/e2e-gate.mjs --tag=<vX.Y.Z> [--ref=<sha>] [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml]
*
* Печатает `result=green|red|missing|error`, `url=…`, `note=…` (и в
* $GITHUB_OUTPUT), код выхода 0 только при green. Логика — чистая функция
@@ -16,9 +22,8 @@
*/
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs';
import { isMainModule } from './spawn-portable.mjs';
export const POLL_MS = 20_000;
export const E2E_REPO = 'Matysh/houseplan-e2e';
@@ -42,15 +47,16 @@ export function previousStable(releases, tag) {
}
/**
* Прогон — наш, если сьют, ставящий сам тег, назван по нему: имя job в
* Прогон — наш, если сьют, ставящий сам кандидат, назван по нему: имя job в
* e2e.yml — `"${suite} · HP ${ref} · HA ${ha}"`, и у `journeys`/`first-run`
* `ref` — это `houseplan_ref`. Сьют `upgrade` носит `upgrade_from` — тег
* ПРЕДЫДУЩЕГО stable, поэтому «любая job с HP <tag>» приняла бы прогон нового
* релиза за прогон старого (живой прогон 09.09: v1.72.0 ← run для v1.73.0).
* `ref` — это `houseplan_ref` (тег или SHA, #540). Сьют `upgrade` носит
* `upgrade_from` — тег ПРЕДЫДУЩЕГО stable, поэтому «любая job с HP <ref>»
* приняла бы прогон нового релиза за прогон старого (живой прогон 09.09:
* v1.72.0 ← run для v1.73.0).
*/
export const TAG_SUITES = ['journeys', 'first-run'];
export function isOurRun(jobs, tag) {
const needles = TAG_SUITES.map((suite) => `${suite} · HP ${tag} · `);
export function isOurRun(jobs, ref) {
const needles = TAG_SUITES.map((suite) => `${suite} · HP ${ref} · `);
return (Array.isArray(jobs) ? jobs : []).some((job) => needles.some((needle) => String(job?.name || '').startsWith(needle)));
}
@@ -59,25 +65,26 @@ export function isOurRun(jobs, tag) {
* сначала планирует матрицу отдельной job, и первые секунды виден только
* «Матрица прогона». Живой прогон 09.09 записал такой run в чужие навсегда.
*/
export function classifyRun(jobs, tag) {
export function classifyRun(jobs, ref) {
const named = (Array.isArray(jobs) ? jobs : []).filter((job) => / · HP .+ · /.test(String(job?.name || '')));
if (!named.length) return 'unknown';
return isOurRun(named, tag) ? 'ours' : 'foreign';
return isOurRun(named, ref) ? 'ours' : 'foreign';
}
/**
* @param {object} p
* @param {string} p.tag тег релиза (houseplan_ref для e2e.yml)
* @param {object} p.ops { releases() → [{tagName,isDraft,isPrerelease}] новые первыми, dispatch(tag, upgradeFrom), listRuns() → [{databaseId,status,conclusion,url,createdAt}], jobs(runId) → [{name,conclusion}], sleep(ms), now() }
* @param {string} p.tag выпускаемый тег — исключается из выбора `upgrade_from`
* @param {string} [p.ref] что ставить под тест (`houseplan_ref` для e2e.yml): SHA кандидата (#540); по умолчанию сам тег
* @param {object} p.ops { releases() → [{tagName,isDraft,isPrerelease}] новые первыми, dispatch(ref, upgradeFrom), listRuns() → [{databaseId,status,conclusion,url,createdAt}], jobs(runId) → [{name,conclusion}], sleep(ms), now() }
* @returns {Promise<{result:'green'|'red'|'missing'|'error', url:string|null, note:string}>}
*/
export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
export async function e2eGate({ tag, ref = tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
const started = ops.now();
try {
// Список релизов читается ДО dispatch и обязан падать громко (ревью r3 M1):
// fine-grained токен «только houseplan-e2e» не видит houseplan-card, и
// тихий пустой список дал бы upgrade_from=stable — тег сам на себя.
await ops.dispatch(tag, previousStable(await ops.releases(), tag));
await ops.dispatch(ref, previousStable(await ops.releases(), tag));
} catch (error) {
const message = String(error?.message || error);
const forbidden = /403|Resource not accessible|not accessible by/i.test(message);
@@ -93,7 +100,7 @@ export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs
for (const candidate of runs) {
const createdAt = Date.parse(candidate.createdAt || '') || 0;
if (createdAt < started - CLOCK_SKEW_MS) continue;
const kind = classifyRun(await ops.jobs(candidate.databaseId), tag);
const kind = classifyRun(await ops.jobs(candidate.databaseId), ref);
if (kind === 'ours') { run = candidate; break; }
if (kind === 'foreign' || candidate.status === 'completed') foreign.add(candidate.databaseId);
}
@@ -101,16 +108,16 @@ export async function e2eGate({ tag, ops, appearMs = VALIDATE_APPEAR_MS, totalMs
if (run) {
tracked = run.databaseId;
if (run.status === 'completed') {
if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` };
if (run.conclusion === 'cancelled') return { result: 'red', url: run.url, note: `E2E на ${tag} отменён вручную — перезапустите гейт` };
return { result: 'red', url: run.url, note: `E2E на ${tag} завершился: ${run.conclusion}` };
if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${ref} зелёный` };
if (run.conclusion === 'cancelled') return { result: 'red', url: run.url, note: `E2E на ${ref} отменён вручную — перезапустите гейт` };
return { result: 'red', url: run.url, note: `E2E на ${ref} завершился: ${run.conclusion}` };
}
} else if (ops.now() - started > appearMs) {
return { result: 'missing', url: null, note: `dispatch e2e.yml на ${tag} не появился за ${Math.round(appearMs / 60000)} мин` };
return { result: 'missing', url: null, note: `dispatch e2e.yml на ${ref} не появился за ${Math.round(appearMs / 60000)} мин` };
}
await ops.sleep(pollMs);
}
return { result: 'red', url: tracked ? `run ${tracked}` : null, note: `E2E на ${tag} не завершился за ${Math.round(totalMs / 60000)} мин` };
return { result: 'red', url: tracked ? `run ${tracked}` : null, note: `E2E на ${ref} не завершился за ${Math.round(totalMs / 60000)} мин` };
}
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
@@ -124,9 +131,9 @@ export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, cardRepo = C
if (r.status !== 0) throw new Error(`gh release list ${cardRepo}: ${(r.stderr || r.stdout || '').trim()}`);
return r.stdout ? JSON.parse(r.stdout) : [];
},
dispatch: async (tag, upgradeFrom = 'stable') => {
dispatch: async (ref, upgradeFrom = 'stable') => {
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', 'main',
'-f', `houseplan_ref=${tag}`, '-f', `upgrade_from=${upgradeFrom}`, '-f', 'ha_version=stable']);
'-f', `houseplan_ref=${ref}`, '-f', `upgrade_from=${upgradeFrom}`, '-f', 'ha_version=stable']);
if (r.status !== 0) throw new Error(`gh workflow run: ${(r.stderr || r.stdout || '').trim()}`);
},
listRuns: async () => parse(exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'workflow_dispatch', '--json', fields, '--limit', '10'])),
@@ -139,15 +146,15 @@ export function realOps({ repo = E2E_REPO, workflow = E2E_WORKFLOW, cardRepo = C
};
}
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
if (isMainModule(import.meta.url)) { // #496: переносимо для Windows
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const tag = arg('tag');
if (!tag) {
console.error('usage: e2e-gate.mjs --tag=<vX.Y.Z> [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml] [--card-repo=Matysh/houseplan-card]');
console.error('usage: e2e-gate.mjs --tag=<vX.Y.Z> [--ref=<sha>] [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml] [--card-repo=Matysh/houseplan-card]');
process.exit(2);
}
const outcome = await e2eGate({ tag, ops: realOps({ repo: arg('repo') || E2E_REPO, workflow: arg('workflow') || E2E_WORKFLOW, cardRepo: arg('card-repo') || CARD_REPO }) });
const ref = arg('ref') || tag;
const outcome = await e2eGate({ tag, ref, ops: realOps({ repo: arg('repo') || E2E_REPO, workflow: arg('workflow') || E2E_WORKFLOW, cardRepo: arg('card-repo') || CARD_REPO }) });
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`];
for (const line of lines) console.log(line);
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
+66 -4
View File
@@ -7782,8 +7782,70 @@ const MUTANT_DEFINITIONS = [
+ 'ответ». Без неё анонс уходит при красном гейте, то есть ровно то, что случилось',
patches: [{
file: '.github/workflows/release.yml',
find: ' name: Оповещение о релизе после выкладки\n needs: build',
replace: ' name: Оповещение о релизе после выкладки\n if: always()',
find: ' name: Оповещение о релизе после выкладки\n needs: [candidate, publish]\n'
+ " if: ${{ needs.publish.outputs.newly_published == 'true' }}",
replace: ' name: Оповещение о релизе после выкладки\n needs: [candidate]\n if: always()',
}],
},
{
id: 'asset-upload-stops-needing-the-gate',
guard: 'node --test test/release-workflow.test.mjs',
because: '#540: единственный публикатор ассетов ценен ровно тем, что стоит ЗА гейтом. '
+ 'Снятая зависимость — это `release-zip.yml` под другим именем: ассеты уходят в '
+ 'ту же минуту, когда Validate, Full Performance и E2E ещё идут или уже красные',
patches: [{
file: '.github/workflows/release.yml',
find: ' stage:\n name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"\n needs: [candidate, gate]',
replace: ' stage:\n name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"\n needs: [candidate]',
}],
},
{
id: 'hand-published-release-stays-public-during-the-gates',
guard: 'node --test test/release-workflow.test.mjs',
because: '#540: fail-closed держится на одном шаге — релиз, опубликованный руками, '
+ 'немедленно возвращается в черновик. Без него всё время гейтов (час и больше) '
+ 'снаружи висит публичный релиз с непроверенными или отсутствующими ассетами — '
+ 'состояние v1.75.0 12.09',
patches: [{
file: '.github/workflows/release.yml',
find: ' gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft\n',
replace: ' true\n',
}],
},
{
id: 'repair-clobbers-the-public-asset',
guard: 'node --test test/release-workflow.test.mjs',
because: '#540: ремонт догружает только недостающее. `--clobber` на публичном релизе '
+ 'подменяет байты, которые кто-то уже скачал и установил, — молча и без следа; '
+ 'расхождение хеша обязано быть отказом, а не перезаписью',
patches: [{
file: '.github/workflows/release.yml',
find: ' gh release upload "$TAG" $missing --repo "$GITHUB_REPOSITORY"\n',
replace: ' gh release upload "$TAG" $missing --repo "$GITHUB_REPOSITORY" --clobber\n',
}],
},
{
id: 'e2e-gate-tests-the-tag-instead-of-the-candidate',
guard: 'node --test test/e2e-gate.test.mjs',
because: '#540: E2E на теге качает `houseplan.zip` из публичного релиза — то есть '
+ 'требует публикации ДО проверки, и цикл «релиз должен быть публичным, чтобы его '
+ 'проверить» возвращается. Под тестом обязан быть SHA кандидата',
patches: [{
file: 'scripts/e2e-gate.mjs',
find: ' await ops.dispatch(ref, previousStable(await ops.releases(), tag));',
replace: ' await ops.dispatch(tag, previousStable(await ops.releases(), tag));',
}],
},
{
id: 'passport-accepts-a-different-hash',
guard: 'node --test test/release-assets.test.mjs',
because: '#540: паспорт ассетов существует ради одного сравнения — публичные байты '
+ 'равны проверенным. Ослеплённое сравнение делает SHA256SUMS украшением: релиз с '
+ 'подменённым ZIP проходит сверку зелёным',
patches: [{
file: 'scripts/release-assets.mjs',
find: ' else if (actual[name] !== expected[name]) mismatched.push(name);',
replace: ' else if (false) mismatched.push(name);',
}],
},
{
@@ -8812,8 +8874,8 @@ const MUTANT_DEFINITIONS = [
+ 'a failed run as green ships the assets the run just rejected (#514 AC1)',
patches: [{
file: 'scripts/e2e-gate.mjs',
find: " if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` };",
replace: " return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` }; // mutant: completed means green",
find: " if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${ref} зелёный` };",
replace: " return { result: 'green', url: run.url, note: `E2E на ${ref} зелёный` }; // mutant: completed means green",
}],
},
{
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Хеши установочных ассетов релиза (#540).
*
* Релиз ставится из `houseplan.zip` (HACS) и `houseplan-card.js` (ручная
* установка). Раз проверив кандидата, публиковать надо ровно те байты, что
* проверены, — поэтому у ассетов есть паспорт `SHA256SUMS`, который считается
* на этапе сборки, кладётся в релиз рядом с ассетами и сверяется с тем, что
* реально скачивается после публикации или при ремонте существующего релиза.
*
* node scripts/release-assets.mjs sums <dir> [--out=<file>]
* посчитать sha256 установочных ассетов в <dir>, записать SHA256SUMS
* node scripts/release-assets.mjs check <dir> <SHA256SUMS> [--allow-missing]
* сверить файлы в <dir> с паспортом; расхождение — код выхода 1
*
* Логика — чистые функции, чтобы контракт проверялся юнитами без диска.
*/
import { createHash } from 'node:crypto';
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
/** Установочные ассеты — то, что скачивает HACS и человек. Ровно эти два. */
export const INSTALLABLE_ASSETS = ['houseplan-card.js', 'houseplan.zip'];
export const SUMS_FILE = 'SHA256SUMS';
export const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
/** Формат `sha256sum`: `<hex> <name>` по строке, детерминированный порядок. */
export function formatSums(entries) {
const names = Object.keys(entries).sort();
if (!names.length) throw new Error('SHA256SUMS: нет ни одного ассета');
return `${names.map((name) => `${entries[name]} ${name}`).join('\n')}\n`;
}
export function parseSums(text) {
const entries = {};
for (const raw of String(text).split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const match = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(line);
if (!match) throw new Error(`SHA256SUMS: непонятная строка «${raw}»`);
if (entries[match[2]]) throw new Error(`SHA256SUMS: ${match[2]} встречается дважды`);
entries[match[2]] = match[1];
}
if (!Object.keys(entries).length) throw new Error('SHA256SUMS: пустой паспорт');
return entries;
}
/**
* Сравнить паспорт с фактическими хешами. `actual` может не содержать файла —
* это «missing» (при ремонте такой ассет догружается), а вот присутствующий
* файл с другим хешем — «mismatched», и это всегда отказ: публичные байты не
* подменяются молча.
*/
export function compareSums(expected, actual) {
const missing = [];
const mismatched = [];
for (const name of Object.keys(expected).sort()) {
if (!(name in actual)) missing.push(name);
else if (actual[name] !== expected[name]) mismatched.push(name);
}
const extra = Object.keys(actual).filter((name) => !(name in expected)).sort();
return { ok: !missing.length && !mismatched.length, missing, mismatched, extra };
}
export function sumsOfDirectory(dir, names = INSTALLABLE_ASSETS) {
const entries = {};
for (const name of names) {
const path = resolve(dir, name);
if (!existsSync(path)) continue;
entries[name] = sha256Hex(readFileSync(path));
}
return entries;
}
if (isMainModule(import.meta.url)) { // #496: переносимо для Windows
try {
const args = process.argv.slice(2);
const flag = (name) => args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`));
const value = (name) => flag(name)?.split('=').slice(1).join('=') || '';
const positionals = args.filter((a) => !a.startsWith('--'));
const [command, dir, sumsPath] = positionals;
if (command === 'sums') {
if (!dir) throw new Error('usage: release-assets.mjs sums <dir> [--out=<file>]');
const entries = sumsOfDirectory(dir);
for (const name of INSTALLABLE_ASSETS) {
if (!entries[name]) throw new Error(`${name} отсутствует в ${dir} — паспорт не выписывается на неполный набор`);
}
const out = value('out') || resolve(dir, SUMS_FILE);
writeFileSync(out, formatSums(entries));
console.log(formatSums(entries).trimEnd());
console.log(`→ ${out}`);
} else if (command === 'check') {
if (!dir || !sumsPath) throw new Error('usage: release-assets.mjs check <dir> <SHA256SUMS> [--allow-missing]');
const expected = parseSums(readFileSync(sumsPath, 'utf8'));
const actual = sumsOfDirectory(dir, Object.keys(expected));
const result = compareSums(expected, actual);
for (const name of Object.keys(expected).sort()) {
const state = result.mismatched.includes(name) ? 'MISMATCH'
: result.missing.includes(name) ? 'missing' : 'ok';
console.log(`${state.padEnd(8)} ${name}`);
}
if (result.mismatched.length) {
throw new Error(`хеш расходится: ${result.mismatched.join(', ')} — байты не те, что проверены`);
}
if (result.missing.length && !flag('allow-missing')) {
throw new Error(`ассетов нет на месте: ${result.missing.join(', ')}`);
}
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `missing=${result.missing.join(' ')}\n`);
}
} else {
throw new Error('usage: release-assets.mjs sums|check …');
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+14 -5
View File
@@ -41,10 +41,15 @@ export function parseVersionSources({
};
}
export function validateVersionSources(tag, sources, { requirePrerelease = true } = {}) {
export function validateVersionSources(tag, sources, { requirePrerelease = true, requireStable = false } = {}) {
const parsed = versionFromTag(tag);
if (requirePrerelease && !parsed.prerelease)
throw new Error(`Prerelease publication requires a prerelease SemVer tag: ${tag}`);
// #540: стабильный путь (release.yml) — зеркальное требование: тег без
// пре-релизного суффикса. Режима «любой тег» нет: публикатор всегда знает,
// что выпускает.
if (requireStable && parsed.prerelease)
throw new Error(`Stable publication requires a stable SemVer tag, got prerelease ${tag}`);
const mismatches = Object.entries(sources)
.filter(([, value]) => value !== parsed.version)
.map(([name, value]) => `${name}=${JSON.stringify(value)}`);
@@ -133,10 +138,10 @@ export function readReleaseContract(root = process.cwd()) {
}
export function assertReleaseContract({
root = process.cwd(), tag, repo = 'Matysh/houseplan-card', requirePrerelease = true,
root = process.cwd(), tag, repo = 'Matysh/houseplan-card', requirePrerelease = true, requireStable = false,
} = {}) {
const contract = readReleaseContract(root);
const parsed = validateVersionSources(tag, contract.sources, { requirePrerelease });
const parsed = validateVersionSources(tag, contract.sources, { requirePrerelease, requireStable });
if (!changelogContainsVersion(contract.changelogRu, tag))
throw new Error(`docs/CHANGELOG.ru.md has no dated ${tag} section`);
if (!changelogContainsVersion(contract.changelogEn, tag))
@@ -152,14 +157,18 @@ if (invokedDirectly) {
const args = process.argv.slice(2);
const positionals = args.filter((arg) => !arg.startsWith('--'));
const repoArgs = args.filter((arg) => arg.startsWith('--repo='));
const unknown = args.filter((arg) => arg.startsWith('--') && !arg.startsWith('--repo='));
// #540: `--stable` — контракт стабильного релиза (release.yml). Тот же
// набор проверок; отличие одно — тег обязан быть БЕЗ пре-релизного суффикса,
// как без флага он обязан быть с ним. Третьего режима «любой тег» нет.
const stable = args.includes('--stable');
const unknown = args.filter((arg) => arg.startsWith('--') && !arg.startsWith('--repo=') && arg !== '--stable');
if (positionals.length !== 1) throw new Error('Exactly one release tag is required');
if (repoArgs.length > 1 || unknown.length)
throw new Error(`Unknown or duplicate release-contract arguments: ${[...repoArgs.slice(1), ...unknown].join(', ')}`);
const tag = positionals[0];
const repo = repoArgs[0]?.slice('--repo='.length)
|| process.env.GITHUB_REPOSITORY || 'Matysh/houseplan-card';
const result = assertReleaseContract({ tag, repo, requirePrerelease: true });
const result = assertReleaseContract({ tag, repo, requirePrerelease: !stable, requireStable: stable });
console.log(JSON.stringify({
ok: true, tag: result.tag, version: result.version,
prerelease: result.prerelease, sources: result.sources,
+21 -52
View File
@@ -14,8 +14,8 @@ import { stdin, stdout } from 'node:process';
import { assertReleaseContract } from './release-contract.mjs';
import { classifyValidateRuns } from './release-gate.mjs';
import { assertBundleManifest } from './bundle-tree.mjs';
import { SUMS_FILE, compareSums, formatSums, parseSums, sumsOfDirectory } from './release-assets.mjs';
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const SUBPROCESS_MAX_BUFFER = 64 * 1024 * 1024;
class ReleaseAssetContentError extends Error {}
@@ -80,19 +80,13 @@ export function verifyReleaseProjection(release, { tag }) {
if (release.isDraft) throw new Error(`GitHub release ${tag} is still a draft`);
if (!release.isPrerelease) throw new Error(`GitHub release ${tag} is not marked as a prerelease`);
const assets = new Map((release.assets || []).map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
for (const name of ['houseplan-card.js', 'houseplan.zip', SUMS_FILE]) {
const asset = assets.get(name);
if (!asset || !(Number(asset.size) > 0)) throw new Error(`Release asset ${name} is missing or empty`);
}
return release;
}
/** Telegram announcements are deliberately skipped for prereleases. */
export function prereleaseWorkflowSucceeded(label, conclusion) {
return conclusion === 'success'
|| (label === 'Announce release' && conclusion === 'skipped');
}
/**
* Read selected root entries from an ordinary ZIP archive without relying on
* platform-specific `tar`/`unzip` executables. GitHub runners, Git Bash, WSL
@@ -358,7 +352,7 @@ if (invokedDirectly) {
try {
run('gh', [
'release', 'download', tag, '--repo', repo, '--dir', download,
'--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip', '--clobber',
'--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip', '--pattern', SUMS_FILE, '--clobber',
]);
try {
const cardPath = resolve(download, 'houseplan-card.js');
@@ -366,6 +360,12 @@ if (invokedDirectly) {
if (cardHash !== bundleSnapshot.entrySha256)
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSnapshot.entrySha256}`);
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSnapshot);
// #540: паспорт обязан быть и обязан описывать ровно эти байты.
const passport = compareSums(
parseSums(readFileSync(resolve(download, SUMS_FILE), 'utf8')),
sumsOfDirectory(download),
);
if (!passport.ok) throw new Error(`Published ${SUMS_FILE} disagrees with the assets: ${JSON.stringify(passport)}`);
} catch (error) {
throw new ReleaseAssetContentError(
error instanceof Error ? error.message : String(error),
@@ -408,45 +408,9 @@ if (invokedDirectly) {
return runs;
};
const waitForRun = async (runId, label) => {
let last = '';
for (let attempt = 0; attempt < 360; attempt++) {
const row = ghJson([
'run', 'view', String(runId), '--repo', repo, '--json', 'status,conclusion,url',
]);
const state = `${row.status}/${row.conclusion || '-'}`;
if (state !== last) console.log(`${label}: ${state} ${row.url}`);
last = state;
if (row.status === 'completed') {
if (!prereleaseWorkflowSucceeded(label, row.conclusion))
throw new Error(`${label} concluded ${row.conclusion}: ${row.url}`);
return row;
}
await sleep(10_000);
}
throw new Error(`${label} did not complete within one hour`);
};
const waitForReleaseWorkflows = async (sha) => {
const expected = [
['release.yml', 'Release'],
['release-zip.yml', 'Attach HACS zip'],
['announce.yml', 'Announce release'],
];
for (const [workflow, label] of expected) {
let match = null;
for (let attempt = 0; attempt < 300 && !match; attempt++) {
const runs = ghJson([
'run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'release',
'--limit', '30', '--json', 'databaseId,headBranch,headSha,status,conclusion,url',
]);
match = runs.find((row) => row.headBranch === tag && row.headSha === sha) || null;
if (!match) await sleep(2_000);
}
if (!match) throw new Error(`${label} workflow did not start for ${tag} at ${sha}`);
await waitForRun(match.databaseId, label);
}
};
// #540: после публикации никто больше не ждёт релизные workflow на событии:
// независимых републикаторов нет, ассеты беты выкладывает только этот путь,
// и сверка выложенного с кандидатом (sha256) делается здесь же ниже.
const verifyHacsDiscovery = () => {
const pages = ghJson(['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`]);
@@ -611,23 +575,28 @@ if (invokedDirectly) {
release = releaseView();
}
// #540: паспорт ассетов — единый вид релиза с release.yml. Считается с
// тех самых файлов, что уходят наверх, и сверяется после публикации.
const sumsPath = resolve(artifactsDir, SUMS_FILE);
writeFileSync(sumsPath, formatSums({
'houseplan-card.js': sha256Path(bundlePath),
'houseplan.zip': sha256Path(zipPath),
}));
run('gh', [
'release', 'upload', tag, bundlePath, zipPath,
'release', 'upload', tag, bundlePath, zipPath, sumsPath,
'--repo', repo, '--clobber',
], { inherit: true });
const staged = releaseView();
const stagedAssets = new Map((staged?.assets || []).map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
for (const name of ['houseplan-card.js', 'houseplan.zip', SUMS_FILE]) {
if (!(Number(stagedAssets.get(name)?.size) > 0))
throw new Error(`Draft release asset ${name} is missing or empty`);
}
const wasDraft = staged.isDraft;
run('gh', [
'release', 'edit', tag, '--repo', repo, '--draft=false', '--prerelease',
'--title', tag, '--notes-file', 'docs/RELEASE-NOTES.md',
], { inherit: true });
if (wasDraft) await waitForReleaseWorkflows(sha);
const published = verifyReleaseProjection(releaseView(), { tag });
const finalTag = remoteTag();
+31
View File
@@ -151,3 +151,34 @@ test('#514: realOps dispatches e2e.yml on main with the tag and the previous sta
await ops.releases();
assert.deepEqual(calls[2].slice(0, 5), ['gh', 'release', 'list', '--repo', 'Matysh/houseplan-card']);
});
// #540: под тестом — коммит-кандидат, не публичный релиз. Гейт диспатчит e2e.yml
// на SHA (install-houseplan.mjs ставит дерево из tarball codeload), опознаёт
// прогон по этому SHA в именах job, а `upgrade_from` по-прежнему выбирает по
// тегу — выпускаемый тег из кандидатов исключается.
const SHA = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678';
test('#540 AC1: with --ref the dispatch and the recognition use the candidate SHA, upgrade_from still excludes the tag', async () => {
const oursBySha = [{ name: `journeys · HP ${SHA} · HA stable`, conclusion: 'success' }, { name: 'upgrade · HP v1.73.0 · HA stable', conclusion: 'success' }];
const fake = fakeOps({ snapshots: [[run({ status: 'in_progress', conclusion: null })], [run()]], jobsById: { 1: oursBySha } });
const outcome = await e2eGate({ tag: TAG, ref: SHA, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.match(outcome.note, new RegExp(SHA));
assert.deepEqual(fake.dispatched, [[SHA, 'v1.73.0']], 'houseplan_ref is the SHA; upgrade_from is the previous stable, not the tag under release');
});
test('#540 AC1: a run whose jobs carry the tag, not the SHA, is foreign to a SHA-dispatched gate', async () => {
const byTag = run({ databaseId: 3, url: 'https://e2e/run/3' });
const fake = fakeOps({ snapshots: [[byTag], [byTag], [byTag]], jobsById: { 3: ours() }, startedAt: 100_000 });
const outcome = await e2eGate({ tag: TAG, ref: SHA, ops: fake.ops, pollMs: 1000, appearMs: 2500 });
assert.equal(outcome.result, 'missing', 'a tag-named run is not the SHA run — the old ZIP-from-release path is gone');
assert.equal(isOurRun(ours(), SHA), false);
assert.equal(classifyRun([{ name: `first-run · HP ${SHA} · HA stable` }], SHA), 'ours');
});
test('#540: without --ref the gate behaves exactly as before — the tag is the ref', async () => {
const fake = fakeOps({ snapshots: [[run()]], jobsById: { 1: ours() } });
const outcome = await e2eGate({ tag: TAG, ops: fake.ops, pollMs: 1000 });
assert.equal(outcome.result, 'green');
assert.deepEqual(fake.dispatched, [[TAG, 'v1.73.0']]);
});
+5 -2
View File
@@ -59,11 +59,14 @@ test('full performance is isolated to stable, scheduled and manual entry points'
assert.equal((workflow.match(/--candidate-sha=/g) || []).length, 2);
const release = readWorkflow('release.yml');
assert.ok(release.includes('if: ${{ !github.event.release.prerelease }}'));
// #540: признак стабильного — тег кандидата, не поле события: тот же гейт
// работает и по `workflow_dispatch`, где события нет.
assert.ok(release.includes("if: ${{ needs.candidate.outputs.prerelease != 'true' }}"));
assert.ok(release.includes('--workflow=performance.yml --label="Полные бенчмарки производительности"'));
assert.ok(release.includes('test -s dist/houseplan-panel.js'));
assert.ok(release.includes('files: dist/houseplan-card.js'),
assert.ok(release.includes('cp dist/houseplan-card.js houseplan.zip release-assets/'),
'the standalone release asset remains card-only; the panel ships through HACS zip');
assert.ok(!release.includes('softprops/action-gh-release'), 'one upload path (gh release upload into the draft), not two');
});
test('#160 Stage 3 dense fixture extends rather than mutates the historical witness', () => {
+79
View File
@@ -0,0 +1,79 @@
// #540: паспорт установочных ассетов — публикуются ровно те байты, что прошли гейты.
import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
INSTALLABLE_ASSETS, SUMS_FILE, compareSums, formatSums, parseSums, sha256Hex, sumsOfDirectory,
} from '../scripts/release-assets.mjs';
const A = 'a'.repeat(64);
const B = 'b'.repeat(64);
const C = 'c'.repeat(64);
test('#540: the passport covers exactly the two installable assets, in sha256sum format, sorted', () => {
assert.deepEqual(INSTALLABLE_ASSETS, ['houseplan-card.js', 'houseplan.zip']);
assert.equal(SUMS_FILE, 'SHA256SUMS');
const text = formatSums({ 'houseplan.zip': A, 'houseplan-card.js': B });
assert.equal(text, `${B} houseplan-card.js\n${A} houseplan.zip\n`);
assert.deepEqual(parseSums(text), { 'houseplan-card.js': B, 'houseplan.zip': A });
assert.deepEqual(parseSums(`${A} *houseplan.zip\r\n`), { 'houseplan.zip': A }, 'binary marker and CRLF tolerated');
assert.throws(() => formatSums({}), /нет ни одного/);
assert.throws(() => parseSums(''), /пустой/);
assert.throws(() => parseSums('deadbeef x'), /непонятная/);
assert.throws(() => parseSums(`${A} x\n${B} x\n`), /дважды/);
});
test('#540 AC3: a present asset with another hash is a mismatch — never a silent replacement; an absent one is merely missing', () => {
const expected = { 'houseplan-card.js': B, 'houseplan.zip': A };
assert.deepEqual(compareSums(expected, { 'houseplan-card.js': B, 'houseplan.zip': A }),
{ ok: true, missing: [], mismatched: [], extra: [] });
assert.deepEqual(compareSums(expected, { 'houseplan-card.js': B }),
{ ok: false, missing: ['houseplan.zip'], mismatched: [], extra: [] }, 'repair may add what is missing');
assert.deepEqual(compareSums(expected, { 'houseplan-card.js': B, 'houseplan.zip': C }),
{ ok: false, missing: [], mismatched: ['houseplan.zip'], extra: [] }, 'v1.75.0 class: public bytes differ from the verified ones');
assert.deepEqual(compareSums(expected, { 'houseplan-card.js': B, 'houseplan.zip': A, 'extra.bin': C }).extra, ['extra.bin']);
});
test('#540: the CLI writes the passport from real files and refuses an incomplete set; check exits 1 on a mismatch', () => {
const script = fileURLToPath(new URL('../scripts/release-assets.mjs', import.meta.url));
const dir = mkdtempSync(join(tmpdir(), 'hp-release-assets-'));
try {
writeFileSync(join(dir, 'houseplan-card.js'), 'card');
let r = spawnSync(process.execPath, [script, 'sums', dir], { encoding: 'utf8' });
assert.equal(r.status, 1, 'no passport for a half set');
assert.match(r.stderr, /houseplan\.zip отсутствует/);
writeFileSync(join(dir, 'houseplan.zip'), 'zip');
r = spawnSync(process.execPath, [script, 'sums', dir], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
const sums = readFileSync(join(dir, SUMS_FILE), 'utf8');
assert.deepEqual(parseSums(sums), {
'houseplan-card.js': sha256Hex(Buffer.from('card')),
'houseplan.zip': sha256Hex(Buffer.from('zip')),
});
assert.deepEqual(sumsOfDirectory(dir), parseSums(sums));
r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE)], { encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
writeFileSync(join(dir, 'houseplan.zip'), 'other bytes');
r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE)], { encoding: 'utf8' });
assert.equal(r.status, 1);
assert.match(r.stdout, /MISMATCH houseplan\.zip/);
assert.match(r.stderr, /хеш расходится: houseplan\.zip/);
rmSync(join(dir, 'houseplan.zip'));
r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE)], { encoding: 'utf8' });
assert.equal(r.status, 1, 'missing is a failure by default');
r = spawnSync(process.execPath, [script, 'check', dir, join(dir, SUMS_FILE), '--allow-missing'], { encoding: 'utf8' });
assert.equal(r.status, 0, 'repair mode tolerates a missing asset — it will be added');
assert.match(r.stdout, /missing {2}houseplan\.zip/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+19 -14
View File
@@ -15,7 +15,7 @@ import {
versionFromTag,
} from '../scripts/release-contract.mjs';
import {
assertHacsDiscoverableTag, parseIssueList, parsePrereleaseArgs, prereleaseWorkflowSucceeded,
assertHacsDiscoverableTag, parseIssueList, parsePrereleaseArgs,
readZipEntries, verifyReleaseProjection,
} from '../scripts/release-prerelease.mjs';
@@ -138,7 +138,7 @@ test('local orchestrator validates issue lists and public release assets', () =>
assert.throws(() => parsePrereleaseArgs([tag, 'extra']), /Exactly one/);
const release = {
tagName: tag, isDraft: false, isPrerelease: true,
assets: [{ name: 'houseplan-card.js', size: 10 }, { name: 'houseplan.zip', size: 20 }],
assets: [{ name: 'houseplan-card.js', size: 10 }, { name: 'houseplan.zip', size: 20 }, { name: 'SHA256SUMS', size: 5 }],
};
assert.equal(verifyReleaseProjection(release, { tag }), release);
assert.throws(
@@ -146,21 +146,23 @@ test('local orchestrator validates issue lists and public release assets', () =>
/still a draft/,
);
assert.throws(
() => verifyReleaseProjection({ ...release, assets: release.assets.slice(0, 1) }, { tag }),
() => verifyReleaseProjection({ ...release, assets: release.assets.filter((a) => a.name !== 'houseplan.zip') }, { tag }),
/houseplan\.zip/,
);
assert.equal(prereleaseWorkflowSucceeded('Release', 'success'), true);
assert.equal(prereleaseWorkflowSucceeded('Announce release', 'skipped'), true);
assert.equal(prereleaseWorkflowSucceeded('Release', 'skipped'), false);
assert.equal(prereleaseWorkflowSucceeded('Announce release', 'failure'), false);
// #540: паспорт — часть единого вида релиза; бета без него неполна.
assert.throws(
() => verifyReleaseProjection({ ...release, assets: release.assets.slice(0, 2) }, { tag }),
/SHA256SUMS/,
);
const orchestrator = readFileSync(
new URL('../scripts/release-prerelease.mjs', import.meta.url), 'utf8',
);
assert.match(
orchestrator,
/if \(!prereleaseWorkflowSucceeded\(label, row\.conclusion\)\)/,
'the workflow waiter must use the prerelease-aware conclusion policy',
);
// #540: после публикации никто не ждёт независимых републикаторов — их нет.
assert.ok(!/waitForReleaseWorkflows|release-zip\.yml|prereleaseWorkflowSucceeded/.test(orchestrator),
'the local publisher no longer waits for release.yml/release-zip.yml to re-upload what it already verified');
assert.match(orchestrator, /formatSums\(\{\n\s+'houseplan-card\.js': sha256Path\(bundlePath\),\n\s+'houseplan\.zip': sha256Path\(zipPath\),/,
'the passport is computed from the very files that are uploaded');
assert.match(orchestrator, /'release', 'upload', tag, bundlePath, zipPath, sumsPath,/);
});
test('release ZIP inspection is portable and does not depend on tar', () => {
@@ -225,8 +227,11 @@ test('manual publish workflow is draft-first, exact-SHA gated and self-contained
'node scripts/release-contract.mjs',
'node scripts/release-gate.mjs',
'--draft --prerelease',
"'houseplan-card.js', 'houseplan.zip'",
"'houseplan-card.js', 'houseplan.zip', 'SHA256SUMS'",
'test -s dist/houseplan-panel.js',
'node scripts/release-assets.mjs sums release-assets',
'node scripts/release-assets.mjs check public release-assets/SHA256SUMS',
'git -c core.autocrlf=false archive --format=zip --output=houseplan.zip',
'--draft=false --prerelease',
'Verify HACS prerelease discovery order',
'group: publish-prerelease-${{ inputs.tag }}',
@@ -250,7 +255,7 @@ test('manual publish workflow is draft-first, exact-SHA gated and self-contained
const local = readFileSync(new URL('../scripts/release-prerelease.mjs', import.meta.url), 'utf8');
assert.ok(local.includes("'core.autocrlf=false', 'archive', '--format=zip'"));
assert.ok(local.includes("'release', 'download'"));
assert.ok(local.includes("'release-zip.yml'"));
assert.ok(!local.includes("'release-zip.yml'"), '#540: no republisher to wait for');
assert.ok(local.includes('Published release needs stale-asset recovery'));
assert.ok(local.includes("['SIGINT'"));
assert.ok(!local.includes("run('tar'"));
+91 -27
View File
@@ -1,34 +1,99 @@
// #514: release.yml holds the assets of a stable release until E2E on a real HA is green.
// #540: release.yml is the ONLY publisher of installable assets, and it publishes
// only after the gates saw the very same bytes.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const workflow = readFileSync(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
const read = (name) => readFileSync(new URL(name, `file://${WORKFLOWS}`), 'utf8');
const workflow = read('release.yml');
const at = (marker, text = workflow) => { const i = text.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
const job = (name) => {
const start = at(`\n ${name}:\n`);
const rest = workflow.slice(start + 1);
const next = rest.slice(1).search(/\n {2}[a-z-]+:\n/);
return next < 0 ? rest : rest.slice(0, next + 1);
};
const jobNeeds = (name) => {
const m = /^ {4}needs: (.+)$/m.exec(job(name));
if (!m) return [];
return m[1].replace(/[[\]\s]/g, '').split(',').filter(Boolean);
};
test('#514 AC1/AC3: the E2E gate step exists in job gate, after Full Performance, for stable releases only', () => {
const gate = at(' gate:\n');
const build = at(' build:\n');
const perf = at(' - name: Require full performance for a stable release\n');
const e2e = at(' - name: Require green E2E on a real Home Assistant for a stable release\n');
assert.ok(gate < perf && perf < e2e && e2e < build, 'E2E stands after Full Performance inside job gate');
const step = workflow.slice(e2e, build);
assert.match(step, /if: \$\{\{ !github\.event\.release\.prerelease \}\}/, 'prereleases skip the step');
assert.match(step, /node scripts\/e2e-gate\.mjs --tag="\$TAG"/);
assert.match(step, /TAG: \$\{\{ github\.event\.release\.tag_name \}\}/);
test('#540 AC1: exactly one workflow reacts to the release event, and none of them publishes on it', () => {
const listeners = readdirSync(WORKFLOWS).filter((name) => name.endsWith('.yml'))
.filter((name) => /^\s*release:\s*\n\s+types:/m.test(read(name).slice(0, read(name).indexOf('\njobs:'))));
assert.deepEqual(listeners, ['release.yml'], 'release-zip.yml (immediate ZIP upload) is gone and must not come back');
assert.ok(!readdirSync(WORKFLOWS).includes('release-zip.yml'));
// asset uploads live only in the job that needs the gate
const jobs = [...workflow.slice(at('\njobs:\n')).matchAll(/^ {2}([a-z-]+):\n/gm)].map((m) => m[1]);
assert.deepEqual(jobs, ['candidate', 'gate', 'stage', 'publish', 'announce', 'hacs-discovery']);
const uploads = jobs.filter((name) => /gh release upload|softprops\/action-gh-release/.test(job(name)));
assert.deepEqual(uploads, ['stage'], 'the one uploading job');
assert.deepEqual(jobNeeds('stage'), ['candidate', 'gate']);
assert.deepEqual(jobNeeds('publish'), ['candidate', 'gate', 'stage']);
});
test('#514: the gate dispatches with a token that can reach houseplan-e2e, with the process token as fallback', () => {
const e2e = at(' - name: Require green E2E on a real Home Assistant for a stable release\n');
const step = workflow.slice(e2e, at(' build:\n'));
assert.match(step, /GH_TOKEN: \$\{\{ secrets\.E2E_DISPATCH_TOKEN \|\| secrets\.HP_PROCESS_TOKEN \}\}/);
test('#540 AC2: a release published by hand is taken back to draft before any gate runs', () => {
const candidate = job('candidate');
assert.match(candidate, /gh release edit "\$TAG" --repo "\$GITHUB_REPOSITORY" --draft\n/, 'fail-closed re-draft');
assert.ok(at('--draft\n', candidate) < at('\n gate:\n'), 're-draft is in the candidate job, ahead of the gate');
assert.match(candidate, /if \[ "\$EVENT" = "release" \]; then/, 'only a hand-made publication is re-drafted');
assert.match(candidate, /echo "mode=repair"/, 'a dispatch on a public release is a repair, not a re-publication');
assert.match(candidate, /if: \$\{\{ github\.event_name == 'workflow_dispatch' \|\| !github\.event\.release\.prerelease \}\}/,
'betas published by hand are skipped: they have their own staged path');
const triggers = workflow.slice(at('\non:\n'), at('\npermissions:'));
assert.match(triggers, /release:\n\s+types: \[published\]/, '`created` never fires for drafts — `published` catches both paths');
assert.match(triggers, /workflow_dispatch:\n\s+inputs:\n\s+tag:/);
});
test('#540 AC1/#514: the gate judges the exact SHA — trailer names the tag, contract, Validate, Full Performance, E2E on the SHA', () => {
const gate = job('gate');
const trailer = at('grep -Fxq "Release: $TAG"', gate);
const contract = at('node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY" --stable', gate);
const validate = at('node scripts/release-gate.mjs "$SHA"\n', gate);
const perf = at(' - name: Require full performance for a stable release\n', gate);
const e2e = at(' - name: Require green E2E on a real Home Assistant for a stable release\n', gate);
assert.ok(trailer < contract && contract < validate && validate < perf && perf < e2e, 'order: trailer, contract, Validate, Full Performance, E2E');
const e2eStep = gate.slice(e2e);
assert.match(e2eStep, /if: \$\{\{ needs\.candidate\.outputs\.prerelease != 'true' \}\}/, 'prereleases skip the step');
assert.match(e2eStep, /node scripts\/e2e-gate\.mjs --ref="\$SHA" --tag="\$TAG"/, 'E2E installs the candidate tree, not a public ZIP');
assert.match(e2eStep, /GH_TOKEN: \$\{\{ secrets\.E2E_DISPATCH_TOKEN \|\| secrets\.HP_PROCESS_TOKEN \}\}/);
assert.match(gate, /--workflow=performance\.yml --label="Полные бенчмарки производительности"/);
assert.ok(!/github\.event\.release\.tag_name/.test(gate + job('stage') + job('publish')), 'every job works from the resolved candidate, not the event payload');
});
test('#540 AC3: one build, deterministic ZIP from the tree E2E installed, passport, verified public bytes', () => {
const stage = job('stage');
assert.match(stage, /git -c core\.autocrlf=false archive --format=zip --output=houseplan\.zip \\\n\s+"\$SHA:custom_components\/houseplan"/);
assert.match(stage, /node scripts\/verify-houseplan-zip\.mjs houseplan\.zip/);
assert.match(stage, /git rev-parse "\$SHA:custom_components\/houseplan"/, 'tree hash printed: identity with the E2E tarball');
assert.match(stage, /node scripts\/release-assets\.mjs sums release-assets/);
assert.match(stage, /test -s dist\/houseplan-panel\.js/);
assert.ok(at('node scripts/release-assets.mjs sums', stage) < at('gh release upload', stage), 'passport before upload');
// repair: only missing assets, a differing hash is a failure
assert.match(stage, /if \[ "\$MODE" = "repair" \]; then/);
assert.match(stage, /node scripts\/release-assets\.mjs check public release-assets\/SHA256SUMS --allow-missing/);
const repair = stage.slice(at('if [ "$MODE" = "repair" ]', stage), at(' else\n # Draft', stage));
const repairCommands = repair.split('\n').filter((line) => !/^\s*#/.test(line) && !/gh release download/.test(line)).join('\n');
assert.ok(!/--clobber/.test(repairCommands), 'repair never clobbers a public asset');
assert.match(repair, /gh release upload "\$TAG" \$missing --repo "\$GITHUB_REPOSITORY"\n/);
const publish = job('publish');
assert.ok(at('--draft=false', publish) < at('gh release download', publish), 'publish, then read back what the public sees');
assert.match(publish, /diff -u passport\/SHA256SUMS public\/SHA256SUMS/);
assert.match(publish, /node scripts\/release-assets\.mjs check public passport\/SHA256SUMS\n/);
assert.match(publish, /test "\$\(git rev-list -n 1 "refs\/tags\/\$TAG"\)" = "\$SHA"/);
assert.match(publish, /download-artifact@v7/, 'the passport travels from stage as an artifact, not via the release');
});
// #538: анонс — последнее звено выпуска, а не параллельное ему. Пока он висел
// на самом событии `release: published`, гонку он выигрывал всегда: проверять
// ему нечего. 12.09 v1.75.0 объявили в канале в ту же минуту, когда гейт
// отказал выкладывать ассеты, и снаружи это выглядело обычным релизом.
const announce = readFileSync(new URL('../.github/workflows/announce.yml', import.meta.url), 'utf8');
const announce = read('announce.yml');
test('#538 AC1: событие релиза не может запустить анонс', () => {
const triggers = announce.slice(announce.indexOf('\non:'), announce.indexOf('\npermissions:'));
@@ -39,16 +104,15 @@ test('#538 AC1: событие релиза не может запустить
'мёртвая ветка события не оставлена в шагах');
});
test('#538 AC2: release.yml зовёт анонс после выкладки ассетов', () => {
const job = at(' announce:\n');
const build = at(' build:\n');
assert.ok(build < job, 'анонс описан после сборки, а не до неё');
const block = workflow.slice(job, workflow.indexOf('\n hacs-discovery:'));
// Не `/needs: build/`: в том же блоке лежит комментарий, где эта строка
test('#538 AC2 / #540: release.yml зовёт анонс только после публикации проверенных ассетов', () => {
const block = job('announce');
assert.ok(at('\n publish:\n') < at('\n announce:\n'), 'анонс описан после публикации, а не до неё');
// Не `/needs: publish/`: в том же блоке лежит комментарий, где эта строка
// процитирована, и проверка зеленела бы на нём. Требуется сама директива.
assert.match(block, /^ {4}needs: build$/m, 'анонс зависит от выкладки ассетов');
assert.match(block, /^ {4}needs: \[candidate, publish\]$/m, 'анонс зависит от публикации');
assert.match(block, /if: \$\{\{ needs\.publish\.outputs\.newly_published == 'true' \}\}/, 'ремонт не анонсируется');
assert.match(block, /uses: \.\/\.github\/workflows\/announce\.yml/);
assert.match(block, /prerelease: \$\{\{ github\.event\.release\.prerelease \}\}/,
'беты остаются тихими по тому же признаку, что и раньше');
assert.match(block, /prerelease: \$\{\{ needs\.candidate\.outputs\.prerelease == 'true' \}\}/,
'беты остаются тихими по признаку тега');
assert.match(block, /secrets: inherit/);
});