diff --git a/.github/workflows/announce.yml b/.github/workflows/announce.yml index 29ff1de0..59f68c47 100644 --- a/.github/workflows/announce.yml +++ b/.github/workflows/announce.yml @@ -47,7 +47,7 @@ jobs: steps: - name: Check out release notes for a reusable call if: ${{ inputs.reusable == true }} - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref }} - name: Send to Telegram diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml index ec4f53b3..8f285065 100644 --- a/.github/workflows/docs-screenshots.yml +++ b/.github/workflows/docs-screenshots.yml @@ -33,10 +33,10 @@ jobs: name: Съёмка и сверка скриншот-индекса runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -45,7 +45,7 @@ jobs: # системные библиотеки Chromium уже в образе раннера. - name: Кэш браузеров Playwright id: pw - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -145,7 +145,7 @@ jobs: echo "ВЕРДИКТ: ничего не изменилось, принимать нечего." fi - name: Upload candidate - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: docs-screenshots path: | diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index ce66d331..6408f1da 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -53,7 +53,7 @@ jobs: tree: ${{ steps.identity.outputs.tree }} ref: ${{ steps.identity.outputs.ref }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }} fetch-depth: 0 @@ -76,19 +76,19 @@ jobs: # Час — потолок против зависшего Chromium. timeout-minutes: 60 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.material.outputs.sha }} fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm - run: npm ci - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' @@ -102,7 +102,7 @@ jobs: - name: Кэш браузеров Playwright id: pw - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -138,7 +138,7 @@ jobs: --shard=${{ matrix.shard }} --shards=4 - name: Сохранить лог и identity шарда if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }} path: artifacts/mutation-shard-${{ matrix.shard }} @@ -153,16 +153,16 @@ jobs: if: always() runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # CLI отчётчика принадлежит зафиксированному material. В main лежит # только workflow и может не быть dev-версии scripts/**. ref: ${{ needs.material.outputs.sha }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 - name: Забрать evidence всех попыток - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: pattern: mutation-shard-* path: artifacts/mutation-logs @@ -191,17 +191,17 @@ jobs: actions: read issues: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # Не перечитываем moving dev: код отчётчика берётся из уже # зафиксированного material. В стабильном main dev-скриптов нет. ref: ${{ needs.material.outputs.sha }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 - name: Забрать логи шардов continue-on-error: true - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: pattern: mutation-shard-* path: artifacts/mutation-logs diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index aa5cad15..078ff718 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -45,7 +45,7 @@ jobs: - space-glow steps: - name: Check out candidate - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: path: candidate fetch-depth: 2 @@ -131,12 +131,12 @@ jobs: echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY" - name: Check out base SHA - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ steps.base.outputs.sha }} path: baseline - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -150,7 +150,7 @@ jobs: # То же, что в validate.yml: кэш браузеров, apt не трогаем (#206). - name: Кэш браузеров Playwright id: pw - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }} @@ -267,7 +267,7 @@ jobs: - name: Upload full performance report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: full-performance-${{ matrix.profile }} path: artifacts/performance diff --git a/.github/workflows/process-reconcile.yml b/.github/workflows/process-reconcile.yml index d62e998a..f9d3565b 100644 --- a/.github/workflows/process-reconcile.yml +++ b/.github/workflows/process-reconcile.yml @@ -28,12 +28,12 @@ jobs: steps: # Расписание читается из main, а исполняемый reconciler — из dev: так # после штатного merge действует та же версия кода, которую проверил CI. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: dev fetch-depth: 1 persist-credentials: false - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 - name: Сопоставить labels, requests, runs и sealed evidence @@ -50,7 +50,7 @@ jobs: --output=artifacts/process-reconcile/summary.json - name: Опубликовать компактный machine-readable итог if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }} path: artifacts/process-reconcile/summary.json diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 6ca0440d..8ef99612 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -29,16 +29,20 @@ on: # отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает # ни одной job (`if` на guard) и в группу не входит. +# #556: права выдаются по job, а не одной строкой на весь workflow. Общий блок +# давал `issues: write` и OIDC даже той стадии, которая ничего не пишет, — +# модели. Ниже минимум на каждую: писать в issue умеют только детерминированные +# стадии, OIDC нужен исключительно `claude-code-action`. permissions: contents: read - issues: write - # Обязательно: claude-code-action получает OIDC-токен для авторизации - # GitHub App. Без этого прогон падает с «Could not fetch an OIDC token». - id-token: write jobs: guard: name: "Страж: ребейз на dev и предпосылки ревью" + # Читает и переставляет метки, комментирует отказ. + permissions: + contents: read + issues: write # Только статусные метки этапов ревью запускают конвейер (#499). Остальные # события помечаются skipped и не занимают место в группе concurrency. if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review' @@ -56,7 +60,7 @@ jobs: # scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что # inline-shell не покрывается тестами (#454). Node на раннере # предустановлен, setup-node не нужен. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 1 ref: dev @@ -257,6 +261,10 @@ jobs: prepare: name: "Ревью: материал и deterministic gates" + # Ребейзит ветку задачи и комментирует возврат автору. + permissions: + contents: read + issues: write needs: guard if: needs.guard.outputs.stage != '' runs-on: ubuntu-latest @@ -290,7 +298,7 @@ jobs: id: clock run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 ref: dev @@ -320,7 +328,7 @@ jobs: # кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели # тратились на работу инфраструктуры. В validate.yml кэш стоит на всех # тяжёлых job, здесь его не было. - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -713,7 +721,7 @@ jobs: - name: Передать подтверждённый материал модели if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ steps.prepared.outputs.artifact }} path: ${{ runner.temp }}/review-prepared @@ -732,6 +740,13 @@ jobs: model_review: name: "Ревью: работа модели" + # Единственная недоверенная стадия: модель читает материал и кладёт + # запечатанный artifact. Писать в issue и в репозиторий ей незачем — это + # делает `integrate`, проверив происхождение. `id-token` нужен самой + # claude-code-action для авторизации GitHub App. + permissions: + contents: read + id-token: write needs: [guard, prepare] if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' runs-on: ubuntu-latest @@ -748,19 +763,19 @@ jobs: id: clock run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 ref: ${{ needs.prepare.outputs.material_sha }} persist-credentials: false - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm - name: Получить контракт подготовленного материала - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: review-prepared-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/review-prepared @@ -808,7 +823,7 @@ jobs: # но когда нужен — качать его заново дороже, чем держать в кэше. - name: Кэш браузеров Playwright id: pw - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -846,7 +861,7 @@ jobs: - name: Review id: review - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1 env: # Вне рабочей копии: восстановление дерева ревьюером не должно # уничтожать его собственный артефакт (#220). @@ -1097,7 +1112,7 @@ jobs: echo "artifact=review-result-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" - name: Передать результат интеграции - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ steps.result.outputs.artifact }} path: ${{ runner.temp }}/review-result @@ -1116,6 +1131,10 @@ jobs: integrate: name: "Ревью: публикация и интеграция" + # Публикует разбор, переставляет метку, сливает проверенный материал. + permissions: + contents: read + issues: write needs: [guard, prepare, model_review] if: always() && needs.guard.outputs.stage != '' runs-on: ubuntu-latest @@ -1153,21 +1172,21 @@ jobs: fi echo "proceed=true" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 if: steps.ready.outputs.proceed == 'true' with: fetch-depth: 0 ref: dev persist-credentials: false - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 if: steps.ready.outputs.proceed == 'true' with: node-version: 22 - name: Получить результат модели if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/review-result @@ -1175,8 +1194,14 @@ jobs: - name: Проверить полноту и происхождение результата id: result if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + # #556: единственное, что недоверенная стадия модели может передать + # дальше, — этот artifact, и принимается он как ввод противника: полный + # набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с + # тем, что посчитала детерминированная `prepare`. Проверка вынесена из + # inline-shell в `scripts/review-result-gate.mjs` ради враждебных + # фикстур — в YAML её нельзя прогнать ни одним отрицательным случаем. env: - NUM: ${{ github.event.issue.number }} + ISSUE: ${{ github.event.issue.number }} STAGE: ${{ needs.guard.outputs.stage }} CYCLE: ${{ needs.guard.outputs.cycle }} BRANCH: ${{ needs.prepare.outputs.branch }} @@ -1192,27 +1217,9 @@ jobs: SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }} SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }} run: | + set -euo pipefail dir="$RUNNER_TEMP/review-result" - expected=$(printf '%s\n' manifest.sha256 prepared.json review-document.md verdict.json | sort) - actual=$(find "$dir" -maxdepth 1 -type f -printf '%f\n' | sort) - test "$actual" = "$expected" || { echo "::error::неполный или неожиданный набор evidence"; printf 'ожидалось:\n%s\nполучено:\n%s\n' "$expected" "$actual"; exit 1; } - (cd "$dir" && sha256sum -c manifest.sha256) - test -s "$dir/review-document.md" - jq -e \ - --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ - --arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \ - --arg branch "$BRANCH" --arg sha "$MATERIAL_SHA" --arg tree "$MATERIAL_TREE" \ - --arg specs "$MATERIAL_SPECS" --arg body "$MATERIAL_ISSUE_BODY" \ - --arg validate "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \ - --arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \ - --arg spec_changed "$SPEC_BODY_CHANGED" --arg spec_doc "$SPEC_BODY_DOC" \ - --arg spec_recorded "$SPEC_BODY_RECORDED" \ - '.schema == 1 and .run_id == $run_id and .run_attempt == $run_attempt and .issue == $issue and .stage == $stage and .cycle == $cycle and .branch == $branch and .material_sha == $sha and .material_tree == $tree and .material_specs == $specs and .material_issue_body == $body and .validate_result == $validate and .validate_url == $validate_url and .rebase_note == $rebase_note and .validated_note == $validated_note and .spec_body_changed == $spec_changed and .spec_body_doc == $spec_doc and .spec_body_recorded == $spec_recorded' \ - "$dir/prepared.json" - jq -e ' - (.verdict == "green" or .verdict == "yellow" or .verdict == "red") - and (.high | type == "number") and (.medium | type == "number") - and (.summary | type == "string")' "$dir/verdict.json" >/dev/null + node scripts/review-result-gate.mjs --dir="$dir" printf 'structured_output<> "$GITHUB_OUTPUT" cat "$dir/verdict.json" >> "$GITHUB_OUTPUT" # structured_output не обязан оканчиваться LF: delimiter команды diff --git a/.github/workflows/publish-prerelease.yml b/.github/workflows/publish-prerelease.yml index 5f15fcca..50424403 100644 --- a/.github/workflows/publish-prerelease.yml +++ b/.github/workflows/publish-prerelease.yml @@ -26,11 +26,11 @@ jobs: sha: ${{ steps.candidate.outputs.sha }} tag: ${{ steps.candidate.outputs.tag }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.sha }} fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - name: Pin the dev candidate or the existing annotated tag id: candidate @@ -112,7 +112,7 @@ jobs: --output=release-membership/RELEASE-MEMBERSHIP.json fi - name: Preserve candidate membership for publication - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-membership path: release-membership/RELEASE-MEMBERSHIP.json @@ -127,13 +127,13 @@ jobs: url: ${{ steps.verify.outputs.url }} newly_published: ${{ steps.release.outputs.newly_published }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.gate.outputs.sha }} fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: release-membership path: release-assets @@ -266,7 +266,7 @@ jobs: printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n\n```\n%s```\n' \ "$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY" - name: Verify HACS prerelease discovery order - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: EXPECTED_TAG: ${{ needs.gate.outputs.tag }} with: @@ -298,13 +298,13 @@ jobs: # pipeline. A PAT here would build a cascade out of a bookkeeping step. issues: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.gate.outputs.sha }} fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: release-membership path: release-membership diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d25dfef1..8d2d1031 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: prerelease: ${{ steps.resolve.outputs.prerelease }} mode: ${{ steps.release.outputs.mode }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Resolve the tag to its exact commit @@ -136,11 +136,11 @@ jobs: needs: candidate runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.candidate.outputs.sha }} fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } # #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`; # без него зелёный Validate — прогон без смоков и golden. Трейлер обязан @@ -206,11 +206,11 @@ jobs: needs: [candidate, gate] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.candidate.outputs.sha }} fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - name: Build once and verify both installable assets id: build @@ -248,12 +248,12 @@ jobs: npm run continuity:screencast - name: Upload failed continuity frames if: ${{ failure() && needs.candidate.outputs.prerelease != 'true' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: continuity-screencast path: artifacts/continuity-screencast - name: Keep the passport for the publication step - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-assets-${{ needs.candidate.outputs.tag }} path: release-assets/SHA256SUMS @@ -350,13 +350,13 @@ jobs: name: ${{ steps.verify.outputs.name }} newly_published: ${{ steps.flip.outputs.newly_published }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.candidate.outputs.sha }} fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: release-assets-${{ needs.candidate.outputs.tag }} path: passport @@ -447,7 +447,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Verify the published tag is the prerelease HACS will discover - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: EXPECTED_TAG: ${{ needs.candidate.outputs.tag }} with: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index cea55e6a..89acf3f9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -65,9 +65,9 @@ jobs: # Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base` # работают как раньше. Единственная догрузка по требованию здесь — # `git show origin/main:.github/workflows/process.yml`: один блоб. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: { fetch-depth: 0, filter: 'blob:none' } - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } # Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты @@ -175,12 +175,22 @@ jobs: # Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе # слияние job стоило бы автору лишнего круга «починил одно — узнал о # втором». + # #556: перемещаемая ссылка в `uses:` — доверие чужому владельцу тега + # здесь и сейчас, а не коду, который читали. Проверка стоит в preflight: + # она стоит миллисекунды и ловит правку, которая иначе доехала бы до + # привилегированной стадии. + - name: "Сторонние Actions закреплены полным SHA" + id: action_pins + continue-on-error: true + run: node scripts/action-pins.mjs + - name: Вердикт предполётных проверок env: DOCS: ${{ steps.docs.outcome }} WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }} PROVENANCE: ${{ steps.provenance.outcome }} PROCESS_GATE: ${{ steps.process_gate.outcome }} + ACTION_PINS: ${{ steps.action_pins.outcome }} run: | fail=0 check() { @@ -197,6 +207,7 @@ jobs: check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC" check "провенанс коммитов" "$PROVENANCE" check "процессный гейт" "$PROCESS_GATE" + check "пины сторонних Actions" "$ACTION_PINS" exit $fail # Классификация изменённых путей: тяжёлые job идут только там, где менялось @@ -233,7 +244,7 @@ jobs: steps: # `git diff --name-only` содержимого файлов не читает вовсе, поэтому # блобы истории этой job не нужны ни на одном шаге (#345). - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: { fetch-depth: 0, filter: 'blob:none' } # База диапазона — самый новый предок с УСПЕШНО завершённым Validate # (#387). Прежде бралась голова предыдущего пуша (`github.event.before`), @@ -395,8 +406,8 @@ jobs: 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 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - name: Ключи переиспользования id: keys @@ -420,7 +431,7 @@ jobs: # удаляется, чтобы один маркер не был принят за другой. - name: Маркер smoke id: m_smoke - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .reuse-marker key: reuse-smoke-${{ steps.keys.outputs.smoke }} @@ -433,7 +444,7 @@ jobs: run: rm -f .reuse-marker - name: Маркер golden id: m_golden - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .reuse-marker key: reuse-golden-${{ steps.keys.outputs.golden }} @@ -446,7 +457,7 @@ jobs: run: rm -f .reuse-marker - name: Маркер performance_smoke id: m_perf - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .reuse-marker key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }} @@ -459,7 +470,7 @@ jobs: run: rm -f .reuse-marker - name: Маркер geometry_parity id: m_geometry_parity - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .reuse-marker key: reuse-geometry_parity-${{ steps.keys.outputs.geometry_parity }} @@ -472,7 +483,7 @@ jobs: run: rm -f .reuse-marker - name: Маркер backend id: m_backend - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .reuse-marker key: reuse-backend-${{ steps.keys.outputs.backend }} @@ -517,9 +528,9 @@ jobs: if: needs.changes.outputs.integration == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: HACS validation - uses: hacs/action@main + uses: hacs/action@1ebf01c408f29afcb6406bd431bc98fd8cbb15aa # main@2026-09-13 with: category: integration @@ -529,9 +540,9 @@ jobs: if: needs.changes.outputs.integration == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Hassfest validation - uses: home-assistant/actions/hassfest@master + uses: home-assistant/actions/hassfest@58bff37c8947f690ace498be413a9b78d6f30f93 # master@2026-09-13 # Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился # файл, который он патчит, либо изменился его гард. `--check` этого не @@ -559,10 +570,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -574,7 +585,7 @@ jobs: # Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе # пропуск станет известен только после установки окружения. - name: Журнал свидетелей (последний по шарду) - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: artifacts/mutation-ledger # Cache immutable: rerun с тем же run_id обязан получить новый key, @@ -624,7 +635,7 @@ jobs: - if: steps.plan.outputs.count != '0' run: npm ci - if: steps.plan.outputs.count != '0' - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.14' - name: Backend test dependencies @@ -633,7 +644,7 @@ jobs: - name: Кэш браузеров Playwright id: pw if: steps.plan.outputs.count != '0' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -658,7 +669,7 @@ jobs: # попытку этого run либо самый новый журнал шарда. - name: Сохранить журнал свидетелей if: always() - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: artifacts/mutation-ledger key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -671,9 +682,9 @@ jobs: steps: # Полная история без блобов (#342): гейт «новый код не добавляет any» # diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: { fetch-depth: 0, filter: 'blob:none' } - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -731,7 +742,7 @@ jobs: # сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного # дерева, и каждая браузерная job делает это перед первым кадром. - name: Выложить бандл для браузерных job - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: card-bundle path: dist/ @@ -741,7 +752,7 @@ jobs: # скомпилированного тестового дерева. Оно создаётся `npm test`, но не # входит в production-бандл и потому едет отдельным артефактом (#336). - name: Выложить тестовые модули для смоков - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: card-test-build path: test-build/ @@ -763,8 +774,8 @@ jobs: matrix: shard: [1, 2, 3] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -776,7 +787,7 @@ jobs: # внятной ошибкой — тогда флаг вернуть. - name: Кэш браузеров Playwright id: pw - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -787,12 +798,12 @@ jobs: # один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle` # сверяет вшитый отпечаток с исходниками этого дерева. - name: Забрать собранный бандл - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: card-bundle path: dist - name: Забрать тестовые модули - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: card-test-build path: test-build @@ -846,7 +857,7 @@ jobs: exit $fail - name: Upload smoke logs if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: smoke-logs-${{ matrix.shard }} path: /tmp/smoke-logs @@ -866,7 +877,7 @@ jobs: "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - - uses: actions/cache/save@v6 + - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». # Это не отказ гейта: работа выполнена, маркер уже записал сосед — job # краснеть из-за этого не должна. @@ -883,8 +894,8 @@ jobs: if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -896,7 +907,7 @@ jobs: # внятной ошибкой — тогда флаг вернуть. - name: Кэш браузеров Playwright id: pw - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -907,7 +918,7 @@ jobs: # один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle` # сверяет вшитый отпечаток с исходниками этого дерева. - name: Забрать собранный бандл - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: card-bundle path: dist @@ -917,7 +928,7 @@ jobs: # прогона, потому что после падения шаги кэша уже не помогут отличить # виновника от свидетеля. - name: Прежнее падение на тех же входах - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .fail-marker key: fail-golden-${{ needs.reuse.outputs.golden_key }} @@ -933,7 +944,7 @@ jobs: fi - name: Upload golden candidates/diffs if: failure() || steps.golden.outputs.has_baselines == 'false' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: golden-images path: artifacts/golden @@ -952,7 +963,7 @@ jobs: # Гонка двух прогонов даёт «Cache already exists»; для маркера падения # это тем более не отказ гейта — job и так красная по существу. continue-on-error: true - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .fail-marker key: fail-golden-${{ needs.reuse.outputs.golden_key }} @@ -965,7 +976,7 @@ jobs: "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - - uses: actions/cache/save@v6 + - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». # Это не отказ гейта: работа выполнена, маркер уже записал сосед — job # краснеть из-за этого не должна. @@ -987,8 +998,8 @@ jobs: # (#473) добавляют до двух замеров по 3 образца — окно расширено под них. timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -1000,7 +1011,7 @@ jobs: # внятной ошибкой — тогда флаг вернуть. - name: Кэш браузеров Playwright id: pw - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -1011,7 +1022,7 @@ jobs: # один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle` # сверяет вшитый отпечаток с исходниками этого дерева. - name: Забрать собранный бандл - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: card-bundle path: dist @@ -1047,7 +1058,7 @@ jobs: run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json - name: Upload performance smoke report if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: performance-smoke path: artifacts/performance-smoke @@ -1060,7 +1071,7 @@ jobs: "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - - uses: actions/cache/save@v6 + - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». # Это не отказ гейта: работа выполнена, маркер уже записал сосед — job # краснеть из-за этого не должна. @@ -1075,12 +1086,12 @@ jobs: if: needs.changes.outputs.geometry_parity == 'true' && needs.reuse.outputs.geometry_parity != 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version-file: .nvmrc cache: npm - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version-file: .python-version - run: npm ci @@ -1097,7 +1108,7 @@ jobs: "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - - uses: actions/cache/save@v6 + - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 continue-on-error: true with: path: .reuse-marker @@ -1109,12 +1120,12 @@ jobs: if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Browser fixtures are generated by their real ESM factories and then # validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test. - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: { python-version: "3.14" } - run: | # Точные версии — в tests_backend/requirements.txt (#392): без них @@ -1162,7 +1173,7 @@ jobs: echo "coverage: $actual% (baseline $baseline%)" python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)" - name: Артефакт coverage.xml - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: backend-coverage path: coverage.xml @@ -1175,7 +1186,7 @@ jobs: "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ "попытка: ${{ github.run_attempt }}" \ > .reuse-marker - - uses: actions/cache/save@v6 + - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». # Это не отказ гейта: работа выполнена, маркер уже записал сосед — job # краснеть из-за этого не должна. @@ -1195,10 +1206,10 @@ jobs: needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, geometry_parity, backend] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.sha }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - name: Зафиксировать tree кандидата id: candidate @@ -1215,7 +1226,7 @@ jobs: NEEDS_JSON: ${{ toJSON(needs) }} run: node scripts/ci-proof.mjs --emit=artifacts/ci-proof/proof.json - name: Опубликовать proof точной попытки - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ci-proof-${{ github.run_id }}-${{ github.run_attempt }} path: artifacts/ci-proof/proof.json diff --git a/scripts/action-pins.mjs b/scripts/action-pins.mjs new file mode 100644 index 00000000..c40aaa7d --- /dev/null +++ b/scripts/action-pins.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Все сторонние Actions закреплены полным immutable SHA (#556). + * + * Перемещаемая ссылка в `uses:` — это доверие чужому владельцу тега здесь и + * сейчас, а не коду, который читали. До этой правки конвейер брал + * `home-assistant/actions/hassfest@master` и `hacs/action@main` — то есть + * произвольный будущий коммит чужой ветки, — а ревьюера с Read/Write/Bash + * запускал `anthropics/claude-code-action@v1`, перемещаемый major. + * + * Проверка механическая и потому не врёт: `uses:` обязан быть либо локальным + * (`./.github/…`), либо `/[/]@<40 hex>` с комментарием, где + * записана человекочитаемая версия — то, что при обновлении сверяет человек. + * + * node scripts/action-pins.mjs # проверить + * node scripts/action-pins.mjs --list # что и к чему закреплено + * + * Обновление пина: посмотреть, что сейчас стоит за тегом + * `gh api repos///commits/ -q .sha`, прочитать дельту от + * закреплённого SHA и заменить обе части — SHA и комментарий — одним коммитом. + */ +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const WORKFLOW_DIR = '.github/workflows'; +const USES = /^\s*(?:-\s*)?uses:\s*(\S+)(.*)$/; + +/** Локальная переиспользуемая workflow — не сторонний код, пина не требует. */ +export const isLocal = (spec) => spec.startsWith('./'); +export const isPinned = (spec) => /^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/.test(spec); +/** Комментарий обязателен: без него человек не знает, какую версию он закрепил. */ +export const hasVersionNote = (tail) => /#\s*\S/.test(tail); + +export function auditWorkflowSource(file, source) { + const problems = []; + source.split('\n').forEach((line, index) => { + const match = USES.exec(line); + if (!match) return; + const [, spec, tail] = match; + const at = `${file}:${index + 1}`; + if (isLocal(spec)) return; + if (!isPinned(spec)) { + problems.push(`${at}: «${spec}» не закреплён полным SHA`); + return; + } + if (!hasVersionNote(tail)) { + problems.push(`${at}: «${spec}» без комментария с версией`); + } + }); + return problems; +} + +export function listWorkflows(root = ROOT) { + return readdirSync(resolve(root, WORKFLOW_DIR)) + .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml')) + .sort(); +} + +export function auditRepository(root = ROOT) { + const problems = []; + for (const name of listWorkflows(root)) { + const file = `${WORKFLOW_DIR}/${name}`; + problems.push(...auditWorkflowSource(file, readFileSync(resolve(root, file), 'utf8'))); + } + return problems; +} + +const invokedDirectly = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) { + if (process.argv.includes('--list')) { + for (const name of listWorkflows()) { + const file = `${WORKFLOW_DIR}/${name}`; + for (const line of readFileSync(resolve(ROOT, file), 'utf8').split('\n')) { + const match = USES.exec(line); + if (match && !isLocal(match[1])) console.log(`${file}: ${match[1]}${match[2]}`); + } + } + process.exit(0); + } + const problems = auditRepository(); + if (problems.length) { + for (const problem of problems) console.error(`::error::${problem}`); + console.error(`не закреплено: ${problems.length}`); + process.exit(1); + } + console.log('все сторонние Actions закреплены полным SHA'); +} diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 48d8dd4e..f77dd822 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -8000,13 +8000,50 @@ const MUTANT_DEFINITIONS = [ }, { id: 'review-integration-skips-evidence-checksum', - guard: 'node --test --test-name-pattern="#551" test/review-doc-guard.test.mjs', - because: '#551: artifact между моделью и привилегированной интеграцией — вход доверенной ' - + 'стадии; без checksum неполный или подменённый документ можно опубликовать и слить', + guard: 'node --test test/review-result-gate.test.mjs', + because: '#551/#556: artifact между моделью и привилегированной интеграцией — вход ' + + 'доверенной стадии; без сверки контрольных сумм подменённый документ или вердикт ' + + 'публикуется и сливается. Проверка живёт в `review-result-gate.mjs` с тех пор, как ' + + 'её стало можно прогнать враждебными фикстурами', patches: [{ - file: '.github/workflows/process.yml', - find: ' (cd "$dir" && sha256sum -c manifest.sha256)\n test -s "$dir/review-document.md"', - replace: ' # mutant: result contents are trusted\n test -s "$dir/review-document.md"', + file: 'scripts/review-result-gate.mjs', + find: ' if (digest !== row.hash) problems.push(`контрольная сумма не сходится: ${row.name}`);', + replace: ' void digest; // mutant: содержимое artifact принимается на веру', + }], + }, + { + id: 'review-gate-accepts-a-foreign-passport', + guard: 'node --test test/review-result-gate.test.mjs', + because: '#556: паспорт — единственное, чем привилегированная стадия отличает свой ' + + 'результат от чужого. Без посполевой сверки чужой run, устаревший material_sha или ' + + 'другой этап проходят как свои', + patches: [{ + file: 'scripts/review-result-gate.mjs', + find: " if (String(got) !== String(want)) {", + replace: " if (false && String(got) !== String(want)) {", + }], + }, + { + id: 'review-gate-tolerates-an-extra-file', + guard: 'node --test test/review-result-gate.test.mjs', + because: '#556: набор файлов задан точно, а не «не меньше»: лишний файл в artifact — это ' + + 'уже не тот artifact, который запечатала модель', + patches: [{ + file: 'scripts/review-result-gate.mjs', + find: " if (actual.join(',') !== wanted.join(',')) {", + replace: " if (wanted.some((name) => !actual.includes(name))) {", + }], + }, + { + id: 'action-pins-accept-a-moving-ref', + guard: 'node --test test/action-pins.test.mjs', + because: '#556: перемещаемая ссылка в `uses:` — доверие чужому владельцу тега здесь и ' + + 'сейчас, а не коду, который читали; `@master` и `@main` у hassfest и hacs означали ' + + 'произвольный будущий коммит чужой ветки', + patches: [{ + file: 'scripts/action-pins.mjs', + find: "export const isPinned = (spec) => /^[\\w.-]+\\/[\\w./-]+@[0-9a-f]{40}$/.test(spec);", + replace: "export const isPinned = (spec) => /^[\\w.-]+\\/[\\w./-]+@\\S+$/.test(spec);", }], }, { diff --git a/scripts/review-result-gate.mjs b/scripts/review-result-gate.mjs new file mode 100644 index 00000000..eab110ac --- /dev/null +++ b/scripts/review-result-gate.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * Граница доверия между стадией модели и привилегированной публикацией (#556). + * + * `model_review` — единственная недоверенная стадия конвейера: там исполняется + * чужой код с Read/Write/Bash. Всё, что она может передать дальше, — один + * artifact. Публикация разбора, перестановка метки и слияние в `dev` идут в + * другой job, на чистом checkout `dev`, и обязаны принимать этот artifact как + * недоверенный ввод: полный набор файлов, сходящиеся контрольные суммы и + * совпадение КАЖДОГО поля паспорта с тем, что посчитала детерминированная + * стадия `prepare`. Подменённый, неполный, устаревший или чужой результат + * отвергается fail-closed. + * + * Проверка вынесена из inline-shell в функцию именно ради враждебных фикстур: + * в YAML её нельзя прогнать ни одним отрицательным случаем. + * + * node scripts/review-result-gate.mjs --dir=<путь> # поля ожидания из env + */ +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Ровно эти файлы и ни одного больше: лишний файл — это уже чужой artifact. */ +export const REQUIRED_FILES = ['manifest.sha256', 'prepared.json', 'review-document.md', 'verdict.json']; +/** Паспорт материала. Каждое поле сверяется с outputs стадии `prepare`. */ +export const PASSPORT_FIELDS = [ + 'run_id', 'run_attempt', 'issue', 'stage', 'cycle', 'branch', + 'material_sha', 'material_tree', 'material_specs', 'material_issue_body', + 'validate_result', 'validate_url', 'rebase_note', 'validated_note', + 'spec_body_changed', 'spec_body_doc', 'spec_body_recorded', +]; +export const VERDICTS = ['green', 'yellow', 'red']; + +export const sha256 = (text) => createHash('sha256').update(text).digest('hex'); + +/** Разбор строки `sha256sum`: « <имя>». */ +export function parseManifest(text) { + const rows = []; + for (const line of String(text).split('\n')) { + if (!line.trim()) continue; + const match = /^([0-9a-f]{64})\s[\s*](.+)$/.exec(line); + if (!match) return null; + rows.push({ hash: match[1], name: match[2].trim() }); + } + return rows; +} + +/** + * @param {object} p + * @param {string[]} p.files имена файлов в каталоге artifact + * @param {(name:string)=>string} p.read содержимое файла + * @param {Record} p.expected паспорт, посчитанный `prepare` + * @returns {string[]} причины отказа; пустой массив — принять + */ +export function reviewResultProblems({ files, read, expected }) { + const problems = []; + const actual = [...files].sort(); + const wanted = [...REQUIRED_FILES].sort(); + if (actual.join(',') !== wanted.join(',')) { + return [`набор файлов не тот: ожидалось ${wanted.join(', ')}, получено ${actual.join(', ') || '(пусто)'}`]; + } + + const manifest = parseManifest(read('manifest.sha256')); + if (!manifest) return ['manifest.sha256 не разобран']; + const covered = manifest.map((row) => row.name).sort(); + const mustCover = REQUIRED_FILES.filter((name) => name !== 'manifest.sha256').sort(); + if (covered.join(',') !== mustCover.join(',')) { + problems.push(`manifest покрывает не те файлы: ${covered.join(', ') || '(пусто)'}`); + } + for (const row of manifest) { + if (!REQUIRED_FILES.includes(row.name)) continue; + const digest = sha256(read(row.name)); + if (digest !== row.hash) problems.push(`контрольная сумма не сходится: ${row.name}`); + } + + if (!String(read('review-document.md')).trim()) problems.push('документ ревью пуст'); + + let prepared; + try { + prepared = JSON.parse(read('prepared.json')); + } catch (error) { + return [...problems, `prepared.json не разобран: ${error.message}`]; + } + if (prepared?.schema !== 1) problems.push('prepared.json: не та схема'); + for (const field of PASSPORT_FIELDS) { + const want = expected[field] ?? ''; + const got = prepared?.[field] ?? ''; + if (String(got) !== String(want)) { + problems.push(`паспорт не совпал: ${field} = «${got}», ожидалось «${want}»`); + } + } + + let verdict; + try { + verdict = JSON.parse(read('verdict.json')); + } catch (error) { + return [...problems, `verdict.json не разобран: ${error.message}`]; + } + if (!VERDICTS.includes(verdict?.verdict)) problems.push(`verdict вне словаря: ${JSON.stringify(verdict?.verdict)}`); + if (typeof verdict?.high !== 'number') problems.push('verdict.high не число'); + if (typeof verdict?.medium !== 'number') problems.push('verdict.medium не число'); + if (typeof verdict?.summary !== 'string') problems.push('verdict.summary не строка'); + return problems; +} + +const invokedDirectly = process.argv[1] + && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) { + const dir = process.argv.find((a) => a.startsWith('--dir='))?.slice(6); + if (!dir) { console.error('usage: review-result-gate.mjs --dir=<путь>'); process.exit(2); } + const env = (name) => process.env[name.toUpperCase()] ?? ''; + const expected = Object.fromEntries(PASSPORT_FIELDS.map((field) => [field, env(field)])); + expected.run_id = process.env.GITHUB_RUN_ID ?? ''; + expected.run_attempt = process.env.GITHUB_RUN_ATTEMPT ?? ''; + const problems = reviewResultProblems({ + files: readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name), + read: (name) => readFileSync(resolve(dir, name), 'utf8'), + expected, + }); + if (problems.length) { + for (const problem of problems) console.error(`::error::результат модели отвергнут — ${problem}`); + process.exit(1); + } + console.log('результат модели полон, суммы сходятся, паспорт совпал'); +} diff --git a/test/action-pins.test.mjs b/test/action-pins.test.mjs new file mode 100644 index 00000000..8cab2ca4 --- /dev/null +++ b/test/action-pins.test.mjs @@ -0,0 +1,67 @@ +// #556: перемещаемая ссылка в `uses:` — это доверие чужому владельцу тега здесь +// и сейчас, а не коду, который читали. До правки конвейер брал +// `home-assistant/actions/hassfest@master` и `hacs/action@main`, то есть +// произвольный будущий коммит чужой ветки, а ревьюера с Read/Write/Bash +// запускал перемещаемый major `anthropics/claude-code-action@v1`. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +import { + auditRepository, auditWorkflowSource, isLocal, isPinned, hasVersionNote, listWorkflows, +} from '../scripts/action-pins.mjs'; + +test('#556: в репозитории не осталось незакреплённых Actions', () => { + assert.deepEqual(auditRepository(), []); +}); + +test('#556: проверка смотрит на все воркфлоу, а не на один', () => { + const files = listWorkflows(); + assert.ok(files.length >= 9, `воркфлоу найдено ${files.length}`); + assert.ok(files.includes('process.yml') && files.includes('validate.yml')); +}); + +test('#556: локальная переиспользуемая workflow пина не требует', () => { + assert.equal(isLocal('./.github/workflows/announce.yml'), true); + assert.deepEqual(auditWorkflowSource('x.yml', ' uses: ./.github/workflows/announce.yml\n'), []); +}); + +test('#556: перемещаемая ссылка — находка, в какой бы форме ни пришла', () => { + for (const spec of [ + 'actions/checkout@v7', 'hacs/action@main', 'home-assistant/actions/hassfest@master', + 'anthropics/claude-code-action@v1', 'owner/repo@abc1234', + ]) { + assert.equal(isPinned(spec), false, spec); + const found = auditWorkflowSource('x.yml', ` - uses: ${spec}\n`); + assert.equal(found.length, 1, spec); + assert.match(found[0], /не закреплён полным SHA/); + } +}); + +test('#556: SHA без комментария с версией — тоже находка', () => { + const sha = 'a'.repeat(40); + assert.equal(isPinned(`owner/repo@${sha}`), true); + assert.equal(hasVersionNote(' # v7'), true); + assert.equal(hasVersionNote(''), false); + const bare = auditWorkflowSource('x.yml', ` - uses: owner/repo@${sha}\n`); + assert.equal(bare.length, 1); + assert.match(bare[0], /без комментария с версией/); + assert.deepEqual(auditWorkflowSource('x.yml', ` - uses: owner/repo@${sha} # v7\n`), []); +}); + +test('#556: находка называет файл и строку', () => { + const source = ['jobs:', ' a:', ' steps:', ' - uses: hacs/action@main'].join('\n'); + assert.match(auditWorkflowSource('.github/workflows/x.yml', source)[0], + /^\.github\/workflows\/x\.yml:4: /); +}); + +// Проверка обязана стоять в предполётном вердикте Validate, иначе она есть, но +// не сработает ни на одном пуше. +test('#556: preflight Validate считает пины частью вердикта', () => { + const workflow = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8'); + const preflight = workflow.slice(0, workflow.indexOf('\n changes:')); + assert.match(preflight, /^\s+run: node scripts\/action-pins\.mjs$/m, 'шаг запускает проверку'); + assert.match(preflight, /ACTION_PINS: \$\{\{ steps\.action_pins\.outcome \}\}/); + assert.match(preflight, /^\s+check "пины сторонних Actions" "\$ACTION_PINS"$/m, + 'исход попадает в вердикт, а не теряется в continue-on-error'); +}); diff --git a/test/release-workflow.test.mjs b/test/release-workflow.test.mjs index 562604b7..e7dc5225 100644 --- a/test/release-workflow.test.mjs +++ b/test/release-workflow.test.mjs @@ -86,7 +86,7 @@ test('#540 AC3: one build, deterministic ZIP from the tree E2E installed, passpo assert.match(publish, /diff -u passport\/SHA256SUMS public\/SHA256SUMS/); assert.match(publish, /node scripts\/release-assets\.mjs check public passport\/SHA256SUMS\n/); assert.match(publish, /test "\$\(git rev-list -n 1 "refs\/tags\/\$TAG"\)" = "\$SHA"/); - assert.match(publish, /download-artifact@v7/, 'the passport travels from stage as an artifact, not via the release'); + assert.match(publish, /download-artifact@/, 'the passport travels from stage as an artifact, not via the release'); }); // #538: анонс — последнее звено выпуска, а не параллельное ему. Пока он висел diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index f9a642de..194f65b6 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -839,14 +839,19 @@ test('#551: gates, модель и интеграция имеют незави 'модель получает exact material, а не подвижную ветку'); assert.match(prepare, /review-prepared-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); - assert.equal((workflow.match(/sha256sum -c manifest\.sha256/g) || []).length, 2, - 'контракт проверяют и модель, и интеграция'); + assert.match(model, /sha256sum .*> "\$RUNNER_TEMP\/review-result\/manifest\.sha256"|sha256sum/, + 'модель запечатывает результат контрольными суммами'); assert.match(model, /test "\$\(git rev-parse HEAD\)" = "\$MATERIAL_SHA"/); assert.match(model, /review-result-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); - assert.match(integrate, /неполный или неожиданный набор evidence/); - assert.match(integrate, /sha256sum -c manifest\.sha256/); - assert.match(integrate, /\.run_id == \$run_id[\s\S]*\.material_sha == \$sha[\s\S]*\.material_tree == \$tree/, - 'подмена run/SHA/tree между jobs отвергается'); + // #556: разбор того же контракта переехал из inline-shell в + // `scripts/review-result-gate.mjs` — ровно ради враждебных фикстур, которых в + // YAML поставить было некуда (`test/review-result-gate.test.mjs`). Здесь + // проверяется, что привилегированная стадия ходит через него и передаёт ему + // весь паспорт, а не его часть. + assert.match(integrate, /node scripts\/review-result-gate\.mjs --dir="\$dir"/); + for (const field of ['MATERIAL_SHA', 'MATERIAL_TREE', 'STAGE', 'CYCLE', 'BRANCH', 'ISSUE']) { + assert.match(integrate, new RegExp(`^\\s+${field}: `, 'm'), `${field} передаётся гейту`); + } assert.match(integrate, /PREPARE_RESULT: \$\{\{ needs\.prepare\.result \}\}/); assert.match(integrate, /MODEL_RESULT: \$\{\{ needs\.model_review\.result \}\}/); assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/, @@ -880,3 +885,28 @@ test('конвейер: ребейз не заканчивается, пока 'ответ REST сверяется с новой вершиной'); assert.match(rebase, /ссылка \$BRANCH за минуту не стала указывать/, 'не доехавшая ссылка — отказ, а не молчание'); }); + +// #556: до этой правки один блок `permissions` на весь workflow выдавал +// `issues: write` и OIDC каждой стадии, включая единственную недоверенную — +// работу модели. Права выдаются по job и по факту использования. +test('конвейер: права выдаются по job, модель не пишет в issue (#556)', () => { + const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const head = workflow.slice(0, workflow.indexOf('\njobs:')); + assert.match(head, /^permissions:\n contents: read\n/m, 'на уровне workflow остаётся только чтение'); + assert.doesNotMatch(head, /^\s+issues: write$/m, 'issues: write на весь workflow больше не выдаётся'); + assert.doesNotMatch(head, /^\s+id-token: write$/m, 'OIDC на весь workflow больше не выдаётся'); + + const jobBlock = (name, next) => workflow.slice(workflow.indexOf(`\n ${name}:\n`), workflow.indexOf(`\n ${next}:\n`)); + const model = jobBlock('model_review', 'integrate'); + assert.match(model, /^\s+permissions:\n\s+contents: read\n\s+id-token: write$/m, + 'модели — только чтение и OIDC для claude-code-action'); + assert.doesNotMatch(model.slice(0, model.indexOf('steps:')), /issues: write/, + 'модель не получает права записи в issue'); + + for (const [name, next] of [['guard', 'prepare'], ['prepare', 'model_review']]) { + assert.match(jobBlock(name, next), /^\s+permissions:\n\s+contents: read\n\s+issues: write$/m, + `${name} пишет в issue и только туда`); + } + const integrate = workflow.slice(workflow.indexOf('\n integrate:\n')); + assert.match(integrate, /^\s+permissions:\n\s+contents: read\n\s+issues: write$/m); +}); diff --git a/test/review-result-gate.test.mjs b/test/review-result-gate.test.mjs new file mode 100644 index 00000000..3a785ef6 --- /dev/null +++ b/test/review-result-gate.test.mjs @@ -0,0 +1,146 @@ +// #556: враждебные фикстуры на границу «работа модели → привилегированная +// публикация». Стадия модели — единственная недоверенная в конвейере; всё, что +// она передаёт дальше, это один artifact, и публикация обязана принимать его +// как ввод противника. В inline-shell эту границу нельзя было прогнать ни одним +// отрицательным случаем — потому проверка и вынесена в функцию. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +import { + reviewResultProblems, parseManifest, sha256, REQUIRED_FILES, PASSPORT_FIELDS, +} from '../scripts/review-result-gate.mjs'; + +const EXPECTED = Object.fromEntries(PASSPORT_FIELDS.map((f) => [f, `значение-${f}`])); + +/** Честный artifact стадии модели. */ +function fixture(over = {}) { + const document = over.document ?? '# разбор\n\nвсё проверено\n'; + const prepared = JSON.stringify({ schema: 1, ...EXPECTED, ...(over.prepared || {}) }); + const verdict = JSON.stringify({ verdict: 'green', high: 0, medium: 0, summary: 'ok', ...(over.verdict || {}) }); + const body = { 'review-document.md': document, 'prepared.json': prepared, 'verdict.json': verdict }; + const manifest = Object.entries(body) + .map(([name, text]) => `${over.breakSum === name ? 'f'.repeat(64) : sha256(text)} ${name}`) + .join('\n') + '\n'; + const files = { ...body, 'manifest.sha256': manifest, ...(over.extraFiles || {}) }; + for (const name of over.drop || []) delete files[name]; + return { + files: Object.keys(files), + read: (name) => files[name], + expected: EXPECTED, + }; +} + +test('#556: честный результат принимается', () => { + assert.deepEqual(reviewResultProblems(fixture()), []); +}); + +test('#556: неполный набор файлов отвергается', () => { + for (const name of REQUIRED_FILES) { + const problems = reviewResultProblems(fixture({ drop: [name] })); + assert.ok(problems.length >= 1, name); + assert.match(problems[0], /набор файлов не тот/, name); + } +}); + +test('#556: лишний файл в artifact отвергается', () => { + const problems = reviewResultProblems(fixture({ extraFiles: { 'payload.sh': 'rm -rf /' } })); + assert.match(problems[0], /набор файлов не тот/); +}); + +test('#556: подменённое содержимое ловится контрольной суммой', () => { + for (const name of ['review-document.md', 'prepared.json', 'verdict.json']) { + const problems = reviewResultProblems(fixture({ breakSum: name })); + assert.ok(problems.some((p) => p.includes(`контрольная сумма не сходится: ${name}`)), name); + } +}); + +test('#556: чужой прогон и чужая попытка отвергаются', () => { + for (const field of ['run_id', 'run_attempt']) { + const problems = reviewResultProblems(fixture({ prepared: { [field]: 'чужое' } })); + assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field); + } +}); + +test('#556: устаревший материал отвергается — и по SHA, и по дереву', () => { + for (const field of ['material_sha', 'material_tree']) { + const problems = reviewResultProblems(fixture({ prepared: { [field]: 'a'.repeat(40) } })); + assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field); + } +}); + +test('#556: чужая задача, этап, раунд и ветка отвергаются', () => { + for (const field of ['issue', 'stage', 'cycle', 'branch']) { + const problems = reviewResultProblems(fixture({ prepared: { [field]: 'чужое' } })); + assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field); + } +}); + +test('#556: сверяется КАЖДОЕ поле паспорта, а не выбранные', () => { + for (const field of PASSPORT_FIELDS) { + const problems = reviewResultProblems(fixture({ prepared: { [field]: 'подменено' } })); + assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field); + } +}); + +test('#556: чужая схема паспорта отвергается', () => { + assert.ok(reviewResultProblems(fixture({ prepared: { schema: 2 } })) + .some((p) => p.includes('не та схема'))); +}); + +test('#556: вердикт вне словаря и кривые поля отвергаются', () => { + assert.ok(reviewResultProblems(fixture({ verdict: { verdict: 'merged' } })) + .some((p) => p.startsWith('verdict вне словаря'))); + assert.ok(reviewResultProblems(fixture({ verdict: { high: '0' } })) + .some((p) => p.includes('high не число'))); + assert.ok(reviewResultProblems(fixture({ verdict: { summary: 42 } })) + .some((p) => p.includes('summary не строка'))); +}); + +test('#556: пустой документ ревью — не документ', () => { + assert.ok(reviewResultProblems(fixture({ document: ' \n' })) + .some((p) => p.includes('документ ревью пуст'))); +}); + +test('#556: manifest, который покрывает не те файлы, отвергается', () => { + const base = fixture(); + const read = (name) => (name === 'manifest.sha256' + ? `${sha256(base.read('verdict.json'))} verdict.json\n` + : base.read(name)); + assert.ok(reviewResultProblems({ ...base, read }) + .some((p) => p.startsWith('manifest покрывает не те файлы'))); +}); + +test('#556: неразбираемые manifest и JSON — отказ, а не исключение', () => { + const base = fixture(); + const broken = (name, text) => ({ ...base, read: (n) => (n === name ? text : base.read(n)) }); + assert.deepEqual(reviewResultProblems(broken('manifest.sha256', 'мусор')), ['manifest.sha256 не разобран']); + assert.ok(reviewResultProblems(broken('prepared.json', '{')).some((p) => p.includes('prepared.json не разобран'))); + assert.ok(reviewResultProblems(broken('verdict.json', '{')).some((p) => p.includes('verdict.json не разобран'))); +}); + +test('#556: разбор строки sha256sum терпит и пробел, и звёздочку', () => { + const hex = 'a'.repeat(64); + assert.deepEqual(parseManifest(`${hex} x.md\n`), [{ hash: hex, name: 'x.md' }]); + assert.deepEqual(parseManifest(`${hex} *x.md\n`), [{ hash: hex, name: 'x.md' }]); + assert.equal(parseManifest('не хеш вовсе'), null); +}); + +// Проверка обязана стоять на пути привилегированной стадии, а не просто +// существовать в репозитории. +test('#556: integrate пропускает artifact только через гейт', () => { + const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const integrate = workflow.slice(workflow.indexOf('\n integrate:\n')); + const step = integrate.slice( + integrate.indexOf(' - name: Проверить полноту и происхождение результата'), + integrate.indexOf(' # Ревьюер пишет только в docs/reviews/.'), + ); + assert.ok(step.length > 0, 'шаг проверки найден'); + assert.match(step, /^\s+node scripts\/review-result-gate\.mjs --dir="\$dir"$/m); + for (const field of PASSPORT_FIELDS) { + if (field === 'run_id' || field === 'run_attempt') continue; // приходят из GITHUB_* + assert.match(step, new RegExp(`^\\s+${field.toUpperCase()}: `, 'm'), `${field} передаётся гейту`); + } + // Публикация читает вердикт только после гейта. + assert.ok(step.indexOf('review-result-gate.mjs') < step.indexOf('structured_output')); +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index bff693cd..24b2ddf3 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -71,13 +71,17 @@ test('предполётные проверки не прячут друг др const preflight = workflow.slice( workflow.indexOf('\n preflight:\n'), workflow.indexOf('\n changes:\n'), ); - for (const id of ['docs', 'workflow_sync', 'provenance', 'process_gate']) { + // #556 добавил пятый сигнал — пины сторонних Actions. Число берётся из + // списка, а не из магической константы: иначе каждая новая проверка красит + // тест, который про другое. + const checks = ['docs', 'workflow_sync', 'provenance', 'process_gate', 'action_pins']; + for (const id of checks) { assert.ok(preflight.includes(`id: ${id}`), `нет шага ${id}`); assert.ok(preflight.includes(`steps.${id}.outcome`), `вердикт не читает ${id}`); } - // Слияние джоб не имеет права превратить четыре независимых сигнала в один + // Слияние джоб не имеет права превратить независимые сигналы в один // «первый упавший»: иначе автор узнаёт о втором нарушении следующим кругом. - assert.equal(preflight.match(/continue-on-error: true/g)?.length, 4); + assert.equal(preflight.match(/continue-on-error: true/g)?.length, checks.length); assert.ok(preflight.includes('exit $fail'), 'вердикт обязан падать сам'); }); @@ -128,7 +132,7 @@ test('упавшая golden называет первопричину, а не // Восстановление обязано стоять ДО прогона: после падения различить виновника // и свидетеля уже нечем. - const restore = golden.indexOf('cache/restore@v6'); + const restore = golden.indexOf('cache/restore@'); const verify = golden.indexOf('npm run golden:verify'); assert.ok(restore > 0 && verify > restore, 'маркер падения восстанавливается до прогона'); @@ -443,7 +447,7 @@ test('#518: пустой план шарда не ставит окружени assert.match(plan, /count=\$\{count:-0\}/, 'непрочитанный план считается пустым, а не срывает шаг'); // Дорогие шаги — под условием, но сама job исполняется: доказательство // гейта ревью (#510 provesMutants) требует УСПЕШНОЙ job, а не пропущенной. - for (const step of ['run: npm ci', 'actions/setup-python@v7', 'pip install -r tests_backend/requirements.txt', + for (const step of ['run: npm ci', 'actions/setup-python@', 'pip install -r tests_backend/requirements.txt', 'name: Затронутые мутанты ловятся']) { const at = job.indexOf(step); assert.ok(at > 0, `нет шага ${step}`); @@ -466,7 +470,7 @@ test('журнал свидетелей changed_mutants: rerun продолжа const workflow = read('validate.yml'); const start = workflow.indexOf('\n changed_mutants:\n'); const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start)); - const restore = job.slice(job.indexOf('actions/cache/restore@v6'), job.indexOf('name: План шарда')); + const restore = job.slice(job.indexOf('actions/cache/restore@'), job.indexOf('name: План шарда')); assert.match(restore, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); assert.match(restore, /mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-/, 'rerun обязан восстановить предыдущую попытку того же run'); assert.match(restore, /^\s+mutation-ledger-\$\{\{ matrix\.shard \}\}-\s*$/m, 'новый run обязан найти последний журнал шарда'); @@ -474,10 +478,10 @@ test('журнал свидетелей changed_mutants: rerun продолжа assert.match(job, /--changed="\$BASE\.\.\$HEAD_SHA" --shard="\$SHARD\/6" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json"/); // #518: журнал обязан восстанавливаться ДО плана, иначе план не увидит // уже пойманных и шард заплатит за окружение впустую. - assert.ok(job.indexOf('actions/cache/restore@v6') < job.indexOf('name: План шарда'), 'restore журнала идёт до плана'); + assert.ok(job.indexOf('actions/cache/restore@') < job.indexOf('name: План шарда'), 'restore журнала идёт до плана'); const save = job.slice(job.indexOf('name: Сохранить журнал свидетелей')); assert.match(save, /if: always\(\)/, 'красный или отменённый шард обязан сохранить уже пойманное'); - assert.match(save, /actions\/cache\/save@v6/); + assert.match(save, /actions\/cache\/save@/); assert.match(save, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); assert.ok(job.indexOf('name: Сохранить журнал свидетелей') > job.indexOf('--ledger='), 'save идёт после шага прогона'); });