From 0d047450ce2dca2062c3fdd038092abc221173af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:09:08 +0300 Subject: [PATCH] =?UTF-8?q?fix(ci):=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=D0=B7=D0=BD=D1=8B=D0=B9=20proof=20=D0=BD?= =?UTF-8?q?=D0=B0=20main=20(#619)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #619 User-Visible: no --- .github/workflows/validate.yml | 24 +++++++++++++----------- docs/DEVELOPMENT.md | 5 +++-- scripts/ci-proof.mjs | 16 +++++++++++----- scripts/release-gate.mjs | 6 +++++- test/ci-proof.test.mjs | 2 ++ test/release-gate.test.mjs | 14 +++++++++++++- test/validate-workflow.test.mjs | 23 ++++++++++++++++++++--- 7 files changed, 67 insertions(+), 23 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1965d75c..b8d3f214 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -243,7 +243,7 @@ jobs: mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510 base: ${{ steps.base.outputs.base }} # `base` — до какого коммита классифицировать файлы ветки (#387), - # `range_base` — от какого судить диапазон на dev (#388). + # `range_base` — от какого судить диапазон на dev/main (#388, #619). range_base: ${{ steps.base.outputs.range_base }} steps: # `git diff --name-only` содержимого файлов не читает вовсе, поэтому @@ -283,9 +283,10 @@ jobs: gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \ -f branch="$BRANCH" -f status=completed -F per_page=100 \ > /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json - if [ "$REF" = "refs/heads/dev" ]; then - # На dev классифицировать нечего (всё true), но база диапазона - # нужна гейту «новый код не добавляет any» в job frontend (#388). + if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then + # На интеграционных ветках классифицировать нечего (всё true), но + # база диапазона нужна гейту «новый код не добавляет any» в job + # frontend (#388, #619). node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \ --name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json exit 0 @@ -302,8 +303,8 @@ jobs: HEAD_SHA: ${{ github.sha }} REF: ${{ github.ref }} run: | - if [ "$REF" = "refs/heads/dev" ]; then - echo "dev: без фильтров, всё true" + if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then + echo "dev/main: без фильтров, всё true" node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" exit 0 fi @@ -713,11 +714,12 @@ jobs: git fetch -q origin dev zero=$(printf '%040d' 0) base="" - # Пуш прямо в dev: диапазон — то, что добавлено с последнего - # ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша: - # его прогон штатно отменяется следующим, и добавленные им строки не - # судил бы никто. Фолбэк — прежний `before`. - if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then + # Пуш прямо в dev/main: диапазон — то, что добавлено с последнего + # ДОКАЗАННО зелёного предка (#388, #619), а не с головы предыдущего + # пуша: его прогон штатно отменяется следующим, и добавленные им + # строки не судил бы никто. Фолбэк — прежний `before`. + if [ "$EVENT_NAME" != "pull_request" ] \ + && { [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; }; then base="${PROVEN_BASE:-$BEFORE_SHA}" fi # Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 50f88aee..454b790b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -509,8 +509,9 @@ 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 and cannot hide an older full -failure; a later complete full proof can refresh it (#511). The release also requires Full +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 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/scripts/ci-proof.mjs b/scripts/ci-proof.mjs index 715b48c2..6f7553c0 100644 --- a/scripts/ci-proof.mjs +++ b/scripts/ci-proof.mjs @@ -355,12 +355,18 @@ export function evaluateCiProof({ return result('green', `${policy?.name || 'consumer'} proof is complete`); } -/** Newest relevant proof wins; cancelled and policy-inadequate stale runs do not. */ +/** + * 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. + */ export function selectCiProofVerdict(evaluations) { - for (const item of evaluations || []) { - if (item?.status === 'cancelled' || item?.status === 'stale') continue; - return item; - } + 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 d21883f5..3abf07ce 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -76,7 +76,11 @@ export async function classifyValidateProofs({ } } const current = evaluations.at(-1); - if (current.status !== 'cancelled' && current.status !== 'stale') break; + // #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; } return selectCiProofVerdict(evaluations); } diff --git a/test/ci-proof.test.mjs b/test/ci-proof.test.mjs index b266fc4c..3aff03bf 100644 --- a/test/ci-proof.test.mjs +++ b/test/ci-proof.test.mjs @@ -133,6 +133,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'); }); test('#601 AC3: release policy accepts a full proof without requested mutants; light stays stale; review/merge still demand them', () => { diff --git a/test/release-gate.test.mjs b/test/release-gate.test.mjs index 19c3fa4c..57f4caff 100644 --- a/test/release-gate.test.mjs +++ b/test/release-gate.test.mjs @@ -111,6 +111,18 @@ 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 () => { + const older = proofContext({ id: 13 }); + const newer = proofContext({ id: 14, conclusion: 'failure' }); + const contexts = new Map([[13, older.context], [14, newer.context]]); + const verdict = await classifyValidateProofs({ + 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'); +}); + test('release gate can target the dedicated exact-SHA performance workflow', () => { assert.equal( workflowRunsUrl({ repo: 'Matysh/houseplan-card', workflow: 'performance.yml', sha: 'abc/123' }), @@ -121,7 +133,7 @@ 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, /cancelled or light run is not a release verdict and cannot hide an older full\nfailure/); + assert.match(development, /Any complete green full proof on the exact SHA\n\s*is sufficient/); 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/); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 24b2ddf3..1074926d 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -167,9 +167,9 @@ test('классификация опирается на завершённый assert.match(changes, /actions: read/, 'чтение прогонов требует прав'); // У PR диапазон задан событием, считать его нечем и незачем. assert.match(changes, /if: github\.event_name != 'pull_request'/); - // На dev классификации нет вовсе — там всё true; шаг там считает базу - // диапазона для другого потребителя (#388), и это разные выходы. - assert.match(changes, /dev: без фильтров, всё true/); + // На dev/main классификации нет вовсе — там всё true; шаг там считает базу + // диапазона для другого потребителя (#388, #619), и это разные выходы. + assert.match(changes, /dev\/main: без фильтров, всё true/); assert.match(changes, /--name=range_base/); // Пустая база означает «доказательства нет» и обязана вести к полному // прогону, а не к пустому диффу, который выглядел бы как «ничего не менялось». @@ -178,6 +178,23 @@ test('классификация опирается на завершённый 'без базы классификация обязана раскрываться в полный прогон'); }); +test('#619: Validate на main раскрывает полный набор так же, как на dev', () => { + const workflow = read('validate.yml'); + const changes = workflow.slice( + workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'), + ); + const integrationRefs = /\[ "\$REF" = "refs\/heads\/dev" \] \|\| \[ "\$REF" = "refs\/heads\/main" \]/g; + assert.equal(changes.match(integrationRefs)?.length, 2, + 'base и classify обязаны одинаково распознавать dev/main'); + assert.match(changes, /dev\/main: без фильтров, всё true/); + + const frontend = workflow.slice( + workflow.indexOf('\n frontend:\n'), workflow.indexOf('\n smoke:\n'), + ); + assert.match(frontend, integrationRefs, + 'no-new-any на main обязан судить диапазон от доказанного предка, а не HEAD..HEAD'); +}); + test('перф-смок добавляет профиль ровно при своём выходе changes (#473 AC3)', () => { const workflow = read('validate.yml'); const changes = workflow.slice(workflow.indexOf('\n changes:\n'), workflow.indexOf('\n reuse:\n'));