From 43fab645b0d07a5ead78341ebca5f788db13a0fc Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sat, 26 Sep 2026 10:02:40 +0300 Subject: [PATCH] fix(ci): fail closed on newest full release proof (#656) Issue: #656 User-Visible: no --- .github/workflows/performance.yml | 3 +++ PROCESS.md | 18 ++++++++++++++++++ docs/DEVELOPMENT.md | 8 +++++--- docs/STATUS.md | 2 +- scripts/ci-proof.mjs | 9 ++++----- scripts/release-gate.mjs | 10 +++++----- test/ci-proof.test.mjs | 6 +++--- test/performance-workflow.test.mjs | 3 +++ test/release-gate.test.mjs | 21 +++++++++++++++++---- 9 files changed, 59 insertions(+), 21 deletions(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index dc46c40f..287c60c1 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -6,6 +6,9 @@ on: push: branches: - main + paths-ignore: + - ".github/workflows/**" + - "docs/**" schedule: - cron: "0 4 * * 1" workflow_dispatch: diff --git a/PROCESS.md b/PROCESS.md index a75cec68..e6a91a25 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1324,6 +1324,24 @@ Golden, браузерные смоки, performance и полный HA-харн `gh workflow run release-review.yml --ref dev -f tag=vX.Y.Z [-f candidate=]`. Беты шаг пропускают. Первый прогон — линия v1.78.0. +### 11.6 Повторные Validate на одном SHA перед релизом + +Решение владельца 2026-09-26, issue #656. + +Релизный гейт рассматривает прогоны одного SHA от нового к старому. Отменённый +прогон и proof, который не соответствует запрошенной политике (например, +лёгкий вместо полного), вердиктом не являются: гейт проходит мимо них к +следующему совместимому proof. **Среди совместимых полных прогонов решает +новейший.** Поэтому поздний полный `failed`, `missing` или `pending` блокирует +более ранний зелёный proof; новый полный зелёный прогон может обновить старый +красный. + +Это fail-closed правило. Content-addressed proof доказывает, что конкретный +прогон относится к кандидату, но не даёт старому зелёному прогону права скрыть +более позднюю проверку той же политики, которая нашла отказ. Чтобы продолжить +выпуск после такого отказа, исправляют причину и получают новый совместимый +полный зелёный прогон на том же финальном SHA либо на новом SHA кандидата. + --- ## 12. Запрещено diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 13fc531f..34bd1e15 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -623,9 +623,11 @@ then a baseline-only commit that reuses smoke, performance smoke, parity and backend from the candidate's green jobs, skips every caught witness in the mutation ledger and re-runs golden, preflight and frontend only. Review, merge and release use the same `missing` / `pending` / `cancelled` / `stale` / `failed` state machine. A -cancelled or light run is not a release verdict. Any complete green full proof on the exact SHA -is sufficient: its content-addressed evidence remains valid -even when a later duplicate run fails (#511, #619). The release also requires Full +cancelled or light run is not a release verdict. The newest compatible full run is the verdict: +a later failed full run blocks an older green proof, while a later cancelled, +light or otherwise stale run is skipped because it does not answer the same +release policy. A later complete green full run can refresh an older failure +(#511, #619, #656). The release also 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 diff --git a/docs/STATUS.md b/docs/STATUS.md index 0863631b..ae8252b6 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -43,7 +43,7 @@ same commit as the change it describes. | 2.5D View | #89 Stage 1 ships in v1.63.0-beta.1, #122 Stage 2 in v1.64.0, #160 Stage 3 in v1.73.0-beta.1, #570/#583 Stage 4 on `dev`. #649 Stage 6 makes it public: the installation-wide General settings switch `settings.volumetric_view` (Display, third item) replaces the alpha entry, the header toggle and the phone-menu item; raised tiles with one floor-shadow layer, a soft sun wash instead of Flat wedges, user wall colours independent of the theme, furniture at the Flat line width. #651 keeps device/lock clusters rigid and independent of live zoom/pan. Flat remains default and byte-for-byte unchanged; editors and `houseplan-space-card` stay Flat. Acceptance frames: `docs/design/649-25d-stage6/ACCEPTANCE.md`. | | 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 | #541 replaces three incompatible meanings of “green” with one machine-verifiable Validate proof: candidate SHA/tree, run ID/attempt, requested checks, actually executed jobs and independently checked content-addressed reuse. Review, merge and release share the same closed state machine; a light green dispatch cannot hide a full red run, and a dispatch without six executed mutant jobs cannot authorize review or merge. #573 makes the proof composite — product-tree identity, accepted golden overlay (tree, index hash, either a `Baseline-Reviewed` run or a `Baseline-Reviewed-Local` attestation) and the content key of every reusable job — and release consumers on the candidate checkout recompute and compare all of it; the accepted overlay is an input of `golden` only, so a baseline-only commit after a golden-red candidate reuses smoke, performance smoke, parity and backend, skips caught witnesses and re-runs golden alone. #641 permits a complete attested WSL/ext4 capture from a clean published SHA to replace the first expected-red artifact-transport run, while a full independent GitHub Validate on the accepted exact SHA remains mandatory. Prerelease publication requires a green full exact-SHA proof covering frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and the short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance remains in `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. | +| CI | #541 replaces three incompatible meanings of “green” with one machine-verifiable Validate proof: candidate SHA/tree, run ID/attempt, requested checks, actually executed jobs and independently checked content-addressed reuse. Review, merge and release share the same closed state machine; a light green dispatch cannot hide a full red run, and a dispatch without six executed mutant jobs cannot authorize review or merge. #656 makes repeated release proofs fail closed: among compatible full runs on one SHA the newest decides, so a later full red blocks an older green while light, stale and cancelled runs are skipped. #573 makes the proof composite — product-tree identity, accepted golden overlay (tree, index hash, either a `Baseline-Reviewed` run or a `Baseline-Reviewed-Local` attestation) and the content key of every reusable job — and release consumers on the candidate checkout recompute and compare all of it; the accepted overlay is an input of `golden` only, so a baseline-only commit after a golden-red candidate reuses smoke, performance smoke, parity and backend, skips caught witnesses and re-runs golden alone. #641 permits a complete attested WSL/ext4 capture from a clean published SHA to replace the first expected-red artifact-transport run, while a full independent GitHub Validate on the accepted exact SHA remains mandatory. Prerelease publication requires a green full exact-SHA proof covering frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and the short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance remains in `performance.yml` (`main` push excluding workflow/docs-only mirrors, 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. | | Local toolchain | #557 removes ambient-PATH claims from the owner's workstation: `scripts/windows-toolchain.ps1` keeps verified portable repository-pinned Node and a dedicated repository-pinned Python `.venv-ci` without changing system defaults; `toolchain:check` reports the current versions and exact executable/package/browser paths. #576 verifies the actual owner setup end to end: repeated Windows setup reuses the existing Node/Python/Chromium, the pinned small gate and pure backend subset are green, and repeated WSL `--verify` runs from an ext4 clone pass the real HA subset without skips and produce a Linux golden capture. The WSL entrypoint uses its own nvm + `.venv-ci`. #641 adds `golden:wsl:capture`: only the ext4 clone, clean named branch at its published remote SHA, pinned toolchain, current source fingerprint, complete matrix and witness floor can produce the self-hashed local passport; plain local capture remains diagnostic. The passport can source baseline review, but exact-SHA Linux CI remains the merge/release canon. | | 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 | | Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) | diff --git a/scripts/ci-proof.mjs b/scripts/ci-proof.mjs index 7eaaf494..e63882e7 100644 --- a/scripts/ci-proof.mjs +++ b/scripts/ci-proof.mjs @@ -434,16 +434,15 @@ export function evaluateCiProof({ } /** - * A complete green proof is content-addressed evidence for the candidate and - * remains valid regardless of a later duplicate run (#619). When no green - * proof exists, keep the newest decisive state so failures still fail closed. + * Evaluations arrive newest first. Cancelled and stale runs do not describe + * the requested policy; the newest remaining run is the verdict. In + * particular, a later failed full run must not be hidden by an older green + * proof for the same candidate (#656). */ export function selectCiProofVerdict(evaluations) { const relevant = (evaluations || []).filter( (item) => item?.status !== 'cancelled' && item?.status !== 'stale', ); - const green = relevant.find((item) => item?.status === 'green'); - if (green) return green; if (relevant.length) return relevant[0]; return { status: 'missing', note: 'no run carries a proof for the requested policy', url: null }; } diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index 3abf07ce..2f0b2f7d 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -76,11 +76,11 @@ export async function classifyValidateProofs({ } } const current = evaluations.at(-1); - // #619: a complete proof is immutable evidence for this exact SHA/tree. - // A later duplicate may fail for workflow topology rather than product - // content, so only a green proof ends the search; failures remain the - // fallback verdict when no run proves the candidate green. - if (current.status === 'green') break; + // #656: runs are newest first. A stale/light or cancelled run does not + // answer the release policy, so search past it. Every compatible state — + // including pending, missing and failed — is decisive and fails closed; + // an older green proof must not hide a newer full failure. + if (current.status !== 'cancelled' && current.status !== 'stale') break; } return selectCiProofVerdict(evaluations); } diff --git a/test/ci-proof.test.mjs b/test/ci-proof.test.mjs index 6ff21b88..6107be76 100644 --- a/test/ci-proof.test.mjs +++ b/test/ci-proof.test.mjs @@ -126,7 +126,7 @@ test('#541 AC: one state machine gives review, merge and release the same termin } }); -test('#541 AC: full red followed by light green still blocks release; a later full green refreshes it', () => { +test('#656 AC: newest compatible full proof decides; light proofs do not', () => { const redFull = proofFixture({ id: 20, conclusion: 'failure' }); const lightGreen = proofFixture({ id: 21, full: false, backend: false, integration: false }); const red = evaluateCiProof({ ...redFull, policy: CI_PROOF_POLICIES.release }); @@ -135,8 +135,8 @@ test('#541 AC: full red followed by light green still blocks release; a later fu assert.equal(selectCiProofVerdict([light, red]).status, 'failed'); const newerFull = evaluateCiProof({ ...proofFixture({ id: 22 }), policy: CI_PROOF_POLICIES.release }); assert.equal(selectCiProofVerdict([newerFull, light, red]).status, 'green'); - assert.equal(selectCiProofVerdict([red, newerFull]).status, 'green', - '#619: a failed duplicate cannot hide a complete green proof for the same candidate'); + assert.equal(selectCiProofVerdict([red, newerFull]).status, 'failed', + '#656: a newer failed full run must block an older green proof for the same candidate'); }); test('#601 AC3: release policy accepts a full proof without requested mutants; light stays stale; review/merge still demand them', () => { diff --git a/test/performance-workflow.test.mjs b/test/performance-workflow.test.mjs index c0bd0db7..42077f3c 100644 --- a/test/performance-workflow.test.mjs +++ b/test/performance-workflow.test.mjs @@ -32,6 +32,9 @@ test('full performance is isolated to stable, scheduled and manual entry points' 'name: Полные бенчмарки производительности', 'branches:', '- main', + 'paths-ignore:', + '- ".github/workflows/**"', + '- "docs/**"', 'schedule:', 'workflow_dispatch:', 'Capture base and candidate profile', diff --git a/test/release-gate.test.mjs b/test/release-gate.test.mjs index 7181f28e..ee5ce011 100644 --- a/test/release-gate.test.mjs +++ b/test/release-gate.test.mjs @@ -93,6 +93,18 @@ test('#541: release skips a newer light proof but does not let it hide an older assert.equal(verdict.url, 'https://run/10'); }); +test('#656: a newer failed light proof cannot hide an older compatible green proof', async () => { + const older = proofContext({ id: 10 }); + const newer = proofContext({ id: 11, full: false, conclusion: 'failure' }); + const contexts = new Map([[10, older.context], [11, newer.context]]); + const verdict = await classifyValidateProofs({ + runs: [older.run, newer.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', + loadContext: async (run) => contexts.get(run.databaseId), + }); + assert.equal(verdict.status, 'green'); + assert.equal(verdict.url, 'https://run/10'); +}); + test('#541: a later complete full proof refreshes an older red release candidate', async () => { const older = proofContext({ id: 10, conclusion: 'failure' }); const newer = proofContext({ id: 12 }); @@ -105,7 +117,7 @@ test('#541: a later complete full proof refreshes an older red release candidate assert.equal(verdict.url, 'https://run/12'); }); -test('#619: any complete green proof on the exact SHA survives a newer failed duplicate', async () => { +test('#656: a newer failed full proof blocks an older green proof on the exact SHA', async () => { const older = proofContext({ id: 13 }); const newer = proofContext({ id: 14, conclusion: 'failure' }); const contexts = new Map([[13, older.context], [14, newer.context]]); @@ -113,8 +125,8 @@ test('#619: any complete green proof on the exact SHA survives a newer failed du runs: [newer.run, older.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', loadContext: async (run) => contexts.get(run.databaseId), }); - assert.equal(verdict.status, 'green'); - assert.equal(verdict.url, 'https://run/13'); + assert.equal(verdict.status, 'failed'); + assert.equal(verdict.url, 'https://run/14'); }); test('release gate can target the dedicated exact-SHA performance workflow', () => { @@ -127,7 +139,8 @@ test('release gate can target the dedicated exact-SHA performance workflow', () test('#541: the release documents describe proof semantics', () => { const development = readFileSync(new URL('../docs/DEVELOPMENT.md', import.meta.url), 'utf8'); assert.match(development, /requires a complete Validate proof for its SHA and\nGit tree/); - assert.match(development, /Any complete green full proof on the exact SHA\n\s*is sufficient/); + assert.match(development, /The newest compatible full run is the verdict/); + assert.match(development, /a later failed full run blocks an older green proof/); assert.match(development, /Review, merge and release use the\nsame `missing` \/ `pending` \/ `cancelled` \/ `stale` \/ `failed` state machine/); const performance = readFileSync(new URL('../demo/performance/README.md', import.meta.url), 'utf8'); assert.match(performance, /latest\nnon-cancelled run on the SHA/);