diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index be7e3fee..8233e105 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -376,6 +376,18 @@ jobs: performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }} performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }} backend_key: ${{ steps.keys.outputs.backend }} + smoke_source_run: ${{ steps.p_smoke.outputs.source_run }} + smoke_source_attempt: ${{ steps.p_smoke.outputs.source_attempt }} + smoke_source_sha: ${{ steps.p_smoke.outputs.source_sha }} + golden_source_run: ${{ steps.p_golden.outputs.source_run }} + golden_source_attempt: ${{ steps.p_golden.outputs.source_attempt }} + golden_source_sha: ${{ steps.p_golden.outputs.source_sha }} + performance_smoke_source_run: ${{ steps.p_perf.outputs.source_run }} + performance_smoke_source_attempt: ${{ steps.p_perf.outputs.source_attempt }} + performance_smoke_source_sha: ${{ steps.p_perf.outputs.source_sha }} + backend_source_run: ${{ steps.p_backend.outputs.source_run }} + backend_source_attempt: ${{ steps.p_backend.outputs.source_attempt }} + backend_source_sha: ${{ steps.p_backend.outputs.source_sha }} steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -397,36 +409,61 @@ jobs: [ "$PERF_INTERACTION" = "true" ] && set="$set-interaction" echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT" echo "performance_smoke set: $set" - # lookup-only: маркер только проверяется, но не восстанавливается — - # сохранять его в этой job нечего, она ничего не прогоняла. + # Маркер восстанавливается во временный файл: #541 требует не только + # cache-hit, но и SHA/run исходного успешного job. Между lookup файл + # удаляется, чтобы один маркер не был принят за другой. - name: Маркер smoke id: m_smoke uses: actions/cache/restore@v6 with: path: .reuse-marker key: reuse-smoke-${{ steps.keys.outputs.smoke }} - lookup-only: true + - name: Доказательство источника smoke + id: p_smoke + if: steps.m_smoke.outputs.cache-hit == 'true' + run: node scripts/ci-proof.mjs --marker=.reuse-marker + - name: Очистить маркер smoke перед следующим lookup + if: always() + run: rm -f .reuse-marker - name: Маркер golden id: m_golden uses: actions/cache/restore@v6 with: path: .reuse-marker key: reuse-golden-${{ steps.keys.outputs.golden }} - lookup-only: true + - name: Доказательство источника golden + id: p_golden + if: steps.m_golden.outputs.cache-hit == 'true' + run: node scripts/ci-proof.mjs --marker=.reuse-marker + - name: Очистить маркер golden перед следующим lookup + if: always() + run: rm -f .reuse-marker - name: Маркер performance_smoke id: m_perf uses: actions/cache/restore@v6 with: path: .reuse-marker key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }} - lookup-only: true + - name: Доказательство источника performance_smoke + id: p_perf + if: steps.m_perf.outputs.cache-hit == 'true' + run: node scripts/ci-proof.mjs --marker=.reuse-marker + - name: Очистить маркер performance_smoke перед следующим lookup + if: always() + run: rm -f .reuse-marker - name: Маркер backend id: m_backend uses: actions/cache/restore@v6 with: path: .reuse-marker key: reuse-backend-${{ steps.keys.outputs.backend }} - lookup-only: true + - name: Доказательство источника backend + id: p_backend + if: steps.m_backend.outputs.cache-hit == 'true' + run: node scripts/ci-proof.mjs --marker=.reuse-marker + - name: Очистить маркер backend + if: always() + run: rm -f .reuse-marker - name: Что переиспользуем id: probe env: @@ -806,6 +843,7 @@ jobs: printf '%s\n' "smoke прогнана успешно (3 шарда)" \ "SHA: ${{ github.sha }}" \ "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - uses: actions/cache/save@v6 # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». @@ -904,6 +942,7 @@ jobs: printf '%s\n' "golden прогнана успешно" \ "SHA: ${{ github.sha }}" \ "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - uses: actions/cache/save@v6 # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». @@ -998,6 +1037,7 @@ jobs: printf '%s\n' "performance_smoke прогнана успешно" \ "SHA: ${{ github.sha }}" \ "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - uses: actions/cache/save@v6 # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». @@ -1078,6 +1118,7 @@ jobs: printf '%s\n' "backend прогнана успешно" \ "SHA: ${{ github.sha }}" \ "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - uses: actions/cache/save@v6 # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». @@ -1087,3 +1128,40 @@ jobs: with: path: .reuse-marker key: reuse-backend-${{ needs.reuse.outputs.backend_key }} + + # #541: общий conclusion workflow не говорит, какие условные job реально + # исполнились, а какие были законно переиспользованы. Этот всегда исполняемый + # финальный job публикует неизменяемый proof, привязанный к SHA/tree, + # run_id/run_attempt и фактическим outputs всех prerequisite job. Consumers + # review/merge/release принимают Validate только через scripts/ci-proof.mjs. + proof: + name: "Доказательство выполненных проверок" + if: always() + needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, backend] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: { ref: ${{ github.sha }} } + - uses: actions/setup-node@v7 + with: { node-version: 22 } + - name: Зафиксировать tree кандидата + id: candidate + run: echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + - name: Собрать единый CI proof + env: + CANDIDATE_SHA: ${{ github.sha }} + CANDIDATE_TREE: ${{ steps.candidate.outputs.tree }} + CI_RUN_ID: ${{ github.run_id }} + CI_RUN_ATTEMPT: ${{ github.run_attempt }} + CI_EVENT: ${{ github.event_name }} + REQUEST_FULL: ${{ inputs.full }} + REQUEST_MUTANTS: ${{ inputs.mutants }} + NEEDS_JSON: ${{ toJSON(needs) }} + run: node scripts/ci-proof.mjs --emit=artifacts/ci-proof/proof.json + - name: Опубликовать proof точной попытки + uses: actions/upload-artifact@v7 + with: + name: ci-proof-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/ci-proof/proof.json + retention-days: 30 + if-no-files-found: error diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e5e96737..13ba0684 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -430,10 +430,15 @@ 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), requires Full +checks the release contract (`release-contract.mjs --stable`) and requires a +complete Validate proof for the exact candidate SHA and Git tree (#541). The +proof is tied to the workflow run ID and attempt and lists both the requested +checks and the jobs that actually executed. A skipped heavy job counts only +when its content-addressed reuse marker names an independently verified +successful source job. 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 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 @@ -461,7 +466,7 @@ everywhere in sync: `src/houseplan-card.ts` (CARD_VERSION), `package.json`, Validate intentionally runs on branch pushes, not tag pushes, so an annotated release tag does not duplicate the expensive browser/performance matrix. Every tagged SHA must therefore already be pushed to a branch and have a completed -green exact-SHA Validate run. For an owner-approved emergency hotfix, push a +green full exact-SHA Validate proof. For an owner-approved emergency hotfix, push a temporary `hotfix/*` branch and wait for Validate before creating the tag; never tag a detached or otherwise unpushed commit, because the release gate will wait for a run that cannot exist and then fail closed after one hour. diff --git a/docs/STATUS.md b/docs/STATUS.md index 963385a9..c2192fba 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -26,7 +26,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate. | 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 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. | +| 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. 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. | | Local toolchain | #557 removes ambient-PATH claims from the owner's workstation: `scripts/windows-toolchain.ps1` keeps verified portable Node 22 and a dedicated Python 3.14 `.venv-ci` without changing system defaults; `toolchain:check` reports exact executable/package/browser paths. The WSL entrypoint uses its own nvm + `.venv-ci`, and `--verify` runs a real HA subset and one Linux golden capture from an ext4 clone. These are early-feedback paths only; exact-SHA Linux CI remains canonical. | | 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/docs/TESTING.md b/docs/TESTING.md index dfaeff23..5c45114e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2466,9 +2466,9 @@ The dedicated `Full Performance` workflow builds the candidate and base SHA, then captures seven measured samples for each sequentially on the same Node 22, Playwright Chromium and hosted runner. It runs on `main`, weekly and by manual dispatch. `demo/performance/compare.mjs` applies the tighter of the approved -absolute ceiling and baseline-relative allowance. Stable release assets need -an exact-SHA green full run; prereleases need the fast exact-SHA `Validate` -only. Raw reports and comparisons are uploaded as CI artifacts, and the check +absolute ceiling and baseline-relative allowance. Stable release assets additionally need +an exact-SHA green Full Performance run; every release, including a prerelease, +needs the full exact-SHA `Validate` proof. Raw reports and comparisons are uploaded as CI artifacts, and the check tables are written to the job summary. Local measurements remain diagnostic. See `demo/performance/README.md` for commands and the budget-review contract. diff --git a/scripts/check-inputs.mjs b/scripts/check-inputs.mjs index 0fc92aad..63e680d6 100755 --- a/scripts/check-inputs.mjs +++ b/scripts/check-inputs.mjs @@ -270,7 +270,7 @@ const BROWSER_PROTOCOL = ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-f 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**']; const WORKFLOW = ['.github/workflows/validate.yml']; /** Протокол реюза: кто считает ключ, тот и вход (§5.1 protocol). */ -const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs']; +const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs', 'scripts/ci-proof.mjs']; /** * Cross-runtime inputs, которые pytest читает динамически и которые поэтому * нельзя вывести из статических import/string ссылок (#542). @@ -286,7 +286,6 @@ const BACKEND_DYNAMIC_INPUTS = [ 'demo/fixtures/large-house.mjs', 'demo/fixtures/visual-matrix.mjs', ]; - export const CHECKS = { preflight: { // документация, провенанс, процесс — всегда запускается; реюза нет diff --git a/scripts/ci-proof.mjs b/scripts/ci-proof.mjs new file mode 100644 index 00000000..98d9af2b --- /dev/null +++ b/scripts/ci-proof.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node +// #541: один проверяемый контракт «зелёного Validate» для review, merge и +// release. Общий conclusion workflow недостаточен: лёгкий dispatch тоже green, +// а skipped job без доказанного content-addressed reuse ничего не доказывает. + +import { inflateRawSync } from 'node:zlib'; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { isMainModule } from './spawn-portable.mjs'; + +export const CI_PROOF_SCHEMA = 'houseplan-ci-proof/v1'; +export const CI_PROOF_ARTIFACT_PREFIX = 'ci-proof'; +export const CI_PROOF_STATES = Object.freeze([ + 'green', 'missing', 'pending', 'cancelled', 'stale', 'failed', +]); + +export const CI_PROOF_POLICIES = Object.freeze({ + review: Object.freeze({ name: 'review', full: false, mutants: true }), + merge: Object.freeze({ name: 'merge', full: false, mutants: true }), + release: Object.freeze({ name: 'release', full: true, mutants: true }), +}); + +const JOB_RULES = Object.freeze({ + preflight: [{ exact: 'Предполётные проверки: документация, провенанс, процесс', count: 1 }], + changes: [{ exact: 'Классификация изменённых файлов', count: 1 }], + reuse: [{ exact: 'Переиспользование: это дерево уже проверено', count: 1 }], + frontend: [{ exact: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', count: 1 }], + integration: [ + { exact: 'HACS: валидация репозитория', count: 1 }, + { exact: 'Hassfest: манифест интеграции', count: 1 }, + ], + mutants: [{ prefix: 'Мутанты по диффу (', count: 6 }], + smoke: [ + { prefix: 'Смоки в браузере (шард ', count: 3 }, + { exact: 'Смоки: все шарды зелёные', count: 1 }, + ], + golden: [{ exact: 'Golden-кадры против принятых эталонов', count: 1 }], + performance_smoke: [{ exact: 'Перф-смок: бюджет времени кадра', count: 1 }], + backend: [{ exact: 'Бэкенд: pytest в Home Assistant', count: 1 }], +}); + +const asBool = (value) => value === true || String(value) === 'true'; +const runIdOf = (run) => Number(run?.id ?? run?.databaseId ?? 0); +const runAttemptOf = (run) => Number(run?.run_attempt ?? run?.runAttempt ?? run?.attempt ?? 1); +const runShaOf = (run) => run?.head_sha ?? run?.headSha ?? ''; +const runUrlOf = (run) => run?.html_url ?? run?.url ?? null; +const jobResult = (needs, id) => needs?.[id]?.result || 'missing'; +const reuseSourceKey = (run, attempt) => `${Number(run)}:${Number(attempt)}`; + +export function ciProofArtifactName(runId, attempt) { + return `${CI_PROOF_ARTIFACT_PREFIX}-${Number(runId)}-${Number(attempt)}`; +} + +export function parseReuseMarker(text) { + const sha = String(text).match(/^SHA:\s*([0-9a-f]{40})\s*$/mi)?.[1] || null; + const runId = Number(String(text).match(/\/actions\/runs\/(\d+)/)?.[1] || 0) || null; + const attempt = Number(String(text).match(/^попытка:\s*(\d+)\s*$/mi)?.[1] || 0) || null; + if (!sha || !runId || !attempt) + throw new Error('reuse marker must contain a full SHA, an actions/runs/ URL and an attempt'); + return { sourceSha: sha, sourceRun: runId, sourceAttempt: attempt }; +} + +export function requiredCheckIds({ request = {}, selection = {} } = {}) { + const ids = ['preflight', 'changes', 'reuse']; + if (asBool(selection.frontend)) ids.push('frontend'); + if (asBool(selection.integration)) ids.push('integration'); + if (asBool(request.mutants)) ids.push('mutants'); + if (asBool(request.full)) ids.push('smoke', 'golden', 'performance_smoke'); + if (asBool(selection.backend)) ids.push('backend'); + return ids; +} + +const reuseClaim = (outputs, id) => ({ + key: outputs?.[`${id}_key`] || '', + sourceRun: Number(outputs?.[`${id}_source_run`] || 0) || null, + sourceAttempt: Number(outputs?.[`${id}_source_attempt`] || 0) || null, + sourceSha: outputs?.[`${id}_source_sha`] || null, +}); + +/** Build the immutable JSON uploaded by the final Validate job. */ +export function buildCiProof({ + candidateSha, candidateTree, runId, attempt, event, needs, + requestedFull = false, requestedMutants = false, +}) { + const changes = needs?.changes?.outputs || {}; + const reuse = needs?.reuse?.outputs || {}; + const request = { + full: asBool(requestedFull) || asBool(changes.heavy), + mutants: asBool(requestedMutants) || asBool(changes.mutants_requested), + }; + const selection = { + frontend: asBool(changes.frontend), + backend: asBool(changes.backend), + integration: asBool(changes.integration), + }; + const checks = {}; + const executed = (id, result = jobResult(needs, id)) => { + checks[id] = { mode: 'executed', result }; + }; + const executedOrReused = (id, result = jobResult(needs, id)) => { + if (asBool(reuse[id])) { + checks[id] = { mode: 'reused', result: 'success', reuse: reuseClaim(reuse, id) }; + } else { + executed(id, result); + } + }; + executed('preflight'); + executed('changes'); + executed('reuse'); + if (selection.frontend) executed('frontend'); + if (selection.integration) { + checks.integration = { + mode: 'executed', + result: jobResult(needs, 'hacs') === 'success' && jobResult(needs, 'hassfest') === 'success' + ? 'success' : `${jobResult(needs, 'hacs')}/${jobResult(needs, 'hassfest')}`, + }; + } + if (request.mutants) executed('mutants', jobResult(needs, 'changed_mutants')); + if (request.full) { + executedOrReused('smoke', asBool(reuse.smoke) + ? 'success' + : (jobResult(needs, 'smoke') === 'success' && jobResult(needs, 'smoke_done') === 'success' + ? 'success' : `${jobResult(needs, 'smoke')}/${jobResult(needs, 'smoke_done')}`)); + executedOrReused('golden'); + executedOrReused('performance_smoke'); + } + if (selection.backend) executedOrReused('backend'); + const requiredChecks = requiredCheckIds({ request, selection }); + return { + schema: CI_PROOF_SCHEMA, + candidate: { sha: candidateSha, tree: candidateTree }, + run: { id: Number(runId), attempt: Number(attempt), workflow: 'validate.yml', event }, + request, + selection, + requiredChecks, + executedChecks: requiredChecks.filter((id) => checks[id]?.mode === 'executed'), + reusedChecks: requiredChecks.filter((id) => checks[id]?.mode === 'reused'), + checks, + }; +} + +const sortedUnique = (values) => [...new Set(values)].sort(); +const sameSet = (a, b) => JSON.stringify(sortedUnique(a)) === JSON.stringify(sortedUnique(b)); +const jobsMatching = (jobs, rule) => (Array.isArray(jobs) ? jobs : []).filter((job) => ( + rule.exact ? job?.name === rule.exact : String(job?.name || '').startsWith(rule.prefix) +)); + +function executedCheckIsGreen(id, jobs) { + return (JOB_RULES[id] || []).every((rule) => { + const matches = jobsMatching(jobs, rule); + return matches.length === rule.count && matches.every((job) => job.conclusion === 'success'); + }); +} + +/** + * One state machine for all consumers. `reuseRuns` maps source run id to + * `{run,jobs}` fetched independently from the marker claim. + */ +export function evaluateCiProof({ run, proof, jobs = [], reuseRuns = new Map(), candidate = {}, policy }) { + const result = (status, note) => ({ status, note, url: runUrlOf(run) }); + if (!run) return result('missing', 'Validate run is missing'); + if (run.status !== 'completed') return result('pending', `Validate run ${runIdOf(run)} is ${run.status || 'pending'}`); + if (run.conclusion === 'cancelled') return result('cancelled', `Validate run ${runIdOf(run)} was cancelled`); + if (!proof) return result('missing', `Validate run ${runIdOf(run)} has no proof artifact`); + if (proof.schema !== CI_PROOF_SCHEMA) return result('stale', `unsupported proof schema ${proof.schema || 'missing'}`); + const expected = { + runId: runIdOf(run), attempt: runAttemptOf(run), sha: candidate.sha || runShaOf(run), tree: candidate.tree, + }; + if (proof.run?.id !== expected.runId || proof.run?.attempt !== expected.attempt + || proof.run?.workflow !== 'validate.yml' || proof.candidate?.sha !== expected.sha + || (expected.tree && proof.candidate?.tree !== expected.tree)) { + return result('stale', 'proof does not belong to the candidate SHA/tree and run attempt'); + } + if (runShaOf(run) && proof.candidate.sha !== runShaOf(run)) + return result('stale', 'run head SHA differs from proof candidate'); + if (run?.event && proof.run?.event !== run.event) + return result('stale', 'run event differs from proof event'); + if (policy?.full && !asBool(proof.request?.full)) return result('stale', 'proof is light; full gates were not requested'); + if (policy?.mutants && !asBool(proof.request?.mutants)) return result('stale', 'proof has no requested mutant jobs'); + const derived = requiredCheckIds(proof); + if (!sameSet(derived, proof.requiredChecks || [])) + return result('failed', 'proof required-check list is incomplete or inconsistent'); + const claimedExecuted = derived.filter((id) => proof.checks?.[id]?.mode === 'executed'); + const claimedReused = derived.filter((id) => proof.checks?.[id]?.mode === 'reused'); + if (!sameSet(claimedExecuted, proof.executedChecks || []) + || !sameSet(claimedReused, proof.reusedChecks || [])) { + return result('failed', 'proof executed/reused check lists are inconsistent'); + } + if (run.conclusion !== 'success') + return result('failed', `Validate run ${runIdOf(run)} concluded ${run.conclusion || 'without success'}`); + for (const id of derived) { + const claim = proof.checks?.[id]; + if (!claim || claim.result !== 'success') return result('failed', `${id}: proof result is ${claim?.result || 'missing'}`); + if (claim.mode === 'executed') { + if (!executedCheckIsGreen(id, jobs)) return result('failed', `${id}: claimed execution is absent, incomplete or not green`); + continue; + } + if (claim.mode !== 'reused' || !['smoke', 'golden', 'performance_smoke', 'backend'].includes(id)) + return result('failed', `${id}: unsupported proof mode ${claim.mode || 'missing'}`); + const reuse = claim.reuse || {}; + if (!/^[0-9a-f]{64}$/.test(reuse.key || '') || !/^[0-9a-f]{40}$/.test(reuse.sourceSha || '') + || !Number.isInteger(reuse.sourceRun) || reuse.sourceRun <= 0 + || !Number.isInteger(reuse.sourceAttempt) || reuse.sourceAttempt <= 0) { + return result('failed', `${id}: content-addressed reuse evidence is incomplete`); + } + const sourceKey = reuseSourceKey(reuse.sourceRun, reuse.sourceAttempt); + const source = reuseRuns instanceof Map ? reuseRuns.get(sourceKey) : reuseRuns?.[sourceKey]; + if (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt + || runShaOf(source.run) !== reuse.sourceSha + || !executedCheckIsGreen(id, source.jobs)) { + return result('failed', `${id}: source run does not verify the reused successful job`); + } + } + return result('green', `${policy?.name || 'consumer'} proof is complete`); +} + +/** Newest relevant proof wins; cancelled and policy-inadequate stale runs do not. */ +export function selectCiProofVerdict(evaluations) { + for (const item of evaluations || []) { + if (item?.status === 'cancelled' || item?.status === 'stale') continue; + return item; + } + return { status: 'missing', note: 'no run carries a proof for the requested policy', url: null }; +} + +export function readCiProofArtifact(bytes) { + const signature = 0x06054b50; + let eocd = -1; + for (let at = bytes.length - 22; at >= Math.max(0, bytes.length - 65557); at -= 1) { + if (bytes.readUInt32LE(at) === signature) { eocd = at; break; } + } + if (eocd < 0) throw new Error('proof artifact is not a ZIP archive'); + const count = bytes.readUInt16LE(eocd + 10); + let cursor = bytes.readUInt32LE(eocd + 16); + for (let index = 0; index < count; index += 1) { + if (bytes.readUInt32LE(cursor) !== 0x02014b50) throw new Error('proof artifact central directory is malformed'); + const method = bytes.readUInt16LE(cursor + 10); + const compressedSize = bytes.readUInt32LE(cursor + 20); + const nameLength = bytes.readUInt16LE(cursor + 28); + const extraLength = bytes.readUInt16LE(cursor + 30); + const commentLength = bytes.readUInt16LE(cursor + 32); + const local = bytes.readUInt32LE(cursor + 42); + const name = bytes.subarray(cursor + 46, cursor + 46 + nameLength).toString('utf8'); + cursor += 46 + nameLength + extraLength + commentLength; + if (!/(^|\/)proof[.]json$/.test(name)) continue; + if (bytes.readUInt32LE(local) !== 0x04034b50) throw new Error('proof artifact local header is malformed'); + const localName = bytes.readUInt16LE(local + 26); + const localExtra = bytes.readUInt16LE(local + 28); + const start = local + 30 + localName + localExtra; + const compressed = bytes.subarray(start, start + compressedSize); + const body = method === 0 ? compressed : method === 8 ? inflateRawSync(compressed) : null; + if (!body) throw new Error(`unsupported proof artifact compression ${method}`); + return JSON.parse(body.toString('utf8')); + } + throw new Error('proof.json is missing from artifact'); +} + +const apiHeaders = (token) => ({ + Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, + 'User-Agent': 'houseplan-ci-proof', 'X-GitHub-Api-Version': '2022-11-28', +}); + +async function githubJson(url, token, fetchImpl) { + const response = await fetchImpl(url, { headers: apiHeaders(token) }); + if (!response.ok) throw new Error(`GitHub API ${response.status}: ${await response.text()}`); + return response.json(); +} + +export async function githubCandidateTree({ repo, sha, token, fetchImpl = fetch }) { + const row = await githubJson(`https://api.github.com/repos/${repo}/git/commits/${sha}`, token, fetchImpl); + return row?.tree?.sha || null; +} + +export async function loadGithubProofContext({ repo, run, token, fetchImpl = fetch }) { + const runId = runIdOf(run); + const attempt = runAttemptOf(run); + const name = ciProofArtifactName(runId, attempt); + const list = await githubJson( + `https://api.github.com/repos/${repo}/actions/runs/${runId}/artifacts?name=${encodeURIComponent(name)}`, + token, fetchImpl, + ); + const artifact = (list?.artifacts || []).find((item) => item.name === name && !item.expired); + let proof = null; + if (artifact) { + const response = await fetchImpl(artifact.archive_download_url, { headers: apiHeaders(token) }); + if (!response.ok) throw new Error(`proof artifact download ${response.status}: ${await response.text()}`); + proof = readCiProofArtifact(Buffer.from(await response.arrayBuffer())); + } + const jobsBody = await githubJson( + `https://api.github.com/repos/${repo}/actions/runs/${runId}/jobs?per_page=100`, token, fetchImpl, + ); + const jobs = jobsBody?.jobs || []; + const reuseRuns = new Map(); + for (const id of proof?.reusedChecks || []) { + const sourceId = proof?.checks?.[id]?.reuse?.sourceRun; + const sourceAttempt = proof?.checks?.[id]?.reuse?.sourceAttempt; + const sourceKey = reuseSourceKey(sourceId, sourceAttempt); + if (!sourceId || !sourceAttempt || reuseRuns.has(sourceKey)) continue; + const sourceRun = await githubJson( + `https://api.github.com/repos/${repo}/actions/runs/${sourceId}/attempts/${sourceAttempt}`, token, fetchImpl, + ); + const sourceJobs = await githubJson( + `https://api.github.com/repos/${repo}/actions/runs/${sourceId}/attempts/${sourceAttempt}/jobs?per_page=100`, + token, fetchImpl, + ); + reuseRuns.set(sourceKey, { run: sourceRun, jobs: sourceJobs?.jobs || [] }); + } + return { proof, jobs, reuseRuns }; +} + +if (isMainModule(import.meta.url)) { + const value = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); + const marker = value('marker'); + const emit = value('emit'); + if (marker) { + const parsed = parseReuseMarker(readFileSync(resolve(marker), 'utf8')); + const output = `source_run=${parsed.sourceRun}\nsource_attempt=${parsed.sourceAttempt}\nsource_sha=${parsed.sourceSha}\n`; + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, output); + process.stdout.write(output); + } else if (emit) { + const proof = buildCiProof({ + candidateSha: process.env.CANDIDATE_SHA, + candidateTree: process.env.CANDIDATE_TREE, + runId: process.env.CI_RUN_ID, + attempt: process.env.CI_RUN_ATTEMPT, + event: process.env.CI_EVENT, + needs: JSON.parse(process.env.NEEDS_JSON || '{}'), + requestedFull: process.env.REQUEST_FULL, + requestedMutants: process.env.REQUEST_MUTANTS, + }); + const target = resolve(emit); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, `${JSON.stringify(proof, null, 2)}\n`); + console.log(`CI proof: ${target} (${proof.requiredChecks.join(', ')})`); + } else { + console.error('usage: ci-proof.mjs --emit= | --marker=<.reuse-marker>'); + process.exitCode = 2; + } +} diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs index ee40e5f7..74129ec9 100755 --- a/scripts/merge-candidate.mjs +++ b/scripts/merge-candidate.mjs @@ -22,6 +22,9 @@ import { spawnSync } from 'node:child_process'; import { appendFileSync } from 'node:fs'; import { isMainModule } from './spawn-portable.mjs'; +import { + CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, loadGithubProofContext, +} from './ci-proof.mjs'; export const MAX_ATTEMPTS = 3; export const VALIDATE_APPEAR_MS = 3 * 60 * 1000; @@ -36,7 +39,7 @@ export const VALIDATE_TOTAL_MS = 45 * 60 * 1000; * @param {boolean} s.devMoved dev не равен базе материала * @param {boolean} s.conflict ребейз на dev упал * @param {boolean} s.patchIdEqual дифф после ребейза совпадает с проверенным - * @param {'green'|'red'|'missing'|null} s.validate результат Validate на кандидате + * @param {'green'|'failed'|'missing'|'pending'|'cancelled'|'stale'|null} s.validate результат общего CI proof * @param {boolean} s.leaseRejected push в dev отклонён: dev двинулся снова * @param {number} s.attempt номер попытки, с 1 */ @@ -49,8 +52,8 @@ export function decideMerge(s) { } if (!s.patchIdEqual) return { action: 'rereview', to: 'S7-code-review' }; if (s.validate === null || s.validate === undefined) return { action: 'validate' }; - if (s.validate === 'missing') return { action: 'validation-missing', to: 'S6-in-progress' }; - if (s.validate === 'red') return { action: 'validation-red', to: 'S6-in-progress' }; + if (['missing', 'pending', 'cancelled', 'stale'].includes(s.validate)) return { action: 'validation-missing', to: 'S6-in-progress' }; + if (s.validate === 'failed') return { action: 'validation-red', to: 'S6-in-progress' }; if (s.leaseRejected) { if ((s.attempt ?? 1) >= (s.maxAttempts ?? MAX_ATTEMPTS)) return { action: 'give-up', to: 'S6-in-progress' }; return { action: 'retry' }; @@ -101,7 +104,12 @@ const sh = (cmd, args, opts = {}) => { return { status: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }; }; -export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, exec = sh }) { +export function realOps({ + repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), + now = Date.now, exec = sh, + candidateTree = (sha) => githubCandidateTree({ repo, sha, token }), + proofContext = (run) => loadGithubProofContext({ repo, run, token }), +}) { const pushUrl = `https://x-access-token:${token}@github.com/${repo}`; const git = (...args) => exec('git', args); const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; }; @@ -140,22 +148,34 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) = waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => { const started = now(); let runId = null; + const ignored = new Set(); + const tree = await candidateTree(sha); while (now() - started < VALIDATE_TOTAL_MS) { - const r = exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']); + const r = exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event,headSha,attempt,startedAt,createdAt', '--limit', '10']); const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []; - // Отменённый прогон ничего не доказывает (#511): его заменил следующий - // dispatch на той же ветке — ждём его, а не красим кандидата. - const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled'); + const runs = all.filter((x) => (!event || x.event === event) && !ignored.has(x.databaseId)); const run = runs.find((x) => x.databaseId === runId) || runs[0]; if (run) { runId = run.databaseId; - if (run.status === 'completed') return { result: run.conclusion === 'success' ? 'green' : 'red', url: run.url }; + if (run.status === 'completed') { + let context; + try { context = await proofContext(run); } + catch { context = { proof: null, jobs: [], reuseRuns: new Map() }; } + const verdict = evaluateCiProof({ + run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.merge, + }); + if (verdict.status === 'green' || verdict.status === 'failed') + return { result: verdict.status, url: verdict.url, note: verdict.note }; + ignored.add(run.databaseId); + runId = null; + continue; + } } else if (now() - started > VALIDATE_APPEAR_MS) { return { result: 'missing', url: null }; } await sleep(20_000); } - return { result: 'red', url: runId ? `run ${runId} (timeout)` : null }; + return { result: 'failed', url: runId ? `run ${runId} (timeout)` : null }; }, comment: (issue, body) => { const r = spawnSync('gh', ['issue', 'comment', String(issue), '--repo', repo, '--body-file', '-'], { input: body, encoding: 'utf8' }); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index a4515322..a04273df 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -8675,6 +8675,39 @@ const MUTANT_DEFINITIONS = [ replace: " return relevant.sort((a, b) => stamp(a) - stamp(b) || Number(a.id || 0) - Number(b.id || 0))[0] || null; // mutant: oldest", }], }, + { + id: 'release-proof-accepts-light-run', + guard: 'node --test test/ci-proof.test.mjs test/release-gate.test.mjs', + because: 'a later light workflow_dispatch must not hide an older full failure and release assets; ' + + 'release accepts only a proof that requested the heavy matrix (#541)', + patches: [{ + file: 'scripts/ci-proof.mjs', + find: " if (policy?.full && !asBool(proof.request?.full)) return result('stale', 'proof is light; full gates were not requested');", + replace: " if (false && policy?.full && !asBool(proof.request?.full)) return result('stale', 'mutant');", + }], + }, + { + id: 'ci-proof-ignores-run-attempt', + guard: 'node --test test/ci-proof.test.mjs', + because: 'rerunning the same Actions run changes its attempt and jobs; an artifact from another ' + + 'attempt cannot vouch for the current result (#541)', + patches: [{ + file: 'scripts/ci-proof.mjs', + find: ' if (proof.run?.id !== expected.runId || proof.run?.attempt !== expected.attempt\n', + replace: ' if (proof.run?.id !== expected.runId || false && proof.run?.attempt !== expected.attempt\n', + }], + }, + { + id: 'ci-proof-trusts-reuse-without-source-job', + guard: 'node --test test/ci-proof.test.mjs', + because: 'a cache-hit bit and key are not proof; lawful reuse also needs the source SHA/run and ' + + 'the independently fetched successful source job (#541)', + patches: [{ + file: 'scripts/ci-proof.mjs', + find: " if (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt\n || runShaOf(source.run) !== reuse.sourceSha\n || !executedCheckIsGreen(id, source.jobs)) {", + replace: " if (false && (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt\n || runShaOf(source.run) !== reuse.sourceSha\n || !executedCheckIsGreen(id, source.jobs))) {", + }], + }, { id: 'summary-first-paint-shows-unavailable', guard: 'node demo/smoke_summary_first_paint.mjs', @@ -8792,13 +8825,13 @@ const MUTANT_DEFINITIONS = [ }, { id: 'review-starts-on-red-validate', - guard: 'node --test test/validate-gate.test.mjs', + guard: 'node --test test/ci-proof.test.mjs test/validate-gate.test.mjs', because: 'a red dispatch run on the material must return the task without a review; treating ' + 'any completed run as green spends the review cycle on code CI already rejected (#510 AC2)', patches: [{ - file: 'scripts/validate-gate.mjs', - find: " if (run.conclusion !== 'success') return { result: 'red', url: run.url, note: `dispatch-прогон завершился: ${run.conclusion}` };", - replace: " // mutant: completed means green — a red dispatch falls through to the job check", + file: 'scripts/ci-proof.mjs', + find: " if (run.conclusion !== 'success')\n return result('failed', `Validate run ${runIdOf(run)} concluded ${run.conclusion || 'without success'}`);", + replace: " if (false && run.conclusion !== 'success')\n return result('failed', 'mutant'); // mutant: completed means green", }], }, { @@ -8847,13 +8880,13 @@ const MUTANT_DEFINITIONS = [ }, { id: 'review-returns-task-on-cancelled-dispatch', - guard: 'node --test test/validate-gate.test.mjs', + guard: 'node --test test/ci-proof.test.mjs test/validate-gate.test.mjs', because: 'a dispatch cancelled by its replacement in the same concurrency group proves nothing; ' + 'reading it as red sends the task back to S6 for no reason (#510 review r1 M1, #511)', patches: [{ - file: 'scripts/validate-gate.mjs', - find: " if (run.conclusion === 'cancelled') {", - replace: " if (false) { // mutant: cancelled counts as red", + file: 'scripts/ci-proof.mjs', + find: " if (run.conclusion === 'cancelled') return result('cancelled', `Validate run ${runIdOf(run)} was cancelled`);", + replace: " if (false && run.conclusion === 'cancelled') return result('cancelled', 'mutant');", }], }, { @@ -8868,14 +8901,14 @@ const MUTANT_DEFINITIONS = [ }], }, { - id: 'merge-trusts-cancelled-dispatch', + id: 'merge-trusts-success-without-proof', guard: 'node --test test/merge-candidate.test.mjs', - because: 'the real waitValidate must skip a dispatch cancelled by its replacement; reading it as red ' - + 'fails the merge candidate for nothing (#510 review r2 M1, #511)', + because: 'a workflow conclusion does not prove that mutant jobs ran; merge must require the shared ' + + 'artifact instead of accepting a successful dispatch on the candidate SHA (#541)', patches: [{ - file: 'scripts/merge-candidate.mjs', - find: " const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');", - replace: " const runs = all.filter((x) => (!event || x.event === event)); // mutant: cancelled is red", + file: 'scripts/ci-proof.mjs', + find: " if (!proof) return result('missing', `Validate run ${runIdOf(run)} has no proof artifact`);", + replace: " if (!proof) return result('green', 'mutant: conclusion alone');", }], }, { diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index c86d3a64..42ae420f 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -3,6 +3,10 @@ // performance workflow. import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, + loadGithubProofContext, selectCiProofVerdict, +} from './ci-proof.mjs'; /** * The verdict is the LATEST run that was not cancelled (#511). A cancelled run @@ -26,6 +30,39 @@ export function classifyValidateRuns(runs) { return latest.conclusion === 'success' ? 'success' : 'fail'; } +const newestFirst = (runs) => [...(Array.isArray(runs) ? runs : [])].sort((a, b) => { + const stamp = (run) => Date.parse(run?.run_started_at || run?.startedAt || run?.created_at || run?.createdAt || 0) || 0; + return stamp(b) - stamp(a) || Number(b?.id || b?.databaseId || 0) - Number(a?.id || a?.databaseId || 0); +}); + +/** #541: proof-aware verdict shared with review and merge. */ +export async function classifyValidateProofs({ + runs, repo, sha, tree, token, fetchImpl = fetch, + loadContext = (run) => loadGithubProofContext({ repo, run, token, fetchImpl }), +}) { + const evaluations = []; + for (const run of newestFirst(runs)) { + if (run?.status !== 'completed' || run?.conclusion === 'cancelled') { + evaluations.push(evaluateCiProof({ run, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.release })); + } else { + try { + const context = await loadContext(run); + evaluations.push(evaluateCiProof({ + run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.release, + })); + } catch (error) { + evaluations.push({ + status: 'missing', url: run.html_url || run.url || null, + note: `proof could not be loaded: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + const current = evaluations.at(-1); + if (current.status !== 'cancelled' && current.status !== 'stale') break; + } + return selectCiProofVerdict(evaluations); +} + export const workflowRunsUrl = ({ repo, workflow, sha }) => ( `https://api.github.com/repos/${repo}/actions/workflows/${encodeURIComponent(workflow)}` + `/runs?head_sha=${encodeURIComponent(sha)}&per_page=100` @@ -39,6 +76,8 @@ export async function waitForGreenWorkflow({ if (!repo || !sha || !token || !workflow) throw new Error('repo, sha, token and workflow are required'); const deadline = Date.now() + timeoutMs; const url = workflowRunsUrl({ repo, workflow, sha }); + const proofRequired = workflow === 'validate.yml'; + const tree = proofRequired ? await githubCandidateTree({ repo, sha, token }) : null; while (true) { const response = await fetch(url, { headers: { @@ -51,21 +90,22 @@ export async function waitForGreenWorkflow({ if (!response.ok) throw new Error(`GitHub Actions API ${response.status}: ${await response.text()}`); const body = await response.json(); const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : []; - const state = classifyValidateRuns(runs); const latest = latestRelevantRun(runs); - if (state === 'fail') { - throw new Error(`${label} is not green for ${sha}: latest run ${JSON.stringify({ - conclusion: latest.conclusion, url: latest.html_url, - })}`); + const verdict = proofRequired + ? await classifyValidateProofs({ runs, repo, sha, tree, token }) + : { status: classifyValidateRuns(runs) === 'success' ? 'green' + : classifyValidateRuns(runs) === 'fail' ? 'failed' : 'pending', url: latest?.html_url, note: '' }; + if (verdict.status === 'failed') { + throw new Error(`${label} is not green for ${sha}: ${verdict.note}${verdict.url ? ` (${verdict.url})` : ''}`); } - if (state === 'success') { - console.log(`${label} is green for ${sha}: latest run ${latest.html_url || latest.id} (${runs.length} run(s) on the SHA)`); + if (verdict.status === 'green') { + console.log(`${label} proof is green for ${sha}: ${verdict.url || latest?.html_url || latest?.id} (${runs.length} run(s) on the SHA)`); return; } - if (Date.now() >= deadline) throw new Error(`No completed green ${label} for ${sha} within the deadline`); + if (Date.now() >= deadline) throw new Error(`No complete ${label} proof for ${sha} within the deadline: ${verdict.status} (${verdict.note})`); const running = runs.filter((run) => run?.status !== 'completed').length; console.log(runs.length - ? `waiting: ${running} ${label} run(s) still going` + ? `waiting: ${label} proof is ${verdict.status}; ${running} run(s) still going (${verdict.note})` : `waiting: no ${label} run for ${sha} yet`); await sleep(30_000); } diff --git a/scripts/release-prerelease.mjs b/scripts/release-prerelease.mjs index 9d35f5b7..c19b53fb 100644 --- a/scripts/release-prerelease.mjs +++ b/scripts/release-prerelease.mjs @@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; import { assertReleaseContract } from './release-contract.mjs'; -import { classifyValidateRuns } from './release-gate.mjs'; +import { classifyValidateProofs } from './release-gate.mjs'; import { assertBundleManifest } from './bundle-tree.mjs'; import { SUMS_FILE, compareSums, formatSums, parseSums, sumsOfDirectory } from './release-assets.mjs'; @@ -392,18 +392,16 @@ if (invokedDirectly) { } }; - const assertGreenValidate = (sha) => { + const assertGreenValidate = async (sha) => { const runs = ghJson([ 'run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', sha, - '--limit', '100', '--json', 'databaseId,status,conclusion,url,headSha', + '--limit', '100', '--json', 'databaseId,status,conclusion,url,headSha,event,attempt,startedAt,createdAt', ]); - const state = classifyValidateRuns(runs); - if (state !== 'success') { - throw new Error( - state === 'wait' - ? `Exact-SHA Validate has not completed successfully for ${sha}` - : `Exact-SHA Validate contains a failed/cancelled run for ${sha}`, - ); + const tree = run('git', ['rev-parse', `${sha}^{tree}`]).stdout; + const token = run('gh', ['auth', 'token']).stdout; + const verdict = await classifyValidateProofs({ runs, repo, sha, tree, token }); + if (verdict.status !== 'green') { + throw new Error(`Exact-SHA Validate proof is ${verdict.status} for ${sha}: ${verdict.note}`); } return runs; }; @@ -487,7 +485,7 @@ if (invokedDirectly) { if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`); const bundleSnapshot = assertBundleSnapshots(sha); const bundleSha256 = bundleSnapshot.entrySha256; - const validateRuns = assertGreenValidate(sha); + const validateRuns = await assertGreenValidate(sha); validateIssues(); const existingTag = remoteTag(); if (existingTag.exists && existingTag.commit !== sha) diff --git a/scripts/validate-gate.mjs b/scripts/validate-gate.mjs index 3e0e871d..a4c619b4 100755 --- a/scripts/validate-gate.mjs +++ b/scripts/validate-gate.mjs @@ -9,7 +9,7 @@ * * node scripts/validate-gate.mjs --repo= --ref=<ветка> --sha= [--workflow=validate.yml] * - * Печатает `result=green|red|missing` и `url=…` (и в $GITHUB_OUTPUT, если он + * Печатает `result=green|failed|missing` и `url=…` (и в $GITHUB_OUTPUT, если он * задан); код выхода 0 только при green. Логика — чистая функция `validateGate` * поверх инъектируемых `ops`, чтобы тесты и мутанты гоняли её без gh. */ @@ -18,6 +18,9 @@ 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 { + CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, loadGithubProofContext, +} from './ci-proof.mjs'; export const POLL_MS = 20_000; /** @@ -54,12 +57,13 @@ export function provesMutants(jobs) { * @param {object} p * @param {string} p.ref ветка, на которой запускать * @param {string} p.sha SHA материала - * @param {object} p.ops { listRuns(sha) → [{databaseId,status,conclusion,url,event,headSha}], listRunsOnRef(ref) → те же, jobs(runId) → [{name,conclusion}], dispatch(ref), sleep(ms), now() } - * @returns {Promise<{result:'green'|'red'|'missing', url:string|null, note:string}>} + * @param {object} p.ops GitHub run/proof operations plus dispatch, sleep and clock. + * @returns {Promise<{result:'green'|'failed'|'missing', url:string|null, note:string}>} */ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) { const started = ops.now(); - const ignored = new Set(); // завершённые dispatch, которые ничего не доказывают: отменённые и зелёные без мутантов + const candidateTree = await ops.candidateTree(sha); + const ignored = new Set(); // завершённые dispatch без применимого proof let tracked = null; let dispatchedAt = null; let attempts = 0; @@ -69,17 +73,13 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M if (run) { tracked = run.databaseId; if (run.status === 'completed') { - if (run.conclusion === 'cancelled') { - // Отменённый прогон ничего не доказывает (#511, ревью r1 M1): его - // заменил другой dispatch в той же concurrency-группе — ждём его, - // а если замены нет, запускаем свой. - ignored.add(run.databaseId); - tracked = null; - continue; - } - if (run.conclusion !== 'success') return { result: 'red', url: run.url, note: `dispatch-прогон завершился: ${run.conclusion}` }; - if (provesMutants(await ops.jobs(run.databaseId))) return { result: 'green', url: run.url, note: 'dispatch-прогон с исполненными мутантами зелёный' }; - // зелёный, но мутанты не исполнялись (чужой dispatch без mutants=true) — не доказательство + const context = await ops.proof(run); + const verdict = evaluateCiProof({ + run, ...context, candidate: { sha, tree: candidateTree }, policy: CI_PROOF_POLICIES.review, + }); + if (verdict.status === 'green') return { result: 'green', url: verdict.url, note: verdict.note }; + if (verdict.status === 'failed') return { result: 'failed', url: verdict.url, note: verdict.note }; + // cancelled, light, stale или legacy run без proof — не доказательство. ignored.add(run.databaseId); tracked = null; continue; @@ -112,13 +112,13 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M } await ops.sleep(pollMs); } - return { result: 'red', url: null, note: 'Validate с мутантами не завершился за 45 минут' }; + return { result: 'failed', url: null, note: 'Validate с мутантами не завершился за 45 минут' }; } const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' }); -export function realOps({ repo, workflow = 'validate.yml' }) { - const fields = 'databaseId,status,conclusion,url,event,headSha'; +export function realOps({ repo, workflow = 'validate.yml', token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN }) { + const fields = 'databaseId,status,conclusion,url,event,headSha,attempt,startedAt,createdAt'; const parse = (r) => (r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []); return { listRuns: async (sha) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', fields, '--limit', '20'])), @@ -126,6 +126,11 @@ export function realOps({ repo, workflow = 'validate.yml' }) { const r = sh('gh', ['run', 'view', String(runId), '--repo', repo, '--json', 'jobs']); return r.status === 0 && r.stdout ? (JSON.parse(r.stdout).jobs || []).map((job) => ({ name: job.name, conclusion: job.conclusion })) : []; }, + candidateTree: (sha) => githubCandidateTree({ repo, sha, token }), + proof: async (run) => { + try { return await loadGithubProofContext({ repo, run, token }); } + catch { return { proof: null, jobs: [], reuseRuns: new Map() }; } + }, listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])), dispatch: async (ref) => { const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']); diff --git a/test/check-inputs.test.mjs b/test/check-inputs.test.mjs index 76074238..d371b07e 100755 --- a/test/check-inputs.test.mjs +++ b/test/check-inputs.test.mjs @@ -140,7 +140,7 @@ test('§8.1 представители: каждая категория кажд 'test/fixtures/real-plan-first-floor.json'], config: ['pyproject.toml', 'pytest.ini', 'scripts/backend-coverage-baseline.txt'], toolchain: ['tests_backend/requirements.txt', 'custom_components/houseplan/manifest.json', '.github/workflows/validate.yml'], - protocol: ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'], + protocol: ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs', 'scripts/ci-proof.mjs'], }, smoke: { source: ['src/houseplan-card.ts', 'src/logic.ts'], @@ -149,7 +149,8 @@ test('§8.1 представители: каждая категория кажд config: ['rollup.config.mjs', 'tsconfig.json'], toolchain: ['package.json', 'package-lock.json', '.github/workflows/validate.yml'], protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs', - 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'scripts/smoke-select.mjs'], + 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'scripts/smoke-select.mjs', + 'scripts/ci-proof.mjs'], }, golden: { source: ['src/houseplan-card.ts'], @@ -158,7 +159,8 @@ test('§8.1 представители: каждая категория кажд 'demo/fixtures/visual-matrix.mjs'], config: ['rollup.config.mjs', 'tsconfig.json'], toolchain: ['package.json', '.github/workflows/validate.yml'], - protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs', 'demo/editor-runtime-compat.mjs'], + protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-freshness.mjs', + 'demo/editor-runtime-compat.mjs', 'scripts/ci-proof.mjs'], }, performance_smoke: { source: ['src/houseplan-card.ts'], @@ -167,7 +169,8 @@ test('§8.1 представители: каждая категория кажд 'demo/performance/budgets-isometric-smoke.json', 'demo/performance/budgets-interaction-smoke.json'], config: ['rollup.config.mjs'], toolchain: ['package.json', '.github/workflows/validate.yml'], - protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/editor-runtime-compat.mjs', 'demo/performance/evaluate.mjs'], + protocol: ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/editor-runtime-compat.mjs', + 'demo/performance/evaluate.mjs', 'scripts/ci-proof.mjs'], }, }; for (const [job, categories] of Object.entries(expect)) { diff --git a/test/ci-proof.test.mjs b/test/ci-proof.test.mjs new file mode 100644 index 00000000..a71d13b0 --- /dev/null +++ b/test/ci-proof.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { deflateRawSync } from 'node:zlib'; + +import { + CI_PROOF_POLICIES, buildCiProof, evaluateCiProof, parseReuseMarker, readCiProofArtifact, + requiredCheckIds, selectCiProofVerdict, +} from '../scripts/ci-proof.mjs'; + +export const SHA = 'a'.repeat(40); +export const TREE = 'b'.repeat(40); + +const names = { + preflight: 'Предполётные проверки: документация, провенанс, процесс', + changes: 'Классификация изменённых файлов', + reuse: 'Переиспользование: это дерево уже проверено', + frontend: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', + hacs: 'HACS: валидация репозитория', + hassfest: 'Hassfest: манифест интеграции', + smokeDone: 'Смоки: все шарды зелёные', + golden: 'Golden-кадры против принятых эталонов', + performance: 'Перф-смок: бюджет времени кадра', + backend: 'Бэкенд: pytest в Home Assistant', +}; + +const success = (name) => ({ name, conclusion: 'success' }); +const mutantJobs = () => Array.from({ length: 6 }, (_, index) => ( + success(`Мутанты по диффу (${index + 1}/6): затронутые свидетели краснеют`) +)); +const smokeJobs = () => Array.from({ length: 3 }, (_, index) => ( + success(`Смоки в браузере (шард ${index + 1} из 3)`) +)); + +export function proofFixture({ + id = 10, attempt = 2, full = true, mutants = true, + frontend = true, backend = true, integration = true, conclusion = 'success', +} = {}) { + const needs = { + preflight: { result: 'success' }, + changes: { + result: 'success', + outputs: { + heavy: String(full), mutants_requested: String(mutants), + frontend: String(frontend), backend: String(backend), integration: String(integration), + }, + }, + reuse: { result: 'success', outputs: {} }, + frontend: { result: frontend ? 'success' : 'skipped' }, + hacs: { result: integration ? 'success' : 'skipped' }, + hassfest: { result: integration ? 'success' : 'skipped' }, + changed_mutants: { result: mutants ? 'success' : 'skipped' }, + smoke: { result: full ? 'success' : 'skipped' }, + smoke_done: { result: full ? 'success' : 'skipped' }, + golden: { result: full ? 'success' : 'skipped' }, + performance_smoke: { result: full ? 'success' : 'skipped' }, + backend: { result: backend ? 'success' : 'skipped' }, + }; + const proof = buildCiProof({ + candidateSha: SHA, candidateTree: TREE, runId: id, attempt, + event: 'workflow_dispatch', needs, + }); + const jobs = [success(names.preflight), success(names.changes), success(names.reuse)]; + if (frontend) jobs.push(success(names.frontend)); + if (integration) jobs.push(success(names.hacs), success(names.hassfest)); + if (mutants) jobs.push(...mutantJobs()); + if (full) jobs.push(...smokeJobs(), success(names.smokeDone), success(names.golden), success(names.performance)); + if (backend) jobs.push(success(names.backend)); + const run = { + databaseId: id, attempt, status: 'completed', conclusion, + event: 'workflow_dispatch', headSha: SHA, url: `https://run/${id}`, + }; + return { run, proof, jobs, reuseRuns: new Map(), candidate: { sha: SHA, tree: TREE } }; +} + +test('#541: proof records candidate identity, request and exact required check set', () => { + const fixture = proofFixture(); + assert.deepEqual(fixture.proof.candidate, { sha: SHA, tree: TREE }); + assert.deepEqual(fixture.proof.run, { + id: 10, attempt: 2, workflow: 'validate.yml', event: 'workflow_dispatch', + }); + assert.deepEqual(fixture.proof.requiredChecks, [ + 'preflight', 'changes', 'reuse', 'frontend', 'integration', 'mutants', + 'smoke', 'golden', 'performance_smoke', 'backend', + ]); + assert.deepEqual(fixture.proof.requiredChecks, requiredCheckIds(fixture.proof)); +}); + +test('#541 AC: one state machine gives review, merge and release the same terminal semantics', () => { + const full = proofFixture(); + for (const policy of Object.values(CI_PROOF_POLICIES)) { + assert.equal(evaluateCiProof({ ...full, policy }).status, 'green', policy.name); + assert.equal(evaluateCiProof({ ...full, run: null, policy }).status, 'missing', policy.name); + assert.equal(evaluateCiProof({ ...full, run: { ...full.run, status: 'in_progress' }, policy }).status, 'pending', policy.name); + assert.equal(evaluateCiProof({ ...full, run: { ...full.run, conclusion: 'cancelled' }, policy }).status, 'cancelled', policy.name); + assert.equal(evaluateCiProof({ ...full, run: { ...full.run, conclusion: 'failure' }, policy }).status, 'failed', policy.name); + assert.equal(evaluateCiProof({ ...full, candidate: { sha: SHA, tree: 'c'.repeat(40) }, policy }).status, 'stale', policy.name); + } +}); + +test('#541 AC: full red followed by light green still blocks release; a later full green refreshes it', () => { + 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 }); + const light = evaluateCiProof({ ...lightGreen, policy: CI_PROOF_POLICIES.release }); + assert.equal(light.status, 'stale'); + 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'); +}); + +test('#541 AC: green dispatch without six executed mutant jobs proves neither review nor merge', () => { + const fixture = proofFixture({ full: false, backend: false, integration: false }); + fixture.jobs = fixture.jobs.filter((job) => !job.name.startsWith('Мутанты по диффу')); + for (const policy of [CI_PROOF_POLICIES.review, CI_PROOF_POLICIES.merge]) { + const verdict = evaluateCiProof({ ...fixture, policy }); + assert.equal(verdict.status, 'failed', policy.name); + assert.match(verdict.note, /mutants: claimed execution/); + } +}); + +test('#541 AC: SHA, tree, run attempt, event and proof inventories cannot drift', () => { + const fixture = proofFixture(); + assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, attempt: 3 }, policy: CI_PROOF_POLICIES.release }).status, 'stale'); + assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, headSha: 'd'.repeat(40) }, policy: CI_PROOF_POLICIES.release }).status, 'stale'); + assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, event: 'push' }, policy: CI_PROOF_POLICIES.release }).status, 'stale'); + const forged = structuredClone(fixture.proof); + forged.executedChecks = forged.executedChecks.filter((id) => id !== 'backend'); + assert.equal(evaluateCiProof({ ...fixture, proof: forged, policy: CI_PROOF_POLICIES.release }).status, 'failed'); +}); + +test('#541 AC: reuse needs a content key, marker source SHA/run and the successful source job', () => { + const fixture = proofFixture(); + fixture.proof.checks.golden = { + mode: 'reused', result: 'success', + reuse: { key: 'e'.repeat(64), sourceRun: 77, sourceAttempt: 3, sourceSha: 'f'.repeat(40) }, + }; + fixture.proof.executedChecks = fixture.proof.executedChecks.filter((id) => id !== 'golden'); + fixture.proof.reusedChecks = ['golden']; + fixture.jobs = fixture.jobs.filter((job) => job.name !== names.golden); + fixture.reuseRuns.set('77:3', { + run: { id: 77, run_attempt: 3, status: 'completed', conclusion: 'failure', head_sha: 'f'.repeat(40) }, + jobs: [success(names.golden)], + }); + assert.equal(evaluateCiProof({ ...fixture, policy: CI_PROOF_POLICIES.release }).status, 'green', + 'individual green job remains lawful even when an unrelated source job made its run red'); + const noSource = new Map(); + assert.equal(evaluateCiProof({ ...fixture, reuseRuns: noSource, policy: CI_PROOF_POLICIES.release }).status, 'failed'); + const badKey = structuredClone(fixture.proof); + badKey.checks.golden.reuse.key = 'short'; + assert.equal(evaluateCiProof({ ...fixture, proof: badKey, policy: CI_PROOF_POLICIES.release }).status, 'failed'); +}); + +test('#541: reuse marker parser fails closed', () => { + assert.deepEqual(parseReuseMarker( + `golden прогнана успешно\nSHA: ${SHA}\nпрогон: https://github.com/x/y/actions/runs/123\nпопытка: 4\n`, + ), { sourceSha: SHA, sourceRun: 123, sourceAttempt: 4 }); + assert.throws(() => parseReuseMarker('SHA: short\nпрогон: https://github.com/x/y/actions/runs/123\nпопытка: 1\n'), /must contain/); + assert.throws(() => parseReuseMarker(`SHA: ${SHA}\n`), /must contain/); +}); + +test('#541: uploaded deflated artifact is read without an external ZIP dependency', () => { + const body = Buffer.from(JSON.stringify({ schema: 'test', ok: true })); + const compressed = deflateRawSync(body); + const name = Buffer.from('nested/proof.json'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(8, 8); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(body.length, 22); + local.writeUInt16LE(name.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(8, 10); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(body.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(0, 42); + const directoryAt = local.length + name.length + compressed.length; + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(central.length + name.length, 12); + eocd.writeUInt32LE(directoryAt, 16); + const zip = Buffer.concat([local, name, compressed, central, name, eocd]); + assert.deepEqual(readCiProofArtifact(zip), { schema: 'test', ok: true }); +}); diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index 008e2861..75f1d01d 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -6,6 +6,31 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { MAX_ATTEMPTS, commentFor, decideMerge, mergeCandidate, realOps } from '../scripts/merge-candidate.mjs'; +import { buildCiProof } from '../scripts/ci-proof.mjs'; + +const mergeProofContext = (row, sha, tree) => { + const proof = buildCiProof({ + candidateSha: sha, candidateTree: tree, runId: row.databaseId, + attempt: row.attempt ?? 1, event: row.event, + needs: { + preflight: { result: 'success' }, + changes: { result: 'success', outputs: { + heavy: 'false', mutants_requested: 'true', frontend: 'true', + backend: 'false', integration: 'false', + } }, + reuse: { result: 'success', outputs: {} }, frontend: { result: 'success' }, + changed_mutants: { result: 'success' }, + }, + }); + const success = (name) => ({ name, conclusion: 'success' }); + return { proof, reuseRuns: new Map(), jobs: [ + success('Предполётные проверки: документация, провенанс, процесс'), + success('Классификация изменённых файлов'), + success('Переиспользование: это дерево уже проверено'), + success('Фронтенд: типы, юниты, мутанты, синхрон бандла'), + ...Array.from({ length: 6 }, (_, i) => success(`Мутанты по диффу (${i + 1}/6): затронутые свидетели краснеют`)), + ] }; +}; // #492 §4 / §8.4: слияние точного кандидата. Таблица решений — на чистой // функции; последовательность операций — на фальшивых git/gh; эксперимент @@ -21,13 +46,13 @@ test('§8.4 таблица решений decideMerge', () => { assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { action: 'rereview', to: 'S7-code-review' }); assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: null }), { action: 'validate' }); assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'missing' }), { action: 'validation-missing', to: 'S6-in-progress' }); - assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'red' }), { action: 'validation-red', to: 'S6-in-progress' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'failed' }), { action: 'validation-red', to: 'S6-in-progress' }); assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green' }), { action: 'push', to: 'S8-merged' }); assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 1 }), { action: 'retry' }); assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 2 }), { action: 'retry' }); assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: MAX_ATTEMPTS }), { action: 'give-up', to: 'S6-in-progress' }); // ни один исход не ведёт в S8 без зелёного Validate при движении dev - for (const validate of [null, 'missing', 'red']) { + for (const validate of [null, 'missing', 'failed', 'pending', 'cancelled', 'stale']) { assert.notEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate }).to, 'S8-merged', String(validate)); } assert.equal(MAX_ATTEMPTS, 3); @@ -119,7 +144,7 @@ test('эксперимент аудита: dev двигался, ребейз ч }); test('красный Validate на кандидате — S6, без push в dev', async () => { - const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['red'] }); + const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['failed'] }); const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); assert.equal(r.action, 'validation-red'); assert.equal(r.to, 'S6-in-progress'); @@ -246,26 +271,61 @@ function scriptedExec(snapshots) { } test('#510 r2 M1: realOps.waitValidate ignores a cancelled dispatch and follows its replacement', async () => { - const cancelled = { databaseId: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch' }; - const push = { databaseId: 2, status: 'completed', conclusion: 'success', url: 'https://run/2', event: 'push' }; - const replacement = { databaseId: 3, status: 'completed', conclusion: 'success', url: 'https://run/3', event: 'workflow_dispatch' }; + const sha = 'c'.repeat(40); + const tree = 'd'.repeat(40); + const cancelled = { databaseId: 1, attempt: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch', headSha: sha }; + const push = { databaseId: 2, attempt: 1, status: 'completed', conclusion: 'success', url: 'https://run/2', event: 'push', headSha: sha }; + const replacement = { databaseId: 3, attempt: 1, status: 'completed', conclusion: 'success', url: 'https://run/3', event: 'workflow_dispatch', headSha: sha }; const gh = scriptedExec([[cancelled, push], [cancelled, push], [replacement, cancelled, push]]); let clock = 0; - const ops = realOps({ repo: 'x/y', token: 'none', exec: gh.exec, sleep: async (ms) => { clock += ms; }, now: () => clock }); - const r = await ops.waitValidate('c'.repeat(40), { event: 'workflow_dispatch' }); - assert.deepEqual(r, { result: 'green', url: 'https://run/3' }); + const ops = realOps({ + repo: 'x/y', token: 'none', exec: gh.exec, + sleep: async (ms) => { clock += ms; }, now: () => clock, + candidateTree: async () => tree, + proofContext: async (row) => row.databaseId === 3 + ? mergeProofContext(row, sha, tree) : { proof: null, jobs: [], reuseRuns: new Map() }, + }); + const r = await ops.waitValidate(sha, { event: 'workflow_dispatch' }); + assert.equal(r.result, 'green'); + assert.equal(r.url, 'https://run/3'); assert.equal(gh.calls(), 3, 'kept polling past the cancelled run instead of returning red on the first answer'); }); test('#510 r2 M1: realOps.waitValidate with only a cancelled dispatch reports missing after the appear window, never red', async () => { - const cancelled = { databaseId: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch' }; + const sha = 'c'.repeat(40); + const tree = 'd'.repeat(40); + const cancelled = { databaseId: 1, attempt: 1, status: 'completed', conclusion: 'cancelled', url: 'https://run/1', event: 'workflow_dispatch', headSha: sha }; const gh = scriptedExec([[cancelled]]); let clock = 0; - const ops = realOps({ repo: 'x/y', token: 'none', exec: gh.exec, sleep: async (ms) => { clock += ms; }, now: () => clock }); - const r = await ops.waitValidate('c'.repeat(40), { event: 'workflow_dispatch' }); + const ops = realOps({ + repo: 'x/y', token: 'none', exec: gh.exec, + sleep: async (ms) => { clock += ms; }, now: () => clock, + candidateTree: async () => tree, + proofContext: async () => ({ proof: null, jobs: [], reuseRuns: new Map() }), + }); + const r = await ops.waitValidate(sha, { event: 'workflow_dispatch' }); assert.equal(r.result, 'missing'); }); +test('#541: real merge waiter never accepts a successful dispatch without its proof artifact', async () => { + const sha = 'c'.repeat(40); + const tree = 'd'.repeat(40); + const unproved = { + databaseId: 4, attempt: 1, status: 'completed', conclusion: 'success', + url: 'https://run/4', event: 'workflow_dispatch', headSha: sha, + }; + const gh = scriptedExec([[unproved]]); + let clock = 0; + const ops = realOps({ + repo: 'x/y', token: 'none', exec: gh.exec, + sleep: async (ms) => { clock += ms; }, now: () => clock, + candidateTree: async () => tree, + proofContext: async () => ({ proof: null, jobs: [], reuseRuns: new Map() }), + }); + const result = await ops.waitValidate(sha, { event: 'workflow_dispatch' }); + assert.equal(result.result, 'missing'); +}); + // ---------- #516: the candidate carries its own review document; dev moves by other documents ---------- test('#516 AC1: dev moved only by review documents and the branch carries its own — patch-id equal, merge goes through Validate, not re-review', async () => { diff --git a/test/release-gate.test.mjs b/test/release-gate.test.mjs index 3714cba1..126c4637 100644 --- a/test/release-gate.test.mjs +++ b/test/release-gate.test.mjs @@ -1,7 +1,45 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { classifyValidateRuns, latestRelevantRun, workflowRunsUrl } from '../scripts/release-gate.mjs'; +import { + classifyValidateProofs, classifyValidateRuns, latestRelevantRun, workflowRunsUrl, +} from '../scripts/release-gate.mjs'; +import { buildCiProof } from '../scripts/ci-proof.mjs'; + +const SHA = 'a'.repeat(40); +const TREE = 'b'.repeat(40); +const greenJob = (name) => ({ name, conclusion: 'success' }); +const proofContext = ({ id, full = true, conclusion = 'success' }) => { + const needs = { + preflight: { result: 'success' }, changes: { result: 'success', outputs: { + heavy: String(full), mutants_requested: 'true', frontend: 'true', + backend: String(full), integration: String(full), + } }, + reuse: { result: 'success', outputs: {} }, frontend: { result: 'success' }, + changed_mutants: { result: 'success' }, hacs: { result: full ? 'success' : 'skipped' }, + hassfest: { result: full ? 'success' : 'skipped' }, smoke: { result: full ? 'success' : 'skipped' }, + smoke_done: { result: full ? 'success' : 'skipped' }, golden: { result: full ? 'success' : 'skipped' }, + performance_smoke: { result: full ? 'success' : 'skipped' }, backend: { result: full ? 'success' : 'skipped' }, + }; + const proof = buildCiProof({ candidateSha: SHA, candidateTree: TREE, runId: id, attempt: 1, event: 'workflow_dispatch', needs }); + const jobs = [ + greenJob('Предполётные проверки: документация, провенанс, процесс'), + greenJob('Классификация изменённых файлов'), greenJob('Переиспользование: это дерево уже проверено'), + greenJob('Фронтенд: типы, юниты, мутанты, синхрон бандла'), + ...Array.from({ length: 6 }, (_, i) => greenJob(`Мутанты по диффу (${i + 1}/6): затронутые свидетели краснеют`)), + ]; + if (full) jobs.push( + greenJob('HACS: валидация репозитория'), greenJob('Hassfest: манифест интеграции'), + ...Array.from({ length: 3 }, (_, i) => greenJob(`Смоки в браузере (шард ${i + 1} из 3)`)), + greenJob('Смоки: все шарды зелёные'), greenJob('Golden-кадры против принятых эталонов'), + greenJob('Перф-смок: бюджет времени кадра'), greenJob('Бэкенд: pytest в Home Assistant'), + ); + const run = { + databaseId: id, attempt: 1, status: 'completed', conclusion, event: 'workflow_dispatch', + headSha: SHA, url: `https://run/${id}`, startedAt: `2026-09-13T10:${id}:00Z`, + }; + return { run, context: { proof, jobs, reuseRuns: new Map() } }; +}; test('release gate waits until an exact-SHA Validate exists and completes', () => { assert.equal(classifyValidateRuns([]), 'wait'); @@ -47,6 +85,30 @@ test('#511: the latest non-cancelled run is the verdict; cancelled runs prove no assert.equal(latestRelevantRun([]), null); }); +test('#541: release skips a newer light proof but does not let it hide an older full failure', async () => { + const older = proofContext({ id: 10, conclusion: 'failure' }); + const newer = proofContext({ id: 11, full: false }); + 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, 'failed'); + 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 }); + const contexts = new Map([[10, older.context], [12, 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/12'); +}); + test('release gate can target the dedicated exact-SHA performance workflow', () => { assert.equal( workflowRunsUrl({ repo: 'Matysh/houseplan-card', workflow: 'performance.yml', sha: 'abc/123' }), @@ -54,10 +116,11 @@ test('release gate can target the dedicated exact-SHA performance workflow', () ); }); -test('#511 AC3: the release documents describe the latest-run semantics', () => { +test('#541: the release documents describe proof semantics', () => { const development = readFileSync(new URL('../docs/DEVELOPMENT.md', import.meta.url), 'utf8'); - assert.match(development, /latest non-cancelled Validate run of the\nSHA/); - assert.doesNotMatch(development, /A missing, failed,\ncancelled or one-hour-timed-out Validate withholds/); + 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, /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-gate.test.mjs b/test/validate-gate.test.mjs index a937616e..cad05dd6 100755 --- a/test/validate-gate.test.mjs +++ b/test/validate-gate.test.mjs @@ -3,12 +3,19 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs'; +import { buildCiProof } from '../scripts/ci-proof.mjs'; const SHA = 'a'.repeat(40); +const TREE = 'b'.repeat(40); /** Fake gh: a scripted list of run snapshots per call, a virtual clock. */ const MUTANT_JOBS = [1, 2, 3, 4, 5, 6].map((n) => ({ name: `Мутанты по диффу (${n}/6): затронутые свидетели краснеют`, conclusion: 'success' })); const OTHER_JOBS = [{ name: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', conclusion: 'success' }]; +const BASE_JOBS = [ + { name: 'Предполётные проверки: документация, провенанс, процесс', conclusion: 'success' }, + { name: 'Классификация изменённых файлов', conclusion: 'success' }, + { name: 'Переиспользование: это дерево уже проверено', conclusion: 'success' }, +]; function fakeOps({ snapshots, onRef = [], jobsById = {} }) { let clock = 0; @@ -19,6 +26,26 @@ function fakeOps({ snapshots, onRef = [], jobsById = {} }) { listRuns: async () => { const s = snapshots[Math.min(calls, snapshots.length - 1)]; calls += 1; return s; }, listRunsOnRef: async () => onRef, jobs: async (id) => jobsById[id] ?? [...OTHER_JOBS, ...MUTANT_JOBS], + candidateTree: async () => TREE, + proof: async (row) => { + const selected = jobsById[row.databaseId] ?? [...OTHER_JOBS, ...MUTANT_JOBS]; + const mutants = provesMutants(selected); + const proof = buildCiProof({ + candidateSha: SHA, candidateTree: TREE, runId: row.databaseId, + attempt: row.attempt ?? 1, event: row.event, + needs: { + preflight: { result: 'success' }, + changes: { result: 'success', outputs: { + heavy: 'false', mutants_requested: String(mutants), + frontend: 'true', backend: 'false', integration: 'false', + } }, + reuse: { result: 'success', outputs: {} }, + frontend: { result: 'success' }, + changed_mutants: { result: mutants ? 'success' : 'skipped' }, + }, + }); + return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() }; + }, dispatch: async (ref) => { dispatched.push(ref); }, sleep: async (ms) => { clock += ms; }, now: () => clock, @@ -67,7 +94,7 @@ test('#510 AC2: a completed green dispatch run on the material is accepted witho test('#510 AC2: a completed red dispatch run returns the task without review', async () => { const fake = fakeOps({ snapshots: [[run({ conclusion: 'failure', url: 'https://run/red' })]] }); const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops }); - assert.equal(outcome.result, 'red'); + assert.equal(outcome.result, 'failed'); assert.equal(outcome.url, 'https://run/red'); }); @@ -112,7 +139,7 @@ test('#510 AC2: a dispatch that never finishes is red after the total window', a const running = [run({ status: 'in_progress', conclusion: null })]; const fake = fakeOps({ snapshots: [running] }); const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, totalMs: 10_000, pollMs: 4000 }); - assert.equal(outcome.result, 'red'); + assert.equal(outcome.result, 'failed'); assert.match(outcome.note, /не завершился/); assert.deepEqual(fake.dispatched, []); }); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 8d17fbbf..7e5bcaa0 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -453,3 +453,33 @@ test('журнал свидетелей changed_mutants: rerun продолжа assert.match(save, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); assert.ok(job.indexOf('name: Сохранить журнал свидетелей') > job.indexOf('--ledger='), 'save идёт после шага прогона'); }); + +test('#541: Validate всегда публикует proof точной попытки, а reuse раскрывает источник', () => { + const workflow = read('validate.yml'); + const proofAt = workflow.indexOf('\n proof:\n'); + assert.ok(proofAt > 0, 'финальная proof job существует'); + const proof = workflow.slice(proofAt); + assert.match(proof, /if: always\(\)/, 'proof создаётся и на красном прогоне'); + for (const dependency of [ + 'preflight', 'changes', 'reuse', 'hacs', 'hassfest', 'changed_mutants', + 'frontend', 'smoke', 'smoke_done', 'golden', 'performance_smoke', 'backend', + ]) assert.match(proof, new RegExp(`needs: \\[[^\\n]*\\b${dependency}\\b`), dependency); + assert.match(proof, /CANDIDATE_SHA: \$\{\{ github\.sha \}\}/); + assert.match(proof, /CANDIDATE_TREE: \$\{\{ steps\.candidate\.outputs\.tree \}\}/); + assert.match(proof, /CI_RUN_ID: \$\{\{ github\.run_id \}\}/); + assert.match(proof, /CI_RUN_ATTEMPT: \$\{\{ github\.run_attempt \}\}/); + assert.match(proof, /REQUEST_FULL: \$\{\{ inputs\.full \}\}/); + assert.match(proof, /REQUEST_MUTANTS: \$\{\{ inputs\.mutants \}\}/); + assert.match(proof, /NEEDS_JSON: \$\{\{ toJSON\(needs\) \}\}/); + assert.match(proof, /name: ci-proof-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); + + const reuse = workflow.slice(workflow.indexOf('\n reuse:\n'), workflow.indexOf('\n hacs:\n')); + for (const id of ['smoke', 'golden', 'performance_smoke', 'backend']) { + assert.match(reuse, new RegExp(`${id}_source_run:`), `${id}: source run output`); + assert.match(reuse, new RegExp(`${id}_source_attempt:`), `${id}: source attempt output`); + assert.match(reuse, new RegExp(`${id}_source_sha:`), `${id}: source SHA output`); + } + assert.equal((reuse.match(/node scripts\/ci-proof\.mjs --marker=\.reuse-marker/g) || []).length, 4); + assert.equal(reuse.includes('lookup-only: true'), false, + 'marker contents must be restored and verified, not reduced to a cache-hit bit'); +});