From e45bc87c6da4b673ec261536859c8520ccc5c09c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:49:00 +0300 Subject: [PATCH] process: preflight does not fail a task branch for foreign causes (#700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 of 85 returns in #600–#691 were the thin-workflow mirror check, and any push could turn red because a foreign site behind a docs link was down. - validate.yml preflight: on refs/heads/issue/* the workflow_sync mismatch is a warning in the summary, not a failed verdict; push to dev, the beta candidate and the release keep it red. - On push to dev a mismatch opens one owner issue titled [workflow-sync] (or comments on the open one), like the nightly mutation gate (#472); preflight gets issues: write for that. - check-docs --external=warn: external link failures become warnings; the docs step passes it on task branches only. Canon: PROCESS.md §10.4 («Workflow из ветки по умолчанию»). Issue: #700 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- .github/workflows/validate.yml | 61 ++++++++++++++++++++++++++++++--- PROCESS.md | 7 +++- scripts/check-docs.mjs | 12 +++++-- scripts/mutation-registry.mjs | 23 +++++++++++++ test/classify-changes.test.mjs | 2 +- test/docs-freshness.test.mjs | 8 ++++- test/validate-workflow.test.mjs | 26 +++++++++++++- 7 files changed, 127 insertions(+), 12 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c72a09dc..f6975991 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -53,12 +53,13 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 # `actions: read` — чтение списка прогонов Validate (#388), `issues: read` — - # проверка 8 процессного гейта. Права перечислены явно, потому что job - # обращается к API сверх содержимого репозитория. + # проверка 8 процессного гейта, `issues: write` — одно issue о расхождении + # зеркала workflow на push в dev (#700). Права перечислены явно, потому что + # job обращается к API сверх содержимого репозитория. permissions: contents: read actions: read - issues: read + issues: write steps: # `blob:none` при полной истории (#345): этой job нужны сообщения # коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых @@ -81,19 +82,24 @@ jobs: id: docs continue-on-error: true env: + REF: ${{ github.ref }} EVENT_NAME: ${{ github.event_name }} HEAD_MESSAGE: ${{ github.event.head_commit.message }} FULL_INPUT: ${{ inputs.full }} # #697: на ветке задачи `Release:` строгий режим не включает. REF_NAME: ${{ github.ref_name }} run: | + # #700: на ветке задачи упавший чужой сайт — предупреждение, а не + # красный preflight; push в dev, кандидат и релиз судят ссылки строго. + external=--external + case "$REF" in refs/heads/issue/*) external=--external=warn ;; esac # #586: CLI отдаёт ОДИН ответ. Прежде здесь сравнивался со строкой # `heavy=true` весь вывод `--heavy`, а он двухстрочный: в `$(…)` # строки схлопываются через пробел, сравнение не совпадало никогда, # и строгий режим не включился ни на одном кандидате. mode=$(node scripts/classify-changes.mjs --screenshots-mode) echo "скриншоты документации: режим $mode" - node scripts/check-docs.mjs --external --screenshots=$mode + node scripts/check-docs.mjs "$external" --screenshots=$mode # #635: `docs/reviews/INDEX.md` — снимок каталога ревью; расхождение с # каталогом — невидимые через индекс документы (r2 #635 H1). Гейт стоит @@ -140,6 +146,34 @@ jobs: done exit $status + # #700: расхождение зеркала на dev — одно открытое issue владельцу, как у + # ночного мутационного гейта (#472): чинит его тот, кто зеркалит в main, а + # не автор задачи, чья ветка ни при чём. Сбой API — предупреждение: шаг + # сообщает, а не судит. + - name: "Расхождение зеркала на dev — issue владельцу" + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && steps.workflow_sync.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + marker="[workflow-sync]" + existing=$(gh issue list --repo "$REPO" --state open --search "\"$marker\" in:title" \ + --json number,title --jq '[.[] | select(.title | startswith("[workflow-sync]"))][0].number // empty' || true) + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$REPO" --body "Расхождение повторилось: $RUN_URL" \ + || echo "::warning::комментарий в #$existing не оставлен" + exit 0 + fi + cat > /tmp/workflow-sync.md <> "$GITHUB_STEP_SUMMARY" + fi + } + task_branch=false + case "$REF" in refs/heads/issue/*) task_branch=true ;; esac echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY" check "документация" "$DOCS" - check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC" + if [ "$task_branch" = "true" ]; then + advise "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC" + else + check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC" + fi check "провенанс коммитов" "$PROVENANCE" check "процессный гейт" "$PROCESS_GATE" check "пины сторонних Actions" "$ACTION_PINS" diff --git a/PROCESS.md b/PROCESS.md index 8e7a6117..0ee91251 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1125,7 +1125,12 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit` триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в `main`, и preflight `workflow_sync` в `validate.yml` держит копии равными — сверяются ровно эти шесть файлов, список держит -`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит: +`test/default-branch-workflows.test.mjs`. Расхождение красит push в `dev` и +заводит одно issue владельцу (`[workflow-sync]`), а на ветке задачи — +предупреждение в сводке (#700): к её изменению оно отношения не имеет, и чинит +его тот, кто зеркалит в `main`. Так же судятся внешние ссылки документации +(`check-docs --external=warn` на ветках `issue/*`): упавший чужой сайт не +возвращает задачу. `performance.yml` в список не входит: по расписанию он судит `main` собственным телом из `main`. **Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index d38aed11..40264063 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -9,7 +9,12 @@ import { freshnessSink, screenshotsMode } from './docs-freshness.mjs'; import { guideParityErrors } from './user-guide-parity.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const EXTERNAL = process.argv.includes('--external'); +// `--external` — внешние ссылки судятся ошибкой; `--external=warn` — только +// предупреждением (#700): упавший чужой сайт не красит ветку задачи, к чьему +// изменению он отношения не имеет. Блокируют внешние ссылки push в dev, +// кандидат беты и релиз — там их чинит релиз-менеджер. +const EXTERNAL_WARN = process.argv.includes('--external=warn'); +const EXTERNAL = EXTERNAL_WARN || process.argv.includes('--external'); const PUBLIC_DOCS = [ 'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md', 'docs/TOUCH-SUPPORT.md', 'docs/DECOR-EDITOR.md', 'docs/VACUUM.md', @@ -233,6 +238,7 @@ if (!existsSync(manifestPath)) { } if (EXTERNAL) { + const externalErrors = EXTERNAL_WARN ? warnings : errors; const allowlist = JSON.parse(canonicalText(resolve(ROOT, 'docs/external-link-allowlist.json'))); const transientHosts = new Set(allowlist.transientHosts || []); for (const href of [...externalUrls].sort()) { @@ -249,10 +255,10 @@ if (EXTERNAL) { continue; } } - errors.push(`external link returned ${response.status}: ${href}`); + externalErrors.push(`external link returned ${response.status}: ${href}`); } catch (error) { if (transientHosts.has(url.hostname)) warnings.push(`transient external failure: ${href} (${error.message})`); - else errors.push(`external link failed: ${href} (${error.message})`); + else externalErrors.push(`external link failed: ${href} (${error.message})`); } } } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 4071bbd9..4d69eebe 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -12706,6 +12706,29 @@ const MUTANT_DEFINITIONS = [ replace: " if (d.startsWith('M') || d.length >= 0) return d;", }], }, + // #700: предполёт не красит ветку задачи чужими причинами. + { + id: 'task-branch-workflow-sync-red-again', + guard: 'node --test --test-name-pattern="#700" test/validate-workflow.test.mjs', + because: '#700: a thin workflow mirror mismatch has nothing to do with the task branch; 11 of 85 ' + + 'returns in #600–#691 came from it — on issue/* it is a warning, on dev a red preflight', + patches: [{ + file: '.github/workflows/validate.yml', + find: ' advise "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"', + replace: ' check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"', + }], + }, + { + id: 'external-link-warn-mode-ignored', + guard: 'node --test --test-name-pattern="#700: check-docs" test/validate-workflow.test.mjs', + because: '#700: a foreign site that is down must not turn a task branch red; --external=warn ' + + 'routes external failures to warnings', + patches: [{ + file: 'scripts/check-docs.mjs', + find: ' const externalErrors = EXTERNAL_WARN ? warnings : errors;', + replace: ' const externalErrors = errors; // mutant: warn mode ignored', + }], + }, { id: 'screenshot-freshness-never-strict', guard: 'node --test --test-name-pattern="#586" test/classify-changes.test.mjs', diff --git a/test/classify-changes.test.mjs b/test/classify-changes.test.mjs index 43baf37a..c92fdbda 100644 --- a/test/classify-changes.test.mjs +++ b/test/classify-changes.test.mjs @@ -215,7 +215,7 @@ test('#586: preflight спрашивает режим одним значени assert.match(workflow, /classify-changes\.mjs --screenshots-mode/); assert.ok(!/=\s*"heavy=true"/.test(workflow), 'сравнение со строкой «heavy=true» вернулось — строгий режим снова не включится'); - assert.match(workflow, /check-docs\.mjs --external --screenshots=\$mode/); + assert.match(workflow, /check-docs\.mjs "\$external" --screenshots=\$mode/); }); test('#510 AC1 / #601 AC1: мутанты по диффу запрашиваются только кнопкой mutants=true и PR — не пушем, не кандидатом беты, не full', () => { diff --git a/test/docs-freshness.test.mjs b/test/docs-freshness.test.mjs index de52fb76..049b2d16 100644 --- a/test/docs-freshness.test.mjs +++ b/test/docs-freshness.test.mjs @@ -30,8 +30,14 @@ test('check-docs: только две проверки свежести идут assert.ok(viaMode[0].includes('fingerprint is stale')); assert.ok(viaMode[1].includes('capture script changed')); // Хеш картинки, полнота набора сцен и ссылки не имеют права ослабляться. - for (const always of ['image hash does not match manifest', 'scenario set is incomplete', 'external link returned']) { + for (const always of ['image hash does not match manifest', 'scenario set is incomplete']) { const line = source.split('\n').find((l) => l.includes(always)); assert.ok(line && line.includes('errors.push'), `${always} остаётся ошибкой в обоих режимах`); } + // #700: внешние ссылки не зависят от режима скриншотов; предупреждением их + // делает только явный `--external=warn` на ветке задачи. + const external = source.split('\n').find((l) => l.includes('external link returned')); + assert.ok(external && external.includes('externalErrors.push'), 'внешняя ссылка идёт в свой сток'); + assert.match(source, /const externalErrors = EXTERNAL_WARN \? warnings : errors;/); + assert.doesNotMatch(source, /externalErrors = freshness/); }); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 32110270..f7916da3 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -440,7 +440,7 @@ test('смоки, golden и performance_smoke условны по heavy (#479)', assert.match(text, /classify-changes\.mjs --heavy/); assert.match(text, /workflow_dispatch:\n\s+inputs:\n\s+full:/); // preflight: режим скриншотов считает тот же скрипт. - assert.match(text, /check-docs\.mjs --external --screenshots=\$mode/); + assert.match(text, /check-docs\.mjs "\$external" --screenshots=\$mode/); }); test('ночной прогон — dispatch Validate на dev с full=true (#479)', () => { @@ -613,3 +613,27 @@ test('#541: Validate всегда публикует proof точной попы assert.equal(reuse.includes('lookup-only: true'), false, 'marker contents must be restored and verified, not reduced to a cache-hit bit'); }); + +test('#700: на ветке задачи зеркало workflow и внешние ссылки — предупреждение, на dev — красный и issue', () => { + const workflow = read('validate.yml'); + const preflight = workflow.slice(workflow.indexOf('\n preflight:\n'), workflow.indexOf('\n changes:\n')); + const docs = preflight.slice(preflight.indexOf('id: docs'), preflight.indexOf('id: reviews_index')); + assert.match(docs, /case "\$REF" in refs\/heads\/issue\/\*\) external=--external=warn ;; esac/); + assert.match(docs, /REF: \$\{\{ github\.ref \}\}/); + const verdict = preflight.slice(preflight.indexOf('- name: Вердикт предполётных проверок')); + assert.match(verdict, /case "\$REF" in refs\/heads\/issue\/\*\) task_branch=true ;; esac/); + assert.match(verdict, /if \[ "\$task_branch" = "true" \]; then\n\s+advise "тонкие вызывающие workflow в main и dev" "\$WORKFLOW_SYNC"\n\s+else\n\s+check "тонкие вызывающие workflow в main и dev"/); + assert.doesNotMatch(verdict.slice(verdict.indexOf('advise() {'), verdict.indexOf('task_branch=false')), /fail=1/, + 'предупреждение не красит вердикт'); + const issue = preflight.slice(preflight.indexOf('- name: "Расхождение зеркала на dev — issue владельцу"')); + assert.match(issue, /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/dev' && steps\.workflow_sync\.outcome == 'failure'/); + assert.match(issue, /gh issue list --repo "\$REPO" --state open --search/, 'одно issue, а не одно на каждый push'); + assert.match(preflight, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: write/); +}); + +test('#700: check-docs --external=warn сводит внешние отказы в предупреждения', () => { + const source = read('../../scripts/check-docs.mjs'); + assert.match(source, /const EXTERNAL_WARN = process\.argv\.includes\('--external=warn'\);/); + assert.match(source, /const externalErrors = EXTERNAL_WARN \? warnings : errors;/); + assert.equal((source.match(/externalErrors\.push\(/g) || []).length, 2, 'оба вида внешнего отказа'); +});