From eb77224e0c4199704f63a68d11afb99db2f688e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 07:42:23 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20st?= =?UTF-8?q?aged=E2=86=92tested=E2=86=92published=20=D0=BF=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=B0=D1=81=D1=81=D0=B5=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20(#540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `release-zip.yml` выкладывал `houseplan.zip` в ту же секунду, когда релиз становился публичным — до Validate, Full Performance и E2E; `release.yml` параллельно пересобирал `houseplan-card.js`, а E2E требовал публичного ZIP, чтобы вообще начаться. Публикаторов было четыре, порядок — ни одного. Теперь публикатор стабильных один — `release.yml`: закрепить SHA → релиз в черновике (опубликованный руками немедленно возвращается в черновик) → гейты на SHA (трейлер `Release: `, контракт `--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=` — под тестом кандидат, `--tag` только для выбора `upgrade_from` - `scripts/release-contract.mjs --stable` - мутанты: независимый публикатор, снятая зависимость от гейта, релиз без возврата в черновик, `--clobber` в ремонте, E2E на теге, слепой паспорт Issue: #540 User-Visible: no --- .github/workflows/publish-prerelease.yml | 27 +- .github/workflows/release-zip.yml | 41 -- .github/workflows/release.yml | 457 +++++++++++++++++++---- AGENTS.md | 7 +- PROCESS.md | 6 +- docs/DEVELOPMENT.md | 52 ++- docs/STATUS.md | 6 +- docs/TESTING.md | 13 +- scripts/e2e-gate.mjs | 67 ++-- scripts/mutation-gate.mjs | 70 +++- scripts/release-assets.mjs | 120 ++++++ scripts/release-contract.mjs | 19 +- scripts/release-prerelease.mjs | 73 ++-- test/e2e-gate.test.mjs | 31 ++ test/performance-workflow.test.mjs | 7 +- test/release-assets.test.mjs | 79 ++++ test/release-contract.test.mjs | 33 +- test/release-workflow.test.mjs | 118 ++++-- 18 files changed, 948 insertions(+), 278 deletions(-) delete mode 100644 .github/workflows/release-zip.yml create mode 100644 scripts/release-assets.mjs create mode 100644 test/release-assets.test.mjs diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 82823690..7c43d62d 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -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: diff --git a/.github/workflows/release-zip.yml b/.github/workflows/release-zip.yml deleted file mode 100644 index 0ed0fed2..00000000 --- a/.github/workflows/release-zip.yml +++ /dev/null @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6aa9b067..d25dfef1 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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}. ` + diff --git a/AGENTS.md b/AGENTS.md index 35268350..cf9d0ba3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.** diff --git a/PROCESS.md b/PROCESS.md index 9864d502..6209d542 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -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 не касается. --- diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a396e57d..1e79b907 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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: ` 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=` 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 :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`. diff --git a/docs/STATUS.md b/docs/STATUS.md index 78944707..11b96853 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 -- --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 -- --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/-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 -- `, curate by hand, then `npm run release:notes -- --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 -- --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/-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 -- `, curate by hand, then `npm run release:notes -- --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 -- --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 | diff --git a/docs/TESTING.md b/docs/TESTING.md index 83141122..d7d79b95 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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=`, #540: +ставится `custom_components/houseplan` из tarball коммита — то же дерево, из +которого `git archive` строит `houseplan.zip`) и ждёт зелёного: красный — +релиз остаётся черновиком, ассеты не публикуются. В цикле разработки и на +бетах не участвует. ## Версия в кадрах и попиксельная приёмка (#512) diff --git a/scripts/e2e-gate.mjs b/scripts/e2e-gate.mjs index 68a2cdfd..7f0417f6 100755 --- a/scripts/e2e-gate.mjs +++ b/scripts/e2e-gate.mjs @@ -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= [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml] + * #540: под тестом — коммит-кандидат (`--ref=`), а не публичный релиз. + * install-houseplan.mjs для ветки/коммита ставит `custom_components/houseplan` + * из tarball codeload — то же дерево, из которого `git archive` строит + * `houseplan.zip`. Так релиз проверяется ДО того, как станет публичным; раньше + * гейт качал ZIP из релиза, то есть требовал публикации до проверки. `--tag` + * при этом остаётся: он исключает выпускаемый тег из выбора `upgrade_from`. + * + * node scripts/e2e-gate.mjs --tag= [--ref=] [--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 » приняла бы прогон нового - * релиза за прогон старого (живой прогон 09.09: v1.72.0 ← run для v1.73.0). + * `ref` — это `houseplan_ref` (тег или SHA, #540). Сьют `upgrade` носит + * `upgrade_from` — тег ПРЕДЫДУЩЕГО stable, поэтому «любая job с HP » + * приняла бы прогон нового релиза за прогон старого (живой прогон 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= [--repo=Matysh/houseplan-e2e] [--workflow=e2e.yml] [--card-repo=Matysh/houseplan-card]'); + console.error('usage: e2e-gate.mjs --tag= [--ref=] [--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`); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index bf66459f..d379d47a 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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", }], }, { diff --git a/scripts/release-assets.mjs b/scripts/release-assets.mjs new file mode 100644 index 00000000..2a940d9e --- /dev/null +++ b/scripts/release-assets.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Хеши установочных ассетов релиза (#540). + * + * Релиз ставится из `houseplan.zip` (HACS) и `houseplan-card.js` (ручная + * установка). Раз проверив кандидата, публиковать надо ровно те байты, что + * проверены, — поэтому у ассетов есть паспорт `SHA256SUMS`, который считается + * на этапе сборки, кладётся в релиз рядом с ассетами и сверяется с тем, что + * реально скачивается после публикации или при ремонте существующего релиза. + * + * node scripts/release-assets.mjs sums [--out=] + * посчитать sha256 установочных ассетов в , записать SHA256SUMS + * node scripts/release-assets.mjs check [--allow-missing] + * сверить файлы в с паспортом; расхождение — код выхода 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`: ` ` по строке, детерминированный порядок. */ +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 [--out=]'); + 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 [--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; + } +} diff --git a/scripts/release-contract.mjs b/scripts/release-contract.mjs index 509b9e29..d49a9a61 100644 --- a/scripts/release-contract.mjs +++ b/scripts/release-contract.mjs @@ -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, diff --git a/scripts/release-prerelease.mjs b/scripts/release-prerelease.mjs index 987e0d1e..9d35f5b7 100644 --- a/scripts/release-prerelease.mjs +++ b/scripts/release-prerelease.mjs @@ -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(); diff --git a/test/e2e-gate.test.mjs b/test/e2e-gate.test.mjs index d6570890..270a9f5d 100755 --- a/test/e2e-gate.test.mjs +++ b/test/e2e-gate.test.mjs @@ -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']]); +}); diff --git a/test/performance-workflow.test.mjs b/test/performance-workflow.test.mjs index 2b4b54f7..c8cb4f45 100644 --- a/test/performance-workflow.test.mjs +++ b/test/performance-workflow.test.mjs @@ -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', () => { diff --git a/test/release-assets.test.mjs b/test/release-assets.test.mjs new file mode 100644 index 00000000..851d7af1 --- /dev/null +++ b/test/release-assets.test.mjs @@ -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 }); + } +}); diff --git a/test/release-contract.test.mjs b/test/release-contract.test.mjs index 9205f274..976db9a9 100644 --- a/test/release-contract.test.mjs +++ b/test/release-contract.test.mjs @@ -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'")); diff --git a/test/release-workflow.test.mjs b/test/release-workflow.test.mjs index c6e1aa39..562604b7 100644 --- a/test/release-workflow.test.mjs +++ b/test/release-workflow.test.mjs @@ -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/); });