From d11ad9c1c2e022b189d1c28f67359940ebdf9e78 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:40:08 +0300 Subject: [PATCH] ci: register ship-review and beta-derived as thin callers in main (#716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `workflow_dispatch` runs the file from the chosen ref, but GitHub lists a workflow and accepts a dispatch (button, `gh workflow run`, API) only when its file exists on the default branch. `ship-review.yml` (#696) and `beta-derived.yml` (#697) lived only in `dev`, so neither could be started at all, and the comment "the file runs from `--ref dev`, no mirror in `main` needed" was wrong. Both beta steps are needed before the next promotion would bring them to `main`. They now follow the #623 layout instead of a full copy in `main`: a thin caller (trigger, dispatch inputs, run-name, permission ceiling, concurrency) calls `_ship-review.yml` / `_beta-derived.yml` at `@dev` with `secrets: inherit`. A full copy would either need a mirror on every edit or drift silently, and a dispatch from `main` (the button's default) would run the stale copy; the thin caller runs the dev body from any ref. The caller ceiling is the union of the body jobs' permissions (#556): ship-review `contents: read` + `issues: read`, beta-derived `contents: read` + `actions: read`; writes to `dev` stay with HP_PROCESS_TOKEN as before. `workflow_sync` in validate.yml now compares eight files, and test/default-branch-workflows.test.mjs lists the two dispatch-only files explicitly with the reason checked (only `workflow_dispatch`). Workflow tests and the #697 provenance mutant read the bodies. PROCESS.md §10.4, §8 and §11.7 say how these are run and that a new thin file is mirrored into `main` before it is merged into `dev`. Issue: #716 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- .github/workflows/_beta-derived.yml | 215 ++++++++++++++++ .github/workflows/_ship-review.yml | 333 +++++++++++++++++++++++++ .github/workflows/beta-derived.yml | 195 ++------------- .github/workflows/ship-review.yml | 323 ++---------------------- .github/workflows/validate.yml | 11 +- PROCESS.md | 35 ++- scripts/mutation-registry.mjs | 6 +- test/beta-derived.test.mjs | 17 +- test/default-branch-workflows.test.mjs | 27 +- test/ship-review.test.mjs | 5 +- 10 files changed, 669 insertions(+), 498 deletions(-) create mode 100644 .github/workflows/_beta-derived.yml create mode 100644 .github/workflows/_ship-review.yml diff --git a/.github/workflows/_beta-derived.yml b/.github/workflows/_beta-derived.yml new file mode 100644 index 00000000..8e0662a8 --- /dev/null +++ b/.github/workflows/_beta-derived.yml @@ -0,0 +1,215 @@ +name: "Бета: производные артефакты на dev · тело (#716)" + +# #697, PROCESS.md §8 и §11.4: ветки задач не коммитят ни отпечаток и кадры +# скриншотов документации, ни эталоны golden. Их обновляет один коммит бота на +# `dev` перед кандидатом беты; коммит проверяет релиз-менеджер. +# +# - Скриншоты: съёмка на `dev` тем же каноническим способом, что +# `docs-screenshots.yml`, и приёмка `docs:accept --reviewed`. Кадры, которые +# менять не собирались, обязаны совпасть байт-в-байт (свидетели среды); +# изменившийся кадр принимается, только если назван в `docs_expect_change`. +# Ничего не изменилось — обновляется один отпечаток исходников. +# - Golden: артефакт `golden-images` полного Validate на `dev` +# (`golden_run`) и `golden:accept --reviewed` с объявленными сценами. Коммит +# несёт `Release:` и `Baseline-Reviewed:` — провенанс, который требует +# validate-commit-provenance.mjs. +# +# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся +# для просмотра: решение, какие кадры сдвинулись законно, принимает человек. + +on: + # #716: тело вызывается тонким файлом `beta-derived.yml` по ссылке `@dev`; + # кнопка, входы ручного запуска, run-name и concurrency живут там. Без + # файла в ветке по умолчанию GitHub не даёт запустить workflow_dispatch вовсе. + workflow_call: + inputs: + tag: + description: "Beta tag these artifacts are for, for example v1.79.0-beta.1" + required: true + type: string + docs_expect_change: + description: "Doc scenario ids expected to change, comma-separated (empty = none)" + required: false + type: string + default: "" + golden_run: + description: "Validate run id on dev whose golden-images artifact holds the shifted frames (empty = skip golden)" + required: false + type: string + default: "" + golden_expect_change: + description: "Golden scenes expected to change, comma-separated" + required: false + type: string + default: "" + golden_expect_new: + description: "New golden scenes, comma-separated" + required: false + type: string + default: "" + +permissions: + contents: read + +# Concurrency уровня workflow — у вызывающего `beta-derived.yml` (#716). + +jobs: + accept: + name: "Отпечаток, кадры и эталоны — одним коммитом в dev" + runs-on: ubuntu-24.04 + timeout-minutes: 40 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: dev + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - name: Install pinned Chromium + if: steps.pw.outputs.cache-hit != 'true' + run: npx playwright install chromium + - name: Build the bundle the screenshots must come from + run: npm run build + # Тот же закреплённый упаковщик, что у docs-screenshots.yml: другой + # переписал бы все кадры без единого изменённого пикселя. + - name: Установить oxipng + env: + OXIPNG_VERSION: 10.2.0 + OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e + run: | + set -euo pipefail + asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + curl -fsSL -o "$asset" \ + "https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}" + echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c - + mkdir -p "$HOME/.local/bin" + tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \ + "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/oxipng" --version + - name: "Съёмка воспроизводима между прогонами (#410, #422)" + run: node scripts/capture-determinism.mjs + - name: Кадры документации — съёмка и приёмка + id: docs + env: + EXPECT: ${{ inputs.docs_expect_change }} + run: | + set -euo pipefail + node demo/docs/capture.mjs --stability=3 + cand="$RUNNER_TEMP/docs-candidate" + rm -rf "$cand" && mkdir -p "$cand" + cp docs/images/*.png docs/images/screenshots.json "$cand/" + # Приёмка сравнивает кандидата с ЗАКОММИЧЕННЫМИ кадрами: рабочая + # копия возвращается к dev, и заменить файлы может только она. + git checkout -- docs/images + git clean -fdq -- docs/images + args=(--reviewed "--from=$cand") + if [ -n "$EXPECT" ]; then args+=("--expect-change=$EXPECT"); fi + node scripts/docs-accept.mjs "${args[@]}" + if git diff --quiet -- docs/images; then changed=false; else changed=true; fi + echo "changed=$changed" >> "$GITHUB_OUTPUT" + git diff --stat -- docs/images + - name: Сохранить кандидата скриншотов для просмотра + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: docs-candidate-${{ github.run_id }} + path: ${{ runner.temp }}/docs-candidate + if-no-files-found: ignore + retention-days: 7 + - name: Эталоны golden из прогона Validate + id: golden + if: inputs.golden_run != '' + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ inputs.golden_run }} + EXPECT_CHANGE: ${{ inputs.golden_expect_change }} + EXPECT_NEW: ${{ inputs.golden_expect_new }} + run: | + set -euo pipefail + case "$RUN" in ''|*[!0-9]*) echo "::error::golden_run — числовой id прогона Validate"; exit 1 ;; esac + meta=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN" \ + --jq '[.path, .head_branch, .status, .html_url] | @tsv') + IFS=$'\t' read -r path branch status url <<< "$meta" + # Провенанс эталонов — завершённый Validate на dev: другой workflow + # или ветка задачи доказательством для кандидата беты не являются. + if [ "$path" != ".github/workflows/validate.yml" ] || [ "$branch" != "dev" ] || [ "$status" != "completed" ]; then + echo "::error::прогон $RUN — не завершённый Validate на dev ($path, $branch, $status)" + exit 1 + fi + from="$RUNNER_TEMP/golden-candidate" + rm -rf "$from" && mkdir -p "$from" + gh run download "$RUN" --repo "${{ github.repository }}" -n golden-images -D "$from" + # Отчёт обязан быть снят с этого же дерева: accept.mjs сверяет его + # отпечаток с исходниками и откажет, если dev ушёл вперёд. + args=(--reviewed "--from=$from") + if [ -n "$EXPECT_CHANGE" ]; then args+=("--expect-change=$EXPECT_CHANGE"); fi + if [ -n "$EXPECT_NEW" ]; then args+=("--expect-new=$EXPECT_NEW"); fi + node scripts/golden-accept.mjs "${args[@]}" + if git diff --quiet -- demo/golden/baselines; then changed=false; else changed=true; fi + { echo "changed=$changed"; echo "url=$url"; } >> "$GITHUB_OUTPUT" + git diff --stat -- demo/golden/baselines + - name: Коммит в dev + env: + TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + TAG: ${{ inputs.tag }} + DOCS_CHANGED: ${{ steps.docs.outputs.changed }} + DOCS_EXPECT: ${{ inputs.docs_expect_change }} + GOLDEN_CHANGED: ${{ steps.golden.outputs.changed }} + GOLDEN_URL: ${{ steps.golden.outputs.url }} + GOLDEN_EXPECT_CHANGE: ${{ inputs.golden_expect_change }} + GOLDEN_EXPECT_NEW: ${{ inputs.golden_expect_new }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + # Хук pre-push гоняет process-gate; gate:small здесь не нужен — + # продуктового кода коммит не несёт. + HP_PREPUSH_GATE: "0" + run: | + set -euo pipefail + git add -- docs/images demo/golden/baselines + if git diff --cached --quiet; then + echo "::notice::отпечаток, кадры и эталоны на dev уже свежие — коммитить нечего" + exit 0 + fi + msg="$RUNNER_TEMP/message.txt" + { + echo "docs: accept derived artifacts on dev for $TAG" + echo "" + echo "Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697)." + echo "Прогон: $RUN_URL" + if [ "$DOCS_CHANGED" = "true" ]; then + echo "Скриншоты документации: отпечаток исходников; изменённые кадры: ${DOCS_EXPECT:-нет}." + fi + if [ "$GOLDEN_CHANGED" = "true" ]; then + echo "Golden: изменённые сцены: ${GOLDEN_EXPECT_CHANGE:-нет}; новые: ${GOLDEN_EXPECT_NEW:-нет}." + fi + echo "" + if [ "$GOLDEN_CHANGED" = "true" ]; then + echo "Release: $TAG" + echo "Baseline-Reviewed: $GOLDEN_URL" + fi + echo "Issue: #697" + echo "User-Visible: no" + } > "$msg" + git -c user.name="claude[bot]" \ + -c user.email="209825114+claude[bot]@users.noreply.github.com" \ + commit -q -F "$msg" + git show --stat --format='%H %s' HEAD | head -40 + if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then + echo "::error::dev ушёл вперёд за время съёмки — запустить workflow заново: отпечаток судит дерево, а оно уже другое" + exit 1 + fi + echo "### Производные артефакты $TAG" >> "$GITHUB_STEP_SUMMARY" + echo "Коммит \`$(git rev-parse --short HEAD)\` в dev — проверить перед кандидатом беты." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/_ship-review.yml b/.github/workflows/_ship-review.yml new file mode 100644 index 00000000..7b2f0bfc --- /dev/null +++ b/.github/workflows/_ship-review.yml @@ -0,0 +1,333 @@ +name: "Бета: пакетное ревью ship · тело (#716)" + +# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели — +# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна +# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат». +# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в +# диапазоне есть ship-задачи. +# +# Три job, как у release-review.yml (#551, #556): детерминированный вход, +# модель без права записи, детерминированная публикация документа в `dev`. + +on: + # #716: тело вызывается тонким файлом `ship-review.yml` по ссылке `@dev`; + # кнопка, входы ручного запуска, run-name и concurrency живут там. Без + # файла в ветке по умолчанию GitHub не даёт запустить workflow_dispatch вовсе. + workflow_call: + inputs: + tag: + description: "Beta tag the review is for, for example v1.79.0-beta.1" + required: true + type: string + candidate: + description: "Exact candidate SHA; empty = the dev tip" + required: false + type: string + default: "" + force: + description: "Review again even when the document already exists in dev" + required: false + type: boolean + default: false + +permissions: + contents: read + +# Concurrency уровня workflow — у вызывающего `ship-review.yml` (#716). + +jobs: + prepare: + name: "Ship-ревью: задачи диапазона" + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + issues: read + outputs: + proceed: ${{ steps.range.outputs.proceed }} + candidate: ${{ steps.range.outputs.candidate }} + base: ${{ steps.range.outputs.base }} + doc: ${{ steps.range.outputs.doc }} + issues: ${{ steps.range.outputs.issues }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: dev + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + + - name: Кандидат, база и ship-задачи + id: range + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + CANDIDATE: ${{ inputs.candidate }} + FORCE: ${{ inputs.force }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + doc=$(node scripts/ship-review.mjs doc --tag="$TAG") + git fetch -q --tags origin + if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi + git cat-file -e "$CANDIDATE^{commit}" + CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}") + if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then + echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + out="$RUNNER_TEMP/ship-review-input" + node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \ + --out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt" + cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT" + echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT" + issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt") + if [ -z "$issues" ]; then + echo "::notice::ship-задач в диапазоне нет — ревью не нужно" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "proceed=true" >> "$GITHUB_OUTPUT" + (cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256) + cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Передать вход модели + if: steps.range.outputs.proceed == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ship-review-input + if-no-files-found: error + retention-days: 3 + + model_review: + name: "Ship-ревью: работа модели" + needs: prepare + if: needs.prepare.outputs.proceed == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + # Недоверенная стадия без права записи (#556): документ публикует + # `publish`. `github_token` у шага Review обязателен — без него action + # меняет OIDC на собственный App-токен с правом записи. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: ${{ needs.prepare.outputs.candidate }} + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + cache: npm + + - name: Получить вход + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ship-review-input + + - name: Проверить вход и кандидата + env: + CANDIDATE: ${{ needs.prepare.outputs.candidate }} + run: | + (cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256) + test "$(git rev-parse HEAD)" = "$CANDIDATE" + + - name: Установить зависимости + run: npm ci + + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Установить Chromium + if: steps.pw.outputs.cache-hit != 'true' + run: npx playwright install chromium + + # Тот же обход, что у конвейера (_process.yml, anthropics issue 1817). + - name: Установить Claude Code детерминированно + id: claude_bin + run: | + src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1) + ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) + ver="${ver:-2.1.265}" + base=https://downloads.claude.ai/claude-code-releases + bin="$HOME/.local/bin/claude" + mkdir -p "$(dirname "$bin")" + curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin" + sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum') + echo "$sum $bin" | sha256sum -c - + chmod +x "$bin" + "$bin" --version + echo "path=$bin" >> "$GITHUB_OUTPUT" + + - name: Review + id: review + uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1 + env: + REVIEW_DOC: ${{ runner.temp }}/ship-review.md + REVIEW_INPUT: ${{ runner.temp }}/ship-review-input + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }} + prompt: | + Ты ревьюер кода проекта House Plan. Язык ответа — русский. + + Бета: ${{ inputs.tag }} · кандидат ${{ needs.prepare.outputs.candidate }} + (рабочая копия уже на нём) · база диапазона: ${{ needs.prepare.outputs.base || 'нет' }}. + Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md + (строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json. + + Эти задачи шли по треку ship и слиты в dev без ревью модели: правка + в механических рамках и зелёный лёгкий Validate. Ты — первый, кто + читает их код. Правила — docs/process/REVIEWER.md, раздел + «Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их + первыми. + + По каждой задаче: `git show ` каждого коммита; делает ли код + то, что сказано в строке ТЗ, и только это; не ломает ли соседнее + поведение; не вышла ли правка из трека ship по смыслу (новый + UX-контракт, геометрия, конфиг, перф) — это находка Medium с + предложением трека. Исполняй, где дёшево: `npm test`, смоук, + который называет строка ТЗ или выбирает + `node scripts/smoke-select.mjs --base ^ --head `. + + Серьёзность: High — дефект, который увидит пользователь беты, или + правка не делает заявленного; High блокирует публикацию беты. + Medium — поведение вне заявленного или вне рамок трека; Low — + остальное. Находка чинится отдельной задачей, ты ничего не правишь. + + Ты ничего не правишь и не публикуешь: ни код, ни issue, ни + комментарии. Права на запись у тебя нет. Любые изменения рабочей + копии будут отброшены — после проверок восстанови её сам + (`git checkout -- . && git clean -fd`), если что-то менял. + + Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный, + вне репозитория). Первой строкой после заголовка — + `Итог: High N · Medium N · Low N`. Затем по задаче на раздел + `## #NN — заголовок`: что проверил (команда → результат), находки с + воспроизведением, чего не проверял. + + Затем верни JSON по схеме — последнее обязательное действие. + claude_args: | + --max-turns 150 + --allowedTools Read,Write,Grep,Glob,Bash + --json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}' + + - name: Запечатать результат модели + env: + SOURCE: ${{ runner.temp }}/ship-review.md + OUT: ${{ steps.review.outputs.structured_output }} + run: | + test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; } + dir="$RUNNER_TEMP/ship-review-result" + mkdir -p "$dir" + printf '%s' "$OUT" > "$dir/result.json" + jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \ + "$dir/result.json" >/dev/null + cp "$SOURCE" "$dir/ship-review.md" + (cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256) + + - name: Передать результат публикации + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ship-review-result + if-no-files-found: error + retention-days: 3 + + publish: + name: "Ship-ревью: документ в dev" + needs: [prepare, model_review] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: dev + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + + - name: Получить результат модели + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ship-review-result + + # Модель пишет только текст. Путь, машинный блок (его читает гейт беты), + # индекс и коммит решает этот шаг. + - name: Опубликовать документ + env: + TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + TAG: ${{ inputs.tag }} + DOC: ${{ needs.prepare.outputs.doc }} + CANDIDATE: ${{ needs.prepare.outputs.candidate }} + BASE: ${{ needs.prepare.outputs.base }} + ISSUES: ${{ needs.prepare.outputs.issues }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + dir="$RUNNER_TEMP/ship-review-result" + (cd "$dir" && sha256sum -c manifest.sha256) + test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG")" + high=$(jq -r '.high' "$dir/result.json") + medium=$(jq -r '.medium' "$dir/result.json") + low=$(jq -r '.low' "$dir/result.json") + for attempt in 1 2 3; do + git fetch -q origin dev + git reset -q --hard origin/dev + git clean -fdq + mkdir -p docs/reviews + { + cat "$dir/ship-review.md" + printf '\n\n' + node -e ' + import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({ + tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null, + issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number), + high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]), + runUrl: process.env.RUN_URL, + }))); + ' "$high" "$medium" "$low" + } > "$DOC" + node scripts/reviews-index.mjs --dir=docs/reviews --strict + git add -- "$DOC" docs/reviews/INDEX.md + git diff --cached --name-only | node scripts/review-doc-guard.mjs + git -c user.name="claude[bot]" \ + -c user.email="209825114+claude[bot]@users.noreply.github.com" \ + commit -q -F - <> "$GITHUB_STEP_SUMMARY" + echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY" + echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low" + exit 0 + fi + echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново" + sleep $((attempt * 10)) + done + echo "::error::документ ревью не опубликован в dev за три попытки" + exit 1 diff --git a/.github/workflows/beta-derived.yml b/.github/workflows/beta-derived.yml index b0bb4e45..fdc33c3f 100644 --- a/.github/workflows/beta-derived.yml +++ b/.github/workflows/beta-derived.yml @@ -1,22 +1,17 @@ name: "Бета: производные артефакты на dev" run-name: "Derived artifacts ${{ inputs.tag }}" -# #697, PROCESS.md §8 и §11.4: ветки задач не коммитят ни отпечаток и кадры -# скриншотов документации, ни эталоны golden. Их обновляет один коммит бота на -# `dev` перед кандидатом беты; коммит проверяет релиз-менеджер. -# -# - Скриншоты: съёмка на `dev` тем же каноническим способом, что -# `docs-screenshots.yml`, и приёмка `docs:accept --reviewed`. Кадры, которые -# менять не собирались, обязаны совпасть байт-в-байт (свидетели среды); -# изменившийся кадр принимается, только если назван в `docs_expect_change`. -# Ничего не изменилось — обновляется один отпечаток исходников. -# - Golden: артефакт `golden-images` полного Validate на `dev` -# (`golden_run`) и `golden:accept --reviewed` с объявленными сценами. Коммит -# несёт `Release:` и `Baseline-Reviewed:` — провенанс, который требует -# validate-commit-provenance.mjs. -# -# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся -# для просмотра: решение, какие кадры сдвинулись законно, принимает человек. +# Тонкий вызывающий файл (#716, устройство #623). `workflow_dispatch` GitHub +# исполняет с выбранной ветки, но кнопку и сам запуск (`gh workflow run`) +# даёт только workflow, чей файл лежит в ветке по умолчанию (`main`). Поэтому +# здесь только то, что обязано жить там: триггер, входы ручного запуска, +# run-name, права и concurrency. Тело — `_beta-derived.yml` по ссылке `@dev`: +# правка съёмки и приёмки — один коммит в `dev`, а запуск с любой ветки (в +# кнопке по умолчанию выбрана `main`) исполняет тело из `dev`. +# Этот файл меняется, только когда меняются входы или потолок прав; тогда он +# зеркалится в `main`, и preflight `workflow_sync` (validate.yml) держит копии +# равными. + on: workflow_dispatch: inputs: @@ -53,162 +48,18 @@ concurrency: cancel-in-progress: false jobs: - accept: - name: "Отпечаток, кадры и эталоны — одним коммитом в dev" - runs-on: ubuntu-24.04 - timeout-minutes: 40 + # Потолок прав тела: объединение job-level прав `_beta-derived.yml`. Вызываемый + # workflow может права только сузить, поэтому каждая его job по-прежнему + # получает свой прежний минимум (#556), а шире этого набора не получит никто. + dev: permissions: contents: read actions: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: dev - fetch-depth: 0 - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - cache: npm - - run: npm ci - - name: Кэш браузеров Playwright - id: pw - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - - name: Install pinned Chromium - if: steps.pw.outputs.cache-hit != 'true' - run: npx playwright install chromium - - name: Build the bundle the screenshots must come from - run: npm run build - # Тот же закреплённый упаковщик, что у docs-screenshots.yml: другой - # переписал бы все кадры без единого изменённого пикселя. - - name: Установить oxipng - env: - OXIPNG_VERSION: 10.2.0 - OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e - run: | - set -euo pipefail - asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz" - curl -fsSL -o "$asset" \ - "https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}" - echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c - - mkdir -p "$HOME/.local/bin" - tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \ - "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - "$HOME/.local/bin/oxipng" --version - - name: "Съёмка воспроизводима между прогонами (#410, #422)" - run: node scripts/capture-determinism.mjs - - name: Кадры документации — съёмка и приёмка - id: docs - env: - EXPECT: ${{ inputs.docs_expect_change }} - run: | - set -euo pipefail - node demo/docs/capture.mjs --stability=3 - cand="$RUNNER_TEMP/docs-candidate" - rm -rf "$cand" && mkdir -p "$cand" - cp docs/images/*.png docs/images/screenshots.json "$cand/" - # Приёмка сравнивает кандидата с ЗАКОММИЧЕННЫМИ кадрами: рабочая - # копия возвращается к dev, и заменить файлы может только она. - git checkout -- docs/images - git clean -fdq -- docs/images - args=(--reviewed "--from=$cand") - if [ -n "$EXPECT" ]; then args+=("--expect-change=$EXPECT"); fi - node scripts/docs-accept.mjs "${args[@]}" - if git diff --quiet -- docs/images; then changed=false; else changed=true; fi - echo "changed=$changed" >> "$GITHUB_OUTPUT" - git diff --stat -- docs/images - - name: Сохранить кандидата скриншотов для просмотра - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: docs-candidate-${{ github.run_id }} - path: ${{ runner.temp }}/docs-candidate - if-no-files-found: ignore - retention-days: 7 - - name: Эталоны golden из прогона Validate - id: golden - if: inputs.golden_run != '' - env: - GH_TOKEN: ${{ github.token }} - RUN: ${{ inputs.golden_run }} - EXPECT_CHANGE: ${{ inputs.golden_expect_change }} - EXPECT_NEW: ${{ inputs.golden_expect_new }} - run: | - set -euo pipefail - case "$RUN" in ''|*[!0-9]*) echo "::error::golden_run — числовой id прогона Validate"; exit 1 ;; esac - meta=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN" \ - --jq '[.path, .head_branch, .status, .html_url] | @tsv') - IFS=$'\t' read -r path branch status url <<< "$meta" - # Провенанс эталонов — завершённый Validate на dev: другой workflow - # или ветка задачи доказательством для кандидата беты не являются. - if [ "$path" != ".github/workflows/validate.yml" ] || [ "$branch" != "dev" ] || [ "$status" != "completed" ]; then - echo "::error::прогон $RUN — не завершённый Validate на dev ($path, $branch, $status)" - exit 1 - fi - from="$RUNNER_TEMP/golden-candidate" - rm -rf "$from" && mkdir -p "$from" - gh run download "$RUN" --repo "${{ github.repository }}" -n golden-images -D "$from" - # Отчёт обязан быть снят с этого же дерева: accept.mjs сверяет его - # отпечаток с исходниками и откажет, если dev ушёл вперёд. - args=(--reviewed "--from=$from") - if [ -n "$EXPECT_CHANGE" ]; then args+=("--expect-change=$EXPECT_CHANGE"); fi - if [ -n "$EXPECT_NEW" ]; then args+=("--expect-new=$EXPECT_NEW"); fi - node scripts/golden-accept.mjs "${args[@]}" - if git diff --quiet -- demo/golden/baselines; then changed=false; else changed=true; fi - { echo "changed=$changed"; echo "url=$url"; } >> "$GITHUB_OUTPUT" - git diff --stat -- demo/golden/baselines - - name: Коммит в dev - env: - TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - TAG: ${{ inputs.tag }} - DOCS_CHANGED: ${{ steps.docs.outputs.changed }} - DOCS_EXPECT: ${{ inputs.docs_expect_change }} - GOLDEN_CHANGED: ${{ steps.golden.outputs.changed }} - GOLDEN_URL: ${{ steps.golden.outputs.url }} - GOLDEN_EXPECT_CHANGE: ${{ inputs.golden_expect_change }} - GOLDEN_EXPECT_NEW: ${{ inputs.golden_expect_new }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - # Хук pre-push гоняет process-gate; gate:small здесь не нужен — - # продуктового кода коммит не несёт. - HP_PREPUSH_GATE: "0" - run: | - set -euo pipefail - git add -- docs/images demo/golden/baselines - if git diff --cached --quiet; then - echo "::notice::отпечаток, кадры и эталоны на dev уже свежие — коммитить нечего" - exit 0 - fi - msg="$RUNNER_TEMP/message.txt" - { - echo "docs: accept derived artifacts on dev for $TAG" - echo "" - echo "Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697)." - echo "Прогон: $RUN_URL" - if [ "$DOCS_CHANGED" = "true" ]; then - echo "Скриншоты документации: отпечаток исходников; изменённые кадры: ${DOCS_EXPECT:-нет}." - fi - if [ "$GOLDEN_CHANGED" = "true" ]; then - echo "Golden: изменённые сцены: ${GOLDEN_EXPECT_CHANGE:-нет}; новые: ${GOLDEN_EXPECT_NEW:-нет}." - fi - echo "" - if [ "$GOLDEN_CHANGED" = "true" ]; then - echo "Release: $TAG" - echo "Baseline-Reviewed: $GOLDEN_URL" - fi - echo "Issue: #697" - echo "User-Visible: no" - } > "$msg" - git -c user.name="claude[bot]" \ - -c user.email="209825114+claude[bot]@users.noreply.github.com" \ - commit -q -F "$msg" - git show --stat --format='%H %s' HEAD | head -40 - if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then - echo "::error::dev ушёл вперёд за время съёмки — запустить workflow заново: отпечаток судит дерево, а оно уже другое" - exit 1 - fi - echo "### Производные артефакты $TAG" >> "$GITHUB_STEP_SUMMARY" - echo "Коммит \`$(git rev-parse --short HEAD)\` в dev — проверить перед кандидатом беты." >> "$GITHUB_STEP_SUMMARY" + uses: Matysh/houseplan-card/.github/workflows/_beta-derived.yml@dev # #716: тело съёмки и приёмки из dev + with: + tag: ${{ inputs.tag }} + docs_expect_change: ${{ inputs.docs_expect_change }} + golden_run: ${{ inputs.golden_run }} + golden_expect_change: ${{ inputs.golden_expect_change }} + golden_expect_new: ${{ inputs.golden_expect_new }} + secrets: inherit diff --git a/.github/workflows/ship-review.yml b/.github/workflows/ship-review.yml index 10d99186..3af2862d 100644 --- a/.github/workflows/ship-review.yml +++ b/.github/workflows/ship-review.yml @@ -1,16 +1,17 @@ name: "Бета: пакетное ревью ship" run-name: "Ship review ${{ inputs.tag }}" -# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели — -# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна -# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат». -# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в -# диапазоне есть ship-задачи. -# -# Только `workflow_dispatch`: файл исполняется с ветки прогона (`--ref dev`), -# зеркало в `main` не нужно. Три job, как у release-review.yml (#551, #556): -# детерминированный вход, модель без права записи, детерминированная -# публикация документа в `dev`. +# Тонкий вызывающий файл (#716, устройство #623). `workflow_dispatch` GitHub +# исполняет с выбранной ветки, но кнопку и сам запуск (`gh workflow run`) +# даёт только workflow, чей файл лежит в ветке по умолчанию (`main`). Поэтому +# здесь только то, что обязано жить там: триггер, входы ручного запуска, +# run-name, права и concurrency. Тело — `_ship-review.yml` по ссылке `@dev`: +# правка ревью — один коммит в `dev`, а запуск с любой ветки (в кнопке по +# умолчанию выбрана `main`) исполняет тело из `dev`. +# Этот файл меняется, только когда меняются входы или потолок прав; тогда он +# зеркалится в `main`, и preflight `workflow_sync` (validate.yml) держит копии +# равными. + on: workflow_dispatch: inputs: @@ -37,298 +38,16 @@ concurrency: cancel-in-progress: false jobs: - prepare: - name: "Ship-ревью: задачи диапазона" - runs-on: ubuntu-24.04 - timeout-minutes: 10 + # Потолок прав тела: объединение job-level прав `_ship-review.yml`. Вызываемый + # workflow может права только сузить, поэтому каждая его job по-прежнему + # получает свой прежний минимум (#556), а шире этого набора не получит никто. + dev: permissions: contents: read issues: read - outputs: - proceed: ${{ steps.range.outputs.proceed }} - candidate: ${{ steps.range.outputs.candidate }} - base: ${{ steps.range.outputs.base }} - doc: ${{ steps.range.outputs.doc }} - issues: ${{ steps.range.outputs.issues }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - ref: dev - persist-credentials: false - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - - - name: Кандидат, база и ship-задачи - id: range - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag }} - CANDIDATE: ${{ inputs.candidate }} - FORCE: ${{ inputs.force }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - doc=$(node scripts/ship-review.mjs doc --tag="$TAG") - git fetch -q --tags origin - if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi - git cat-file -e "$CANDIDATE^{commit}" - CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}") - if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then - echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)" - echo "proceed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - out="$RUNNER_TEMP/ship-review-input" - node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \ - --out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt" - cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT" - echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT" - issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt") - if [ -z "$issues" ]; then - echo "::notice::ship-задач в диапазоне нет — ревью не нужно" - echo "proceed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "proceed=true" >> "$GITHUB_OUTPUT" - (cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256) - cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY" - - - name: Передать вход модели - if: steps.range.outputs.proceed == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/ship-review-input - if-no-files-found: error - retention-days: 3 - - model_review: - name: "Ship-ревью: работа модели" - needs: prepare - if: needs.prepare.outputs.proceed == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - # Недоверенная стадия без права записи (#556): документ публикует - # `publish`. `github_token` у шага Review обязателен — без него action - # меняет OIDC на собственный App-токен с правом записи. - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - ref: ${{ needs.prepare.outputs.candidate }} - persist-credentials: false - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - cache: npm - - - name: Получить вход - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/ship-review-input - - - name: Проверить вход и кандидата - env: - CANDIDATE: ${{ needs.prepare.outputs.candidate }} - run: | - (cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256) - test "$(git rev-parse HEAD)" = "$CANDIDATE" - - - name: Установить зависимости - run: npm ci - - - name: Кэш браузеров Playwright - id: pw - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - - - name: Установить Chromium - if: steps.pw.outputs.cache-hit != 'true' - run: npx playwright install chromium - - # Тот же обход, что у конвейера (_process.yml, anthropics issue 1817). - - name: Установить Claude Code детерминированно - id: claude_bin - run: | - src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1) - ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) - ver="${ver:-2.1.265}" - base=https://downloads.claude.ai/claude-code-releases - bin="$HOME/.local/bin/claude" - mkdir -p "$(dirname "$bin")" - curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin" - sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum') - echo "$sum $bin" | sha256sum -c - - chmod +x "$bin" - "$bin" --version - echo "path=$bin" >> "$GITHUB_OUTPUT" - - - name: Review - id: review - uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1 - env: - REVIEW_DOC: ${{ runner.temp }}/ship-review.md - REVIEW_INPUT: ${{ runner.temp }}/ship-review-input - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - github_token: ${{ secrets.GITHUB_TOKEN }} - path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }} - prompt: | - Ты ревьюер кода проекта House Plan. Язык ответа — русский. - - Бета: ${{ inputs.tag }} · кандидат ${{ needs.prepare.outputs.candidate }} - (рабочая копия уже на нём) · база диапазона: ${{ needs.prepare.outputs.base || 'нет' }}. - Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md - (строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json. - - Эти задачи шли по треку ship и слиты в dev без ревью модели: правка - в механических рамках и зелёный лёгкий Validate. Ты — первый, кто - читает их код. Правила — docs/process/REVIEWER.md, раздел - «Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их - первыми. - - По каждой задаче: `git show ` каждого коммита; делает ли код - то, что сказано в строке ТЗ, и только это; не ломает ли соседнее - поведение; не вышла ли правка из трека ship по смыслу (новый - UX-контракт, геометрия, конфиг, перф) — это находка Medium с - предложением трека. Исполняй, где дёшево: `npm test`, смоук, - который называет строка ТЗ или выбирает - `node scripts/smoke-select.mjs --base ^ --head `. - - Серьёзность: High — дефект, который увидит пользователь беты, или - правка не делает заявленного; High блокирует публикацию беты. - Medium — поведение вне заявленного или вне рамок трека; Low — - остальное. Находка чинится отдельной задачей, ты ничего не правишь. - - Ты ничего не правишь и не публикуешь: ни код, ни issue, ни - комментарии. Права на запись у тебя нет. Любые изменения рабочей - копии будут отброшены — после проверок восстанови её сам - (`git checkout -- . && git clean -fd`), если что-то менял. - - Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный, - вне репозитория). Первой строкой после заголовка — - `Итог: High N · Medium N · Low N`. Затем по задаче на раздел - `## #NN — заголовок`: что проверил (команда → результат), находки с - воспроизведением, чего не проверял. - - Затем верни JSON по схеме — последнее обязательное действие. - claude_args: | - --max-turns 150 - --allowedTools Read,Write,Grep,Glob,Bash - --json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}' - - - name: Запечатать результат модели - env: - SOURCE: ${{ runner.temp }}/ship-review.md - OUT: ${{ steps.review.outputs.structured_output }} - run: | - test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; } - dir="$RUNNER_TEMP/ship-review-result" - mkdir -p "$dir" - printf '%s' "$OUT" > "$dir/result.json" - jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \ - "$dir/result.json" >/dev/null - cp "$SOURCE" "$dir/ship-review.md" - (cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256) - - - name: Передать результат публикации - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/ship-review-result - if-no-files-found: error - retention-days: 3 - - publish: - name: "Ship-ревью: документ в dev" - needs: [prepare, model_review] - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - ref: dev - persist-credentials: false - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - - - name: Получить результат модели - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/ship-review-result - - # Модель пишет только текст. Путь, машинный блок (его читает гейт беты), - # индекс и коммит решает этот шаг. - - name: Опубликовать документ - env: - TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - TAG: ${{ inputs.tag }} - DOC: ${{ needs.prepare.outputs.doc }} - CANDIDATE: ${{ needs.prepare.outputs.candidate }} - BASE: ${{ needs.prepare.outputs.base }} - ISSUES: ${{ needs.prepare.outputs.issues }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - dir="$RUNNER_TEMP/ship-review-result" - (cd "$dir" && sha256sum -c manifest.sha256) - test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG")" - high=$(jq -r '.high' "$dir/result.json") - medium=$(jq -r '.medium' "$dir/result.json") - low=$(jq -r '.low' "$dir/result.json") - for attempt in 1 2 3; do - git fetch -q origin dev - git reset -q --hard origin/dev - git clean -fdq - mkdir -p docs/reviews - { - cat "$dir/ship-review.md" - printf '\n\n' - node -e ' - import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({ - tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null, - issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number), - high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]), - runUrl: process.env.RUN_URL, - }))); - ' "$high" "$medium" "$low" - } > "$DOC" - node scripts/reviews-index.mjs --dir=docs/reviews --strict - git add -- "$DOC" docs/reviews/INDEX.md - git diff --cached --name-only | node scripts/review-doc-guard.mjs - git -c user.name="claude[bot]" \ - -c user.email="209825114+claude[bot]@users.noreply.github.com" \ - commit -q -F - <> "$GITHUB_STEP_SUMMARY" - echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY" - echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low" - exit 0 - fi - echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново" - sleep $((attempt * 10)) - done - echo "::error::документ ревью не опубликован в dev за три попытки" - exit 1 + uses: Matysh/houseplan-card/.github/workflows/_ship-review.yml@dev # #716: тело ревью из dev + with: + tag: ${{ inputs.tag }} + candidate: ${{ inputs.candidate }} + force: ${{ inputs.force }} + secrets: inherit diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 80c3aab8..849a69d0 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -124,9 +124,16 @@ jobs: # ни одним событием. Тонкий файл сверяется: правка триггера или потолка # прав, не доехавшая до `main`, действовала бы только в dev-копии. # + # #716: `ship-review.yml` и `beta-derived.yml` — только `workflow_dispatch`, + # он исполняет файл с выбранной ветки, но запуск GitHub даёт лишь workflow, + # чей файл есть в `main`. Шагам беты он нужен до промоушена, поэтому они + # устроены так же и сверяются здесь же: правка входа или потолка прав, + # не доехавшая до `main`, не действовала бы в запуске с `main`, а именно + # его кнопка выбирает по умолчанию. + # # `performance.yml` не входит: по расписанию он судит `main` собственным # телом из `main`, расхождение с `dev` до промоушена законно. Список ниже - # держит равным множеству файлов с такими триггерами + # держит равным множеству тонких файлов # test/default-branch-workflows.test.mjs. - name: "Процесс: тонкие вызывающие workflow идентичны в main и dev" id: workflow_sync @@ -134,7 +141,7 @@ jobs: run: | git fetch --quiet origin main dev status=0 - for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml; do + for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml ship-review.yml beta-derived.yml; do if diff <(git show "origin/main:.github/workflows/$file") \ <(git show "origin/dev:.github/workflows/$file"); then echo "$file: main и dev идентичны" diff --git a/PROCESS.md b/PROCESS.md index 295ba043..5bad8e0f 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -852,7 +852,11 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \ (`golden:accept --reviewed`, `Release:` и `Baseline-Reviewed:` в коммите). Изменившийся кадр или сцена принимается, только если назван во входах workflow; необъявленная разница — отказ с перечнем. Коммит проверяет - релиз-менеджер; + релиз-менеджер. Запуск — `gh workflow run beta-derived.yml --ref dev -f + tag=vX.Y.Z-beta.N` (сдвиги — `-f docs_expect_change=…`, эталоны — `-f + golden_run=` с `golden_expect_change`/`golden_expect_new`) + или кнопка в Actions: тонкий вызывающий лежит и в `main`, тело + `_beta-derived.yml` читается из `dev` (§10.4, #716); - **задача, которая меняет визуал намеренно**, ставит `ci:golden`: конвейер прогоняет полный набор на материале ревью, и сдвинутые кадры задача принимает сама — по §3 п.13; @@ -1170,12 +1174,16 @@ Medium-находки вне скоупа задачи (#202), кладёт до 4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера. -**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623). Для -событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из -`main`. Таких файлов шесть: `process.yml`, `process-resume.yml`, +**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623, #716). +Для событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из +`main`. `workflow_dispatch` исполняет файл с выбранной ветки, но кнопку и сам +запуск (`gh workflow run`, API) даёт только workflow, чей файл лежит в `main`: +файл, добавленный в `dev`, до промоушена не запускается вовсе (#716). Таких +файлов восемь: по событиям — `process.yml`, `process-resume.yml`, `process-reconcile.yml`, `mutation-gate.yml`, `nightly.yml`, -`process-metrics.yml`. Каждый — тонкий вызывающий: триггеры, run-name, -права, concurrency и одна job `uses: +`process-metrics.yml`; по кнопке, но до промоушена — шаги беты +`ship-review.yml` (§11.7) и `beta-derived.yml` (§8). Каждый — тонкий +вызывающий: триггеры, run-name, права, concurrency и одна job `uses: Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`. Тело `_<имя>.yml` читается из `dev` в момент запуска, поэтому **правка конвейера — один коммит в `dev`**, зеркало в `main` и возврат `main` в `dev` @@ -1184,14 +1192,18 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit` получает прежний минимум (#556). Тонкий файл меняется, только когда меняются триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в `main`, и preflight `workflow_sync` в `validate.yml` держит копии равными — -сверяются ровно эти шесть файлов, список держит -`test/default-branch-workflows.test.mjs`. Расхождение красит push в `dev` и +сверяются ровно эти восемь файлов, список держит +`test/default-branch-workflows.test.mjs`. Новый тонкий файл сначала +зеркалится в `main`, затем сливается в `dev`: в обратном порядке push в `dev` +найдёт файл, которого нет в `main`. Расхождение красит push в `dev` и заводит одно issue владельцу (`[workflow-sync]`), а на ветке задачи — предупреждение в сводке (#700): к её изменению оно отношения не имеет, и чинит его тот, кто зеркалит в `main`. Так же судятся внешние ссылки документации (`check-docs --external=warn` на ветках `issue/*`): упавший чужой сайт не возвращает задачу. `performance.yml` в список не входит: -по расписанию он судит `main` собственным телом из `main`. +по расписанию он судит `main` собственным телом из `main`. Прочие workflow по +кнопке приезжают в `main` целиком с промоушеном; новый такой файл, который +нужен до него, заводится тонким и входит в список. **Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и @@ -1547,7 +1559,10 @@ action ревью пускает ровно этого бота (`allowed_bots`, диапазона дешевле ревью на каждую, а рамки ship держат объём малым. **Шаг.** Перед публикацией беты — `ship-review.yml` -(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N`): +(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N` или кнопка +«Run workflow» в Actions). Это тонкий вызывающий файл, он лежит и в `main`: +без этого GitHub запуск не даёт. Тело `_ship-review.yml` читается из `dev` при +любой выбранной ветке (§10.4, #716): - **вход** — issue из трейлеров `Issue: #NN` в диапазоне «прошлый тег..кандидат» (тот же построитель, что `RELEASE-MEMBERSHIP.json`, #547), из них — ship: с diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 19644e2e..600ef709 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -10838,8 +10838,8 @@ const MUTANT_DEFINITIONS = [ + 'доезжала до dev и молча не действовала', patches: [{ file: '.github/workflows/validate.yml', - find: ' for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml; do', - replace: ' for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml; do', + find: ' for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml ship-review.yml beta-derived.yml; do', + replace: ' for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml ship-review.yml beta-derived.yml; do', }], }, { @@ -13798,7 +13798,7 @@ const MUTANT_DEFINITIONS = [ because: '#697: the bot commit that moves baselines must carry Release: and Baseline-Reviewed:, ' + 'otherwise validate-commit-provenance turns dev red on the push', patches: [{ - file: '.github/workflows/beta-derived.yml', + file: '.github/workflows/_beta-derived.yml', find: ' echo "Release: $TAG"\n', replace: '', }], diff --git a/test/beta-derived.test.mjs b/test/beta-derived.test.mjs index aa6635e7..25096d2c 100644 --- a/test/beta-derived.test.mjs +++ b/test/beta-derived.test.mjs @@ -7,7 +7,10 @@ import { fileURLToPath } from 'node:url'; import { validateCommitMessage } from '../scripts/validate-commit-provenance.mjs'; import { isCandidateSubject } from '../scripts/bundle-policy.mjs'; -const WORKFLOW = readFileSync(fileURLToPath(new URL('../.github/workflows/beta-derived.yml', import.meta.url)), 'utf8'); +const readWorkflow = (name) => readFileSync(fileURLToPath(new URL(`../.github/workflows/${name}`, import.meta.url)), 'utf8'); +// #716: кнопка — у тонкого вызывающего, который лежит и в `main`; шаги — в теле из `dev`. +const CALLER = readWorkflow('beta-derived.yml'); +const WORKFLOW = readWorkflow('_beta-derived.yml'); const step = (name) => { const start = WORKFLOW.indexOf(` - name: ${name}\n`); assert.ok(start > 0, `нет шага ${name}`); @@ -16,10 +19,14 @@ const step = (name) => { }; test('#697 бот: только по кнопке, прав на запись у job нет — пишет PAT одним push', () => { - assert.match(WORKFLOW, /^on:\n workflow_dispatch:\n/m); - assert.doesNotMatch(WORKFLOW, /^\s+(push|schedule|workflow_run):/m); - assert.match(WORKFLOW, /permissions:\n\s+contents: read\n\s+actions: read\n/); - assert.doesNotMatch(WORKFLOW, /contents: write/); + assert.match(CALLER, /^on:\n workflow_dispatch:\n/m); + assert.match(CALLER, /uses: Matysh\/houseplan-card\/\.github\/workflows\/_beta-derived\.yml@dev\b/); + assert.match(WORKFLOW, /^on:\n(?: {2}#[^\n]*\n)* {2}workflow_call:\n/m); + for (const text of [CALLER, WORKFLOW]) { + assert.doesNotMatch(text, /^\s+(push|schedule|workflow_run):/m); + assert.match(text, /permissions:\n\s+contents: read\n\s+actions: read\n/); + assert.doesNotMatch(text, /contents: write/); + } const commit = step('Коммит в dev'); assert.match(commit, /git push -q "https:\/\/x-access-token:\$TOKEN@github\.com\/\$\{\{ github\.repository \}\}" HEAD:dev/); assert.doesNotMatch(commit, /--force/, 'ушедший dev — перезапуск, а не перезапись'); diff --git a/test/default-branch-workflows.test.mjs b/test/default-branch-workflows.test.mjs index 743b5651..5dd90020 100644 --- a/test/default-branch-workflows.test.mjs +++ b/test/default-branch-workflows.test.mjs @@ -10,6 +10,11 @@ // держит проводку: каждый такой файл тонкий, вызывает своё тело из dev, передаёт // секреты и входы, не расширяет права, а preflight сверяет ровно их. // +// #716: `workflow_dispatch` GitHub исполняет с выбранной ветки, но запуск (кнопку, +// `gh workflow run`, API dispatches) даёт только workflow, чей файл лежит в +// `main`. Такие файлы обычно приезжают туда с промоушеном; шагам беты запуск +// нужен раньше, поэтому они устроены так же и сверяются тем же preflight. +// // Полноценного YAML-парсера в зависимостях нет (см. scripts/workflow-jobs.mjs): // разбирается ровно та блочная структура, которую пишут эти файлы; всё // неожиданное — громкая ошибка разбора, а не догадка. @@ -37,6 +42,12 @@ const DEFAULT_BRANCH_EVENTS = new Set([ // исполняемая копия и есть та, что лежит рядом с проверяемым кодом. const SELF_CONSISTENT = new Set(['performance.yml']); +// #716: только по кнопке, но нужны до промоушена, который принёс бы их в `main`: +// пакетное ревью ship (PROCESS.md §11.7) и производные артефакты (§8) идут перед +// каждой бетой. Триггер их не выдаёт — `workflow_dispatch` исполняется с +// выбранной ветки, — поэтому список явный, а причина проверяется ниже. +const DISPATCH_BEFORE_PROMOTION = new Set(['beta-derived.yml', 'ship-review.yml']); + const isComment = (line) => !line.trim() || line.trimStart().startsWith('#'); const indentOf = (line) => line.length - line.trimStart().length; @@ -113,7 +124,7 @@ const workflows = readdirSync(DIR).filter((name) => /\.ya?ml$/.test(name)).sort( const bodies = workflows.filter((name) => name.startsWith('_')); const fromDefaultBranch = workflows .filter((name) => !name.startsWith('_') && triggers(name).some((event) => DEFAULT_BRANCH_EVENTS.has(event))); -const THIN = fromDefaultBranch.filter((name) => !SELF_CONSISTENT.has(name)); +const THIN = [...fromDefaultBranch.filter((name) => !SELF_CONSISTENT.has(name)), ...DISPATCH_BEFORE_PROMOTION].sort(); function syncList() { const validate = read('validate.yml'); @@ -124,13 +135,25 @@ function syncList() { } test('#623: исполняемые из main файлы найдены по триггерам, а не по памяти', () => { - assert.deepEqual(THIN, [ + assert.deepEqual(fromDefaultBranch.filter((name) => !SELF_CONSISTENT.has(name)), [ 'mutation-gate.yml', 'nightly.yml', 'process-metrics.yml', 'process-reconcile.yml', 'process-resume.yml', 'process.yml', ]); for (const name of SELF_CONSISTENT) assert.ok(fromDefaultBranch.includes(name), `${name}: исключение без причины`); }); +test('#716: шаги беты по кнопке — тонкие файлы наравне с исполняемыми из main', () => { + assert.deepEqual(THIN, [ + 'beta-derived.yml', 'mutation-gate.yml', 'nightly.yml', 'process-metrics.yml', 'process-reconcile.yml', + 'process-resume.yml', 'process.yml', 'ship-review.yml', + ]); + for (const name of DISPATCH_BEFORE_PROMOTION) { + assert.ok(workflows.includes(name), `${name}: файла нет — запись в списке без причины`); + assert.deepEqual(triggers(name), ['workflow_dispatch'], + `${name}: только кнопка — иначе файл находится по триггеру, и явная запись не нужна`); + } +}); + test('#623: preflight сверяет в main и dev ровно тонкие вызывающие файлы', () => { const list = syncList(); assert.equal(new Set(list).size, list.length, 'в списке сверки нет повторов'); diff --git a/test/ship-review.test.mjs b/test/ship-review.test.mjs index 2581aeb4..ccbed11f 100644 --- a/test/ship-review.test.mjs +++ b/test/ship-review.test.mjs @@ -93,8 +93,9 @@ test('#696 индекс и архив знают SHIP-REVIEW: бета в инд assert.ok(plan.kept.some((k) => k.name === 'SHIP-REVIEW-v1.80.0-beta.1.md')); }); -test('#696 ship-review.yml: модель без права записи, документ с машинным блоком в dev', () => { - const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/ship-review.yml', import.meta.url)), 'utf8'); +test('#696 _ship-review.yml: модель без права записи, документ с машинным блоком в dev', () => { + // #716: тело из dev; тонкий `ship-review.yml` в main — кнопка и потолок прав. + const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_ship-review.yml', import.meta.url)), 'utf8'); const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:')); assert.match(model, /permissions:\n\s+contents: read\n\s+steps:/, 'модель только читает'); assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, 'без обмена OIDC на App-токен (#556)');