From 32b1baa1898f03f2e6543cdebb211f1b72bf0e05 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 00:16:45 +0300 Subject: [PATCH] ci: merge the exact candidate; nightly waits for its Validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/merge-candidate.mjs owns the review pipeline's merge: when dev moved during review, the rebased candidate is pushed to the issue branch, its diff is compared to the reviewed one by patch-id, Validate on that SHA is awaited, and only then dev is advanced with --force-with-lease on the base the candidate was built on — a rejected lease restarts, at most three times. Every non-merge outcome moves the label with a comment, so the "label always changes" invariant holds. nightly.yml now finds the Validate run it dispatched and inherits its conclusion. Three mutants guard this. Issue: #492 User-Visible: no --- .github/workflows/nightly.yml | 30 +++- .github/workflows/process.yml | 74 ++-------- scripts/merge-candidate.mjs | 244 +++++++++++++++++++++++++++++++++ scripts/mutation-gate.mjs | 33 +++++ test/merge-candidate.test.mjs | 222 ++++++++++++++++++++++++++++++ test/nightly-workflow.test.mjs | 29 ++++ 6 files changed, 571 insertions(+), 61 deletions(-) create mode 100755 scripts/merge-candidate.mjs create mode 100755 test/merge-candidate.test.mjs create mode 100644 test/nightly-workflow.test.mjs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ca121e11..f52efc46 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -13,6 +13,11 @@ # Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт: # гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и # там полный набор идёт заново. +# +# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась +# успехом в момент постановки Validate в очередь, и красный полный прогон не +# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт +# его: успешный dispatch — не успешная проверка. name: Ночной полный прогон dev on: @@ -26,13 +31,34 @@ permissions: jobs: dispatch: - name: "Запустить Validate на dev с полным набором" + name: "Запустить Validate на dev с полным набором и дождаться результата" runs-on: ubuntu-latest + timeout-minutes: 90 steps: - env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | set -euo pipefail + since=$(date -u +%FT%TZ) gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true - echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)" + echo "Validate(dev, full=true) поставлен в очередь: $since" + # Найти именно этот прогон: workflow_dispatch на dev, созданный не + # раньше момента запуска. До трёх минут на появление в списке. + run_id="" + for _ in $(seq 1 18); do + sleep 10 + run_id=$(gh run list --repo "$REPO" --workflow validate.yml --branch dev \ + --event workflow_dispatch --json databaseId,createdAt --limit 5 \ + --jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty") + [ -n "$run_id" ] && break + done + if [ -z "$run_id" ]; then + echo "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке" + exit 1 + fi + url="${{ github.server_url }}/$REPO/actions/runs/$run_id" + echo "дочерний прогон: $url" + echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY" + # Ждём завершения; красный дочерний прогон — красный ночной. + gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30 diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 499b2898..19fee6a4 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -1012,16 +1012,19 @@ jobs: # S8-merged утверждает, что код в dev. Значит слияние обязано произойти # ДО метки, иначе она врёт в промежутке. # - # При конфликте шаг НЕ падает и метку не оставляет на месте. Первая - # редакция делала именно так, и это оказалось тупиком: автор ждёт смену - # метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы - # затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант - # теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА. + # Слияние — точный кандидат (#492 §4, scripts/merge-candidate.mjs): + # ветка сверяется с материалом (#312); если dev не двигался — push с + # lease на текущую вершину; если двигался — ребейз, сравнение patch-id + # с проверенным диффом, публикация кандидата в ветку, ожидание + # Validate на этом SHA и только потом push в dev с lease. Повторное + # движение dev — новая попытка, не более трёх. Каждый исход, кроме + # успеха, ведёт в S6-in-progress/S7-code-review с комментарием, ПОСЛЕ + # ПРОГОНА МЕТКА МЕНЯЕТСЯ ВСЕГДА — инвариант тот же, что и раньше. - name: Слить ветку в dev id: merge if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' env: - TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} NUM: ${{ github.event.issue.number }} @@ -1032,58 +1035,8 @@ jobs: echo "merged=false" >> "$GITHUB_OUTPUT" exit 0 fi - git fetch -q origin dev "$BRANCH" - # #312: сливается только проверенный код. Допустимые вершины ветки: - # сам SHA материала либо он же плюс ровно один коммит публикации - # документа ревью (дифф только docs/reviews/). Любой другой коммит — - # ветка уехала после ревью, вердикт к ней не применим: возврат в - # S6-in-progress через merged=false, как при конфликте. - actual=$(git rev-parse "origin/$BRANCH") - reviewed="$MATERIAL_SHA" - fresh=false - if [ "$actual" = "$reviewed" ]; then - fresh=true - elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \ - && [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then - fresh=true - fi - if [ "$fresh" != true ]; then - echo "merged=false" >> "$GITHUB_OUTPUT" - echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)" - cat > /tmp/stale-verdict.md <> "$GITHUB_OUTPUT" - echo "::warning::ветка $BRANCH не сливается в dev без конфликта" - cat > /tmp/conflict.md <> "$GITHUB_OUTPUT" - echo "слито в dev: $(git rev-parse --short HEAD)" + node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \ + --issue="$NUM" --repo="${{ github.repository }}" - name: Переставить метку if: steps.rebase.outputs.conflict != 'true' @@ -1094,7 +1047,10 @@ jobs: FROM: ${{ steps.decide.outputs.from }} # Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к # автору: метка утверждала бы, что код в dev, а его там нет. - TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }} + # Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 — + # когда кандидат не слит (конфликт, красный Validate, изменившийся + # patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6. + TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }} run: | gh issue edit "$NUM" --repo "${{ github.repository }}" \ --add-label "$TO" --remove-label "$FROM" diff --git a/scripts/merge-candidate.mjs b/scripts/merge-candidate.mjs new file mode 100755 index 00000000..c3606b42 --- /dev/null +++ b/scripts/merge-candidate.mjs @@ -0,0 +1,244 @@ +#!/usr/bin/env node +// Слияние точного кандидата (#492 §4). +// +// До этой задачи шаг «Слить ветку в dev» ревью-конвейера после зелёного +// вердикта делал `git rebase origin/dev` и сразу `push HEAD:dev`. Если `dev` +// продвинулся за время ревью (28 августа — четыре раза за день), в `dev` +// уезжало дерево, которого не видел никто: ни ревью (материал другой), ни +// Validate (на этот SHA он не бежал). Чистый ребейз не доказывает +// совместимость: соседняя правка в `dev` меняет поведение без единого +// конфликта (эксперимент аудита: 20 → 40). +// +// Правило теперь: в `dev` попадает только SHA, для которого есть зелёный +// Validate, и попадает атомарно — `--force-with-lease` на ту вершину `dev`, +// поверх которой кандидат собран. Движение `dev` во время проверки не +// перезаписывает чужое: lease отклоняется, кандидат собирается заново, не +// более трёх раз. Дифф, изменившийся при ребейзе (patch-id), — не предмет +// этого шага: вердикт к нему не применим, задача возвращается на ревью. +// +// Решение (`decideMerge`) отделено от git и gh (`ops`), чтобы таблица +// случаев §8.4 была юнит-тестом, а не верой в shell. + +import { spawnSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; + +export const MAX_ATTEMPTS = 3; +export const VALIDATE_APPEAR_MS = 3 * 60 * 1000; +export const VALIDATE_TOTAL_MS = 45 * 60 * 1000; + +/** + * Чистое решение по состоянию одной попытки. Возвращает действие и, где + * применимо, статусную метку, к которой ведёт это действие. + * + * @param {object} s + * @param {boolean} s.fresh вершина ветки = материал (+ документ ревью), #312 + * @param {boolean} s.devMoved dev не равен базе материала + * @param {boolean} s.conflict ребейз на dev упал + * @param {boolean} s.patchIdEqual дифф после ребейза совпадает с проверенным + * @param {'green'|'red'|'missing'|null} s.validate результат Validate на кандидате + * @param {boolean} s.leaseRejected push в dev отклонён: dev двинулся снова + * @param {number} s.attempt номер попытки, с 1 + */ +export function decideMerge(s) { + if (!s.fresh) return { action: 'reject-stale', to: 'S6-in-progress' }; + if (s.conflict) return { action: 'conflict', to: 'S6-in-progress' }; + if (!s.devMoved) { + if (s.leaseRejected) return { action: 'retry' }; + return { action: 'fast-forward', to: 'S8-merged' }; + } + if (!s.patchIdEqual) return { action: 'rereview', to: 'S7-code-review' }; + if (s.validate === null || s.validate === undefined) return { action: 'validate' }; + if (s.validate === 'missing') return { action: 'validation-missing', to: 'S6-in-progress' }; + if (s.validate === 'red') return { action: 'validation-red', to: 'S6-in-progress' }; + if (s.leaseRejected) { + if ((s.attempt ?? 1) >= (s.maxAttempts ?? MAX_ATTEMPTS)) return { action: 'give-up', to: 'S6-in-progress' }; + return { action: 'retry' }; + } + return { action: 'push', to: 'S8-merged' }; +} + +/** Тексты комментариев в issue — один на исход. */ +export function commentFor(action, ctx) { + const short = (sha) => String(sha || '').slice(0, 8); + switch (action) { + case 'reject-stale': + return `**Слияние отменено: ветка изменилась после проверенного материала (#312).**\n\n` + + `Ревью выполнялось на \`${short(ctx.material)}\`, а вершина ветки сейчас \`${short(ctx.actual)}\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.\n\n` + + `Задача переведена в \`S6-in-progress\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \`S7-code-review\`. Если вершина отличается от проверенного материала только коммитами публикации документов ревью, новый заход применит зелёный вердикт повторно без вызова модели (#499).`; + case 'conflict': + return `**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`${ctx.branch}\` конфликтует с \`dev\`.\n\n` + + `Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:\n\n` + + `1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;\n2. запушить ветку;\n3. вернуть метку \`S7-code-review\`.\n\n` + + `Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.`; + case 'rereview': + return `**Дифф изменился при ребейзе на \`dev@${short(ctx.devNow)}\` — вердикт к нему не применим (§7.2, #492).**\n\n` + + `Материал ревью \`${short(ctx.material)}\` и кандидат \`${short(ctx.candidate)}\` дают разные patch-id: соседние правки в \`dev\` изменили содержимое патча. Кандидат опубликован в ветку; задача возвращена в \`S7-code-review\` — новый заход ревью читает актуальный код.`; + case 'validation-red': + return `**Кандидат после ребейза на \`dev@${short(ctx.devNow)}\` красный (#492).**\n\n` + + `Вердикт ревью на \`${short(ctx.material)}\` в силе, но точный кандидат \`${short(ctx.candidate)}\` не прошёл Validate: ${ctx.runUrl || 'прогон не найден'}. Задача переведена в \`S6-in-progress\`: разобраться с прогоном на ветке, затем вернуть \`S7-code-review\`.`; + case 'validation-missing': + return `**Validate на кандидате \`${short(ctx.candidate)}\` не появился за ${Math.round(VALIDATE_APPEAR_MS / 60000)} мин (#492).**\n\n` + + `Кандидат опубликован в ветку, но прогон не стартовал — проверьте токен конвейера и очередь Actions. Слияние без проверки не выполняется; задача в \`S6-in-progress\`, после зелёного Validate на этом SHA вернуть \`S7-code-review\`.`; + case 'give-up': + return `**\`dev\` движется быстрее слияния: ${ctx.attempt} попытки собрать и проверить кандидата, каждый раз \`dev\` уходил до push (#492).**\n\n` + + `Последний проверенный кандидат \`${short(ctx.candidate)}\` опубликован в ветку. Задача в \`S6-in-progress\`; вернуть \`S7-code-review\`, когда \`dev\` успокоится.`; + case 'push': + case 'fast-forward': + return `материал \`${short(ctx.material)}\` · dev@\`${short(ctx.devNow)}\` → кандидат \`${short(ctx.candidate)}\`` + + (action === 'push' ? ` · Validate ${ctx.runUrl} зелёный` : ' · dev не двигался') + + ' · слито'; + default: + return ''; + } +} + +// --------------------------------------------------------------------------- +// Исполнение: git + gh через `ops`, чтобы тест подменял их целиком. + +const sh = (cmd, args, opts = {}) => { + const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts }); + return { status: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }; +}; + +export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now }) { + const pushUrl = `https://x-access-token:${token}@github.com/${repo}`; + const git = (...args) => sh('git', args); + const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; }; + return { + fetch: (...refs) => must(git('fetch', '-q', 'origin', ...refs), 'git fetch'), + revParse: (ref) => must(git('rev-parse', ref), `rev-parse ${ref}`), + mergeBase: (a, b) => must(git('merge-base', a, b), 'merge-base'), + diffNames: (from, to, pathspec = []) => must(git('diff', '--name-only', from, to, '--', ...pathspec), 'diff').split('\n').filter(Boolean), + patchId: (from, to) => { + const diff = must(git('diff', '--full-index', from, to), 'diff'); + const r = spawnSync('git', ['patch-id', '--stable'], { input: diff, encoding: 'utf8' }); + return (r.stdout || '').trim().split(' ')[0] || 'empty'; + }, + rebaseOnto: (branchTip, onto) => { + must(git('checkout', '-q', '-B', 'merge-into-dev', branchTip), 'checkout'); + const r = spawnSync('git', ['-c', 'user.name=claude[bot]', '-c', 'user.email=209825114+claude[bot]@users.noreply.github.com', 'rebase', onto], { encoding: 'utf8' }); + if (r.status !== 0) { spawnSync('git', ['rebase', '--abort']); return null; } + return must(git('rev-parse', 'HEAD'), 'rev-parse HEAD'); + }, + pushWithLease: (sha, ref, expected) => { + const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `${sha}:refs/heads/${ref}`); + if (r.status === 0) return true; + if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false; + throw new Error(`git push ${ref}: ${r.stderr}`); + }, + waitValidate: async (sha) => { + const started = now(); + let runId = null; + while (now() - started < VALIDATE_TOTAL_MS) { + const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url', '--limit', '5']); + const runs = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []; + const run = runs.find((x) => x.databaseId === runId) || runs[0]; + if (run) { + runId = run.databaseId; + if (run.status === 'completed') return { result: run.conclusion === 'success' ? 'green' : 'red', url: run.url }; + } else if (now() - started > VALIDATE_APPEAR_MS) { + return { result: 'missing', url: null }; + } + await sleep(20_000); + } + return { result: 'red', url: runId ? `run ${runId} (timeout)` : null }; + }, + comment: (issue, body) => { + const r = spawnSync('gh', ['issue', 'comment', String(issue), '--repo', repo, '--body-file', '-'], { input: body, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`gh issue comment: ${r.stderr}`); + }, + log: (line) => console.log(line), + }; +} + +/** + * Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }. + */ +export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS }) { + ops.fetch('dev', branch); + const actual = ops.revParse(`origin/${branch}`); + const reviewedFresh = actual === material + || (safe(() => ops.revParse(`${actual}^`)) === material + && ops.diffNames(material, actual, ['.', ':!docs/reviews']).length === 0); + const ctx = { branch, material, actual, issue }; + const finish = (decision, extra = {}) => { + const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt }); + if (body) ops.comment(issue, body); + const merged = decision.action === 'push' || decision.action === 'fast-forward'; + ops.log(`решение: ${decision.action} → ${decision.to || '(метка по вердикту)'}`); + return { merged, to: decision.to, action: decision.action, candidate: extra.candidate || actual }; + }; + + if (!reviewedFresh) return finish(decideMerge({ fresh: false })); + + let tip = actual; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + ops.fetch('dev'); + const devNow = ops.revParse('origin/dev'); + const materialBase = ops.mergeBase(material, 'origin/dev'); + const devMoved = devNow !== materialBase; + ops.log(`попытка ${attempt}: dev@${devNow.slice(0, 8)}, база материала ${materialBase.slice(0, 8)}, dev ${devMoved ? 'двигался' : 'на месте'}`); + + if (!devMoved) { + const pushed = ops.pushWithLease(tip, 'dev', devNow); + const decision = decideMerge({ fresh: true, devMoved: false, leaseRejected: !pushed }); + if (decision.action === 'retry') continue; + return finish(decision, { candidate: tip, devNow }); + } + + const candidate = ops.rebaseOnto(tip, 'origin/dev'); + if (!candidate) return finish(decideMerge({ fresh: true, devMoved: true, conflict: true }), { devNow }); + + const patchIdEqual = ops.patchId(materialBase, material) === ops.patchId(devNow, candidate); + // кандидат публикуется в ветку в любом случае: он и есть то, что должно + // ехать в dev, и Validate стартует именно от этого push + if (!ops.pushWithLease(candidate, branch, tip)) { + // ветку задачи подвинули, пока шло ревью или ребейз — это #312, не наш случай + return finish(decideMerge({ fresh: false }), { candidate, devNow }); + } + tip = candidate; + if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow }); + + ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`); + const { result, url } = await ops.waitValidate(candidate); + let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts }); + if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url }); + + const pushed = ops.pushWithLease(candidate, 'dev', devNow); + decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, leaseRejected: !pushed, attempt, maxAttempts }); + if (decision.action === 'retry') { ops.log('dev двинулся снова — ещё попытка'); continue; } + return finish(decision, { candidate, devNow, runUrl: url, attempt }); + } + return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: maxAttempts, maxAttempts }), { candidate: tip, attempt: maxAttempts }); +} + +const safe = (fn) => { try { return fn(); } catch { return null; } }; + +const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); + +if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) { + const branch = arg('branch'); + const material = arg('material'); + const issue = arg('issue'); + const repo = arg('repo') || process.env.GITHUB_REPOSITORY; + const token = process.env.HP_PROCESS_TOKEN || process.env.TOKEN; + if (!branch || !material || !issue || !repo || !token) { + console.error('usage: merge-candidate.mjs --branch= --material= --issue= [--repo=owner/name]; HP_PROCESS_TOKEN in env'); + process.exit(2); + } + const ops = realOps({ repo, token }); + mergeCandidate({ branch, material, issue, ops }).then((r) => { + const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`; + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out); + process.stdout.write(out); + }, (err) => { + // Инвариант конвейера: после прогона метка меняется всегда. Сбой самого + // слияния — не повод оставить задачу висеть в S7: S6 и внятный комментарий. + console.error(err); + try { + ops.comment(issue, `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${String(err && err.message || err).slice(0, 1500)}\n\`\`\`\n\nВердикт ревью в силе. Задача в \`S6-in-progress\`; после разбора сбоя вернуть \`S7-code-review\`.`); + } catch (e) { console.error(e); } + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'merged=false\nto=S6-in-progress\naction=error\n'); + process.exit(0); + }); +} diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index d10704b3..5c16aeb6 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -3615,6 +3615,39 @@ const MUTANT_DEFINITIONS = [ replace: ' byRegistry = []; // mutant: definitions never select', }], }, + { + id: 'merge-pushes-unvalidated-candidate', + guard: 'node --test --test-name-pattern="эксперимент аудита" test/merge-candidate.test.mjs', + because: 'after a clean rebase onto a moved dev the candidate is a tree nobody checked; pushing ' + + 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)', + patches: [{ + file: 'scripts/merge-candidate.mjs', + find: ' const { result, url } = await ops.waitValidate(candidate);', + replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation", + }], + }, + { + id: 'merge-ignores-lease-rejection', + guard: 'node --test --test-name-pattern="lease отклонён" test/merge-candidate.test.mjs', + because: 'dev moving again between Validate and push must restart the candidate, not be ' + + 'reported as merged: a rejected lease treated as success leaves S8-merged on code not in dev (#492 AC2)', + patches: [{ + file: 'scripts/merge-candidate.mjs', + find: " const pushed = ops.pushWithLease(candidate, 'dev', devNow);\n decision = decideMerge(", + replace: " const pushed = ops.pushWithLease(candidate, 'dev', devNow) || true; // mutant: lease never rejected\n decision = decideMerge(", + }], + }, + { + id: 'nightly-does-not-wait', + guard: 'node --test --test-name-pattern="nightly ждёт запущенный Validate" test/nightly-workflow.test.mjs', + because: 'a nightly that returns green at dispatch time hides a red full run; the job must wait ' + + 'for the child and inherit its conclusion (#492 §7)', + patches: [{ + file: '.github/workflows/nightly.yml', + find: ' gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30', + replace: ' echo "watching skipped" # mutant: dispatch counted as success', + }], + }, { id: 'changed-selection-ignores-guard-files', guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs', diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs new file mode 100755 index 00000000..abe0e8f4 --- /dev/null +++ b/test/merge-candidate.test.mjs @@ -0,0 +1,222 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { MAX_ATTEMPTS, commentFor, decideMerge, mergeCandidate, realOps } from '../scripts/merge-candidate.mjs'; + +// #492 §4 / §8.4: слияние точного кандидата. Таблица решений — на чистой +// функции; последовательность операций — на фальшивых git/gh; эксперимент +// аудита «20 → 40» — на настоящем git в temp-репозитории. + +test('§8.4 таблица решений decideMerge', () => { + assert.deepEqual(decideMerge({ fresh: false }), { action: 'reject-stale', to: 'S6-in-progress' }); + assert.deepEqual(decideMerge({ fresh: true, conflict: true }), { action: 'conflict', to: 'S6-in-progress' }); + // dev не двигался — fast-forward с lease + assert.deepEqual(decideMerge({ fresh: true, devMoved: false }), { action: 'fast-forward', to: 'S8-merged' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: false, leaseRejected: true }), { action: 'retry' }); + // dev двигался: сначала patch-id, потом Validate, потом push с lease + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { action: 'rereview', to: 'S7-code-review' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: null }), { action: 'validate' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'missing' }), { action: 'validation-missing', to: 'S6-in-progress' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'red' }), { action: 'validation-red', to: 'S6-in-progress' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green' }), { action: 'push', to: 'S8-merged' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 1 }), { action: 'retry' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: 2 }), { action: 'retry' }); + assert.deepEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: MAX_ATTEMPTS }), { action: 'give-up', to: 'S6-in-progress' }); + // ни один исход не ведёт в S8 без зелёного Validate при движении dev + for (const validate of [null, 'missing', 'red']) { + assert.notEqual(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate }).to, 'S8-merged', String(validate)); + } + assert.equal(MAX_ATTEMPTS, 3); +}); + +test('каждый исход, меняющий метку, объясняется комментарием; успех — одной строкой', () => { + const ctx = { material: 'a'.repeat(40), actual: 'b'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40), branch: 'issue/1-x', runUrl: 'https://run', attempt: 3 }; + for (const action of ['reject-stale', 'conflict', 'rereview', 'validation-red', 'validation-missing', 'give-up']) { + const body = commentFor(action, ctx); + assert.ok(body.length > 80, action); + assert.match(body, /S6-in-progress|S7-code-review/, action); + } + assert.match(commentFor('push', ctx), /^материал `aaaaaaaa` · dev@`dddddddd` → кандидат `cccccccc` · Validate https:\/\/run зелёный · слито$/); + assert.match(commentFor('fast-forward', ctx), /dev не двигался · слито$/); + assert.equal(commentFor('validate', ctx), ''); +}); + +/** + * Фальшивые git/gh: `base` — merge-base материала с dev, `devTips` — вершины + * dev по порядку (следующая после каждого отклонённого lease), ответы + * Validate — по порядку кандидатов. + */ +function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false }) { + const calls = []; + let devIndex = 0; + let validateIndex = 0; + let rejects = leaseRejects; + let conflict = conflictOnce; + const dev = () => devTips[Math.min(devIndex, devTips.length - 1)]; + return { + calls, + fetch: (...refs) => { calls.push(['fetch', ...refs]); }, + revParse: (ref) => { + if (ref === 'origin/dev') return dev(); + if (ref.startsWith('origin/issue')) return branchTip; + if (ref.endsWith('^')) return material; + return ref; + }, + mergeBase: () => base, + diffNames: () => [], + patchId: (from, to) => patchIds[`${from}..${to}`] || 'same', + rebaseOnto: (tip, onto) => { + calls.push(['rebase', tip, onto]); + if (conflict) { conflict = false; return null; } + return `cand-${tip}-on-${dev()}`; + }, + pushWithLease: (sha, ref, expected) => { + calls.push(['push', sha, ref, expected]); + if (ref === 'dev' && rejects > 0) { rejects -= 1; devIndex += 1; return false; } + return true; + }, + waitValidate: async (sha) => { + calls.push(['validate', sha]); + const result = validate[Math.min(validateIndex, validate.length - 1)] || 'green'; + validateIndex += 1; + return { result, url: `https://run/${sha}` }; + }, + comment: (issue, body) => { calls.push(['comment', body.split('\n')[0]]); }, + log: () => {}, + }; +} + +test('dev не двигался: push кандидата как есть, с lease на текущий dev', async () => { + const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat' }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); + assert.equal(r.action, 'fast-forward'); + assert.equal(r.merged, true); + assert.deepEqual(ops.calls.filter((c) => c[0] === 'push'), [['push', 'mat', 'dev', 'dev0']]); + assert.ok(!ops.calls.some((c) => c[0] === 'validate'), 'без движения dev Validate не ждётся'); +}); + +test('эксперимент аудита: dev двигался, ребейз чистый, patch-id равен — Validate ОБЯЗАТЕЛЕН до push', async () => { + const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat' }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); + assert.equal(r.action, 'push'); + assert.equal(r.merged, true); + const order = ops.calls.map((c) => c[0]); + const validateAt = order.indexOf('validate'); + const devPushAt = ops.calls.findIndex((c) => c[0] === 'push' && c[2] === 'dev'); + assert.ok(validateAt >= 0 && validateAt < devPushAt, `Validate (${validateAt}) раньше push в dev (${devPushAt})`); + // кандидат сначала опубликован в ветку (от этого push стартует Validate) + assert.deepEqual(ops.calls.find((c) => c[0] === 'push'), ['push', 'cand-mat-on-dev1', 'issue/1-x', 'mat']); + assert.deepEqual(ops.calls.find((c) => c[0] === 'push' && c[2] === 'dev'), ['push', 'cand-mat-on-dev1', 'dev', 'dev1']); +}); + +test('красный Validate на кандидате — S6, без push в dev', async () => { + const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['red'] }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); + assert.equal(r.action, 'validation-red'); + assert.equal(r.to, 'S6-in-progress'); + assert.equal(r.merged, false); + assert.ok(!ops.calls.some((c) => c[0] === 'push' && c[2] === 'dev')); +}); + +test('patch-id изменился при ребейзе — S7-code-review, без Validate и без push в dev', async () => { + const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', patchIds: { 'dev0..mat': 'p1', 'dev1..cand-mat-on-dev1': 'p2' } }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); + assert.equal(r.action, 'rereview'); + assert.equal(r.to, 'S7-code-review'); + assert.ok(!ops.calls.some((c) => c[0] === 'validate')); + assert.ok(!ops.calls.some((c) => c[0] === 'push' && c[2] === 'dev')); +}); + +test('dev ушёл снова после Validate: lease отклонён → новая попытка; трижды → S6', async () => { + const twice = fakeOps({ devTips: ['dev1', 'dev2', 'dev3'], branchTip: 'mat', material: 'mat', leaseRejects: 2 }); + const ok = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: twice }); + assert.equal(ok.action, 'push'); + assert.equal(twice.calls.filter((c) => c[0] === 'validate').length, 3, 'каждый новый кандидат проверен заново'); + assert.equal(twice.calls.filter((c) => c[0] === 'push' && c[2] === 'dev').length, 3); + + const always = fakeOps({ devTips: ['dev1', 'dev2', 'dev3', 'dev4'], branchTip: 'mat', material: 'mat', leaseRejects: 99 }); + const giveUp = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: always }); + assert.equal(giveUp.action, 'give-up'); + assert.equal(giveUp.to, 'S6-in-progress'); + assert.equal(giveUp.merged, false); +}); + +test('ветка уехала после материала — #312, без ребейза и push', async () => { + const ops = fakeOps({ devTips: ['dev0'], branchTip: 'other', material: 'mat' }); + ops.revParse = (ref) => (ref === 'origin/dev' ? 'dev0' : ref.startsWith('origin/issue') ? 'other' : ref.endsWith('^') ? 'zzz' : ref); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); + assert.equal(r.action, 'reject-stale'); + assert.ok(!ops.calls.some((c) => c[0] === 'push' || c[0] === 'rebase')); +}); + +test('конфликт при ребейзе — S6 с инструкцией, без push', async () => { + const ops = fakeOps({ devTips: ['dev1'], branchTip: 'mat', material: 'mat', conflictOnce: true }); + const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops }); + assert.equal(r.action, 'conflict'); + assert.ok(!ops.calls.some((c) => c[0] === 'push')); +}); + +// --- настоящий git: «20 → 40» ----------------------------------------------- + +const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); + +test('на настоящем git: чистый ребейз с равным patch-id и изменённым поведением идёт через Validate, не мимо', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hp-merge-')); + try { + const bare = join(dir, 'origin.git'); + execFileSync('git', ['init', '-q', '--bare', bare]); + const work = join(dir, 'work'); + execFileSync('git', ['clone', '-q', bare, work]); + const cfg = ['-c', 'user.name=t', '-c', 'user.email=t@x']; + const commit = (msg) => execFileSync('git', ['-C', work, ...cfg, 'commit', '-q', '-am', msg]); + writeFileSync(join(work, 'a.mjs'), 'export const a = 20;\n'); + writeFileSync(join(work, 'b.mjs'), 'export const b = 1;\n'); + git(work, 'add', '.'); + commit('base'); + git(work, 'branch', '-M', 'dev'); + git(work, 'push', '-q', '-u', 'origin', 'dev'); + // ветка задачи: b = a * 2 (проверено ревью при a = 20 → 40) + git(work, 'checkout', '-q', '-b', 'issue/7-double'); + writeFileSync(join(work, 'b.mjs'), "import { a } from './a.mjs';\nexport const b = a * 2;\n"); + commit('double'); + const material = git(work, 'rev-parse', 'HEAD'); + git(work, 'push', '-q', '-u', 'origin', 'issue/7-double'); + // dev уходит вперёд: a = 40 — другой файл, конфликта нет, поведение b: 40 → 80 + git(work, 'checkout', '-q', 'dev'); + writeFileSync(join(work, 'a.mjs'), 'export const a = 40;\n'); + commit('a is 40 now'); + git(work, 'push', '-q', 'origin', 'dev'); + + const calls = []; + const ops = realOps({ repo: 'x/y', token: 'none' }); + ops.pushWithLease = (sha, ref, expected) => { + calls.push(['push', ref, expected]); + const r = spawnSync('git', ['-C', work, 'push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, 'origin', `${sha}:refs/heads/${ref}`], { encoding: 'utf8' }); + return r.status === 0; + }; + ops.waitValidate = async (sha) => { calls.push(['validate', sha]); return { result: 'green', url: 'https://run/1' }; }; + ops.comment = (issue, body) => { calls.push(['comment', body.slice(0, 40)]); }; + ops.log = () => {}; + const inWork = (fn) => (...args) => { const cwd = process.cwd(); process.chdir(work); try { return fn(...args); } finally { process.chdir(cwd); } }; + for (const name of ['fetch', 'revParse', 'mergeBase', 'diffNames', 'patchId', 'rebaseOnto']) ops[name] = inWork(ops[name]); + + const r = await mergeCandidate({ branch: 'issue/7-double', material, issue: 7, ops }); + assert.equal(r.action, 'push', JSON.stringify(calls)); + const validateAt = calls.findIndex((c) => c[0] === 'validate'); + const devPushAt = calls.findIndex((c) => c[0] === 'push' && c[1] === 'dev'); + assert.ok(validateAt >= 0 && validateAt < devPushAt, 'без Validate кандидат в dev не уходит'); + const devTip = git(work, 'rev-parse', 'origin/dev'); + assert.equal(devTip, r.candidate, 'в dev ровно проверенный кандидат'); + assert.match(git(work, 'show', `${devTip}:a.mjs`), /a = 40/); + assert.match(git(work, 'show', `${devTip}:b.mjs`), /a \* 2/); + // lease: dev ждали на вершине «a is 40 now» + const lease = calls.find((c) => c[0] === 'push' && c[1] === 'dev')[2]; + assert.equal(lease, git(work, 'rev-parse', `${devTip}^`)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/nightly-workflow.test.mjs b/test/nightly-workflow.test.mjs new file mode 100644 index 00000000..f4cc6423 --- /dev/null +++ b/test/nightly-workflow.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +// #492 §7: ночной workflow обязан ждать дочерний Validate и наследовать его +// исход — успешный dispatch не равен успешной проверке. + +const read = (name) => readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8'); + +test('nightly ждёт запущенный Validate и падает вместе с ним (#492 §7)', () => { + const nightly = read('nightly.yml'); + assert.match(nightly, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/); + // найти именно свой прогон: dispatch на dev, созданный не раньше запуска + assert.match(nightly, /gh run list --repo "\$REPO" --workflow validate\.yml --branch dev/); + assert.match(nightly, /--event workflow_dispatch/); + assert.match(nightly, /createdAt >= /); + // отсутствие прогона — ошибка, не тихий успех + assert.match(nightly, /прогон Validate не появился[^\n]*\n\s+exit 1/); + // ждать с наследованием кода возврата + assert.match(nightly, /gh run watch "\$run_id" --repo "\$REPO" --exit-status/); + assert.match(nightly, /timeout-minutes: 90/); + assert.match(nightly, /set -euo pipefail/); +}); + +test('ночная job носит русское имя и не выдаёт очередь за результат (#327, #492)', () => { + const nightly = read('nightly.yml'); + assert.match(nightly, /name: "Запустить Validate на dev с полным набором и дождаться результата"/); + assert.ok(!/поставлен в очередь[^\n]*\n\s*$/.test(nightly), 'echo про очередь не может быть последним шагом'); +});