diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index 291026f5..cf1d062f 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -1430,6 +1430,28 @@ jobs: with: node-version: 22 + # #749: тело читается из dev (@dev, #623) — значит, и скрипты, которые + # оно зовёт со своими флагами и форматами, тоже из dev. Рабочая копия + # после публикации документа — ветка задачи, а ветка show/ship с чистым + # слиянием до ревью не ребейзится (§10.4) и несёт scripts/, отставшие на + # дни: неизвестный флаг review-doc-guard.mjs молча теряется, старый + # merge-candidate.mjs сливает по-старому. Рабочая копия — материал, а не + # инструмент: git, документ и пути судятся в ней, скрипты зовутся отсюда. + # Снимок один на job — одна версия на весь заход, а не смесь по шагам. + # validate.yml в снимке обязателен: workflow-jobs.mjs читает его по пути + # от себя, и без него ci-proof.mjs отвечает `failed (#622)` на каждом + # слиянии кода. + - name: Скрипты конвейера — из dev (#749) + id: tools + if: steps.ready.outputs.proceed == 'true' + run: | + set -euo pipefail + tools="$RUNNER_TEMP/dev-tools" + rm -rf "$tools" && mkdir -p "$tools" + git fetch -q origin dev + git archive origin/dev scripts .github/workflows/validate.yml | tar -x -C "$tools" + echo "dir=$tools" >> "$GITHUB_OUTPUT" + - name: Получить результат модели if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -1462,10 +1484,11 @@ jobs: SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }} SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }} SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }} + TOOLS: ${{ steps.tools.outputs.dir }} run: | set -euo pipefail dir="$RUNNER_TEMP/review-result" - node scripts/review-result-gate.mjs --dir="$dir" + node "$TOOLS/scripts/review-result-gate.mjs" --dir="$dir" printf 'structured_output<> "$GITHUB_OUTPUT" cat "$dir/verdict.json" >> "$GITHUB_OUTPUT" # structured_output не обязан оканчиваться LF: delimiter команды @@ -1494,6 +1517,8 @@ jobs: # #737: расход модели — последней строкой блока якорей; строка # недоверенная, скрипт разбирает её строго. USAGE: ${{ needs.model_review.outputs.usage }} + # #749: скрипты — из снимка dev, рабочая копия — ветка задачи. + TOOLS: ${{ steps.tools.outputs.dir }} run: | verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true) high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true) @@ -1552,7 +1577,7 @@ jobs: # выводом — через раунд команда из §2.10 не работала. Машина же # снимает якоря в момент чтения материала и ошибиться в них не # может; блок помечен как машинный, чтобы никто не правил его руками. - node scripts/review-doc-guard.mjs --anchor="$doc" \ + node "$TOOLS/scripts/review-doc-guard.mjs" --anchor="$doc" \ --sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \ --branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \ --issue-body="$MATERIAL_ISSUE_BODY" \ @@ -1571,7 +1596,7 @@ jobs: # задачи — один документ: индекс там конфликтовал бы с документами # соседей в dev по построению; его пересоберёт слияние кандидата. if [ -f "$doc" ] && [ "$target" = "dev" ]; then - node scripts/reviews-index.mjs --dir=docs/reviews + node "$TOOLS/scripts/reviews-index.mjs" --dir=docs/reviews git add -- docs/reviews/INDEX.md fi if git diff --cached --quiet; then @@ -1592,7 +1617,7 @@ jobs: exit 1 fi # Первый рубеж: что вообще проиндексировано. - git diff --cached --name-only | node scripts/review-doc-guard.mjs + git diff --cached --name-only | node "$TOOLS/scripts/review-doc-guard.mjs" # Сообщение коммита — построчно в файл, без heredoc в `run:` (#723). msg="$RUNNER_TEMP/review-doc-commit.txt" { @@ -1607,25 +1632,21 @@ jobs: # Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый # судит намерение шага, этот — результат, а расходились они именно # тогда, когда база оказывалась не той. - git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs + git diff --name-only "origin/$target...HEAD" | node "$TOOLS/scripts/review-doc-guard.mjs" # Публикация в dev идёт из детачнутого состояния поверх ветки задачи # либо dev, поэтому push нужен с явным перебазированием при гонке: # dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут. # # #723: гонкой считается только устаревший lease. Отказ разбирает код - # слияния (merge-candidate.mjs --push-refusal, #705) — из dev, как у - # стража ребейза: ветка, отставшая от dev, его может не нести. Отказ - # GitHub (право на workflow, правило ветки, хук) ребейз не лечит: - # шаг падает сразу, причина и ответ git без токена — в журнале и в - # сводке шага; метка не меняется. - tools="$RUNNER_TEMP/publish-tools" - rm -rf "$tools" && mkdir -p "$tools" - git fetch -q origin dev - git archive origin/dev scripts | tar -x -C "$tools" + # слияния (merge-candidate.mjs --push-refusal, #705) — из снимка dev + # (#749): ветка, отставшая от dev, его может не нести. Отказ GitHub + # (право на workflow, правило ветки, хук) ребейз не лечит: шаг падает + # сразу, причина и ответ git без токена — в журнале и в сводке шага; + # метка не меняется. push_err="$RUNNER_TEMP/review-doc-push.stderr" if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \ "HEAD:$target" 2> "$push_err"; then - kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \ + kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \ --stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown if [ "$kind" != "stale" ]; then echo "::error::push документа ревью в $target отклонён ($kind) — это не сдвиг $target, ребейз не поможет; причина и ответ git — выше и в сводке шага" @@ -1642,10 +1663,10 @@ jobs: fi # После ребейза набор путей другой — проверяется заново. Форс здесь # запрещён и не появляется: ветка двигается только вперёд. - git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs + git diff --name-only "origin/$target...HEAD" | node "$TOOLS/scripts/review-doc-guard.mjs" if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \ "HEAD:$target" 2> "$push_err"; then - kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \ + kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \ --stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown echo "::error::документ ревью не опубликован в $target и после ребейза ($kind); причина и ответ git — выше" exit 1 @@ -1688,6 +1709,7 @@ jobs: STAGE: ${{ needs.guard.outputs.stage }} CYCLE: ${{ needs.guard.outputs.cycle }} BRANCH: ${{ needs.prepare.outputs.branch }} + TOOLS: ${{ steps.tools.outputs.dir }} run: | marker=CODE-REVIEW if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi @@ -1696,7 +1718,7 @@ jobs: git fetch -q origin "$target" # Судится опубликованная версия, а не рабочая копия: именно её прочтёт # следующий раунд. - git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=- + git show "origin/$target:$doc" | node "$TOOLS/scripts/review-doc-guard.mjs" --doc=- - name: Решение по вердикту id: decide if: steps.ready.outputs.proceed == 'true' @@ -1723,6 +1745,7 @@ jobs: LIMIT: ${{ needs.guard.outputs.limit }} CYCLE: ${{ needs.guard.outputs.cycle }} BRANCH: ${{ needs.prepare.outputs.branch }} + TOOLS: ${{ steps.tools.outputs.dir }} run: | # ship и повторно применённый зелёный (#499) модель не вызывали: # маршрута у них нет, решение прежнее — зелёный двигает вперёд. @@ -1769,18 +1792,14 @@ jobs: # жёлтый и красный — цикл и возврат автору, на код-ревью show с # `route: reclassify` — повышение до ask или вопрос владельцу. # Bash только исполняет: комментарий — из файла скрипта, метки — - # по его выходу. Скрипт — из dev, как у шага трека: рабочая копия - # здесь — ветка задачи после публикации, и ветка show/ship, - # отставшая от dev, его может ещё не нести. Метки — текущие: шаг - # трека мог в этом же прогоне сменить ship на show. - git fetch -q origin dev - tools="$RUNNER_TEMP/route-tools" - rm -rf "$tools" && mkdir -p "$tools" - git archive origin/dev scripts | tar -x -C "$tools" + # по его выходу. Скрипт — из снимка dev (#749): рабочая копия здесь — + # ветка задачи после публикации, и ветка show/ship, отставшая от dev, + # его может ещё не нести. Метки — текущие: шаг трека мог в этом же + # прогоне сменить ship на show. printf '%s' "$OUT" > "$RUNNER_TEMP/route-verdict.json" labels=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels \ --jq '[.labels[].name] | join(",")') || labels="$LABELS" - route=$(node "$tools/scripts/process-track.mjs" route --stage="$STAGE" --track="$TRACK" \ + route=$(node "$TOOLS/scripts/process-track.mjs" route --stage="$STAGE" --track="$TRACK" \ --confirmed="$CONFIRMED" --labels="$labels" --verdict="$RUNNER_TEMP/route-verdict.json" \ --spent="$SPENT" --limit="$LIMIT" --num="$NUM" --cycle="$CYCLE" --branch="$BRANCH" \ --out="$RUNNER_TEMP/route" \ @@ -1857,13 +1876,17 @@ jobs: MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} # #696: show/ship сливаются по лёгкому Validate на кандидате. MUTANTS: ${{ needs.prepare.outputs.mutants }} + # #749: из снимка dev — и суждение о Validate кандидата (ci-proof.mjs + # с validate.yml снимка), и reviews-index.mjs, который скрипт зовёт + # по пути от себя. + TOOLS: ${{ steps.tools.outputs.dir }} run: | if [ -z "$BRANCH" ]; then echo "::error::ветки задачи нет — сливать нечего" echo "merged=false" >> "$GITHUB_OUTPUT" exit 0 fi - node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \ + node "$TOOLS/scripts/merge-candidate.mjs" --branch="$BRANCH" --material="$MATERIAL_SHA" \ --issue="$NUM" --repo="${{ github.repository }}" --mutants="${MUTANTS:-false}" - name: Переставить метку @@ -1873,6 +1896,7 @@ jobs: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} FROM: ${{ steps.decide.outputs.from }} + TOOLS: ${{ steps.tools.outputs.dir }} # Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к # автору: метка утверждала бы, что код в dev, а его там нет. # Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 — @@ -1884,7 +1908,7 @@ jobs: # статуса. Скрипт снимает и ставит её заново, чтобы событие `labeled` # запустило новый заход. run: | - node scripts/status-label.mjs --repo="${{ github.repository }}" \ + node "$TOOLS/scripts/status-label.mjs" --repo="${{ github.repository }}" \ --issue="$NUM" --from="$FROM" --to="$TO" - name: Сводка длительности стадий diff --git a/PROCESS.md b/PROCESS.md index 27134935..f1e8be81 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1317,6 +1317,28 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit` Validate запускает `ship-review.yml` dispatch'ем с `-f tag=nightly` (§11.7, #727): входы и права для этого в `main` уже есть, тонкие файлы не меняются. +**Скрипты конвейера — из `dev`** (#749). Тело читается из `dev`, поэтому и +скрипты, которые оно зовёт со своими флагами и форматами, — версии `dev`. Job +`integrate` сразу после `setup-node` снимает один снимок `git archive +origin/dev scripts .github/workflows/validate.yml` и зовёт из него каждый +repo-скрипт: `review-result-gate`, `review-doc-guard`, `reviews-index`, +`merge-candidate`, `process-track route`, `status-label`. Снимок один на job — +одна версия на весь заход, а не смесь по шагам. Так же уже работают шаг трека +(#707), страж ребейза (#698) и разбор отказа push (#723). Рабочая копия ветки +задачи — только материал: git-команды, документ и пути судятся в ней. Ветка +`show`/`ship` с чистым слиянием до ревью не ребейзится и может нести +`scripts/`, отставшие от `dev` на дни: её `review-doc-guard.mjs` молча +проглотил бы неизвестный флаг, её `merge-candidate.mjs` слил бы по-старому. +`validate.yml` в снимке обязателен: `workflow-jobs.mjs` читает его по пути от +себя, без него доказательство Validate кандидата — `failed (#622)`. Исключение +— job `model_review`: ревьюер исполняет тесты и скрипты материала, это его +работа, а не конвейера. Следствие для задач, меняющих сам конвейер: их +слияние судит версия `dev`, новая начинает действовать со следующей задачи. +Правка контракта доказательства Validate (имена потребляемых job +`validate.yml`, политики `ci-proof.mjs`, #622) пишется совместимой с +собственным слиянием — старое имя потребляемой job живёт до слияния, — иначе +задачу сливает владелец. + **Красная ночь** (#736). Красный ночной Validate — сигнал с адресатом: job `night_red` в `_nightly.yml` (`scripts/night-red.mjs`) пишет по одному комментарию в каждую задачу, чьи коммиты классов A/B вошли в `dev` после diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 371c4341..b619dc8b 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -4100,7 +4100,7 @@ const MUTANT_DEFINITIONS = [ + 'combined gh call that stripped S7-code-review on rereview', patches: [{ file: '.github/workflows/_process.yml', - find: ' node scripts/status-label.mjs --repo="${{ github.repository }}" \\\n --issue="$NUM" --from="$FROM" --to="$TO"\n', + find: ' node "$TOOLS/scripts/status-label.mjs" --repo="${{ github.repository }}" \\\n --issue="$NUM" --from="$FROM" --to="$TO"\n', replace: ' gh issue edit "$NUM" --repo "${{ github.repository }}" \\\n --add-label "$TO" --remove-label "$FROM"\n', }], }, diff --git a/test/process-integrate-tools.test.mjs b/test/process-integrate-tools.test.mjs new file mode 100644 index 00000000..574872ef --- /dev/null +++ b/test/process-integrate-tools.test.mjs @@ -0,0 +1,180 @@ +// #749: job `integrate` исполняет скрипты конвейера одним снимком `dev`. +// +// Тело `_process.yml` читается из `dev` (`@dev`, #623), значит, и флаги с +// форматами, с которыми оно зовёт скрипты, — версии `dev`. Рабочая копия после +// публикации документа — ветка задачи, а ветка show/ship с чистым слиянием до +// ревью не ребейзится (§10.4) и может нести `scripts/`, отставшие на дни. +// Здесь — контракт job: ни один шаг не зовёт скрипт из рабочей копии, каждый +// вызов идёт через каталог снимка, снимок берётся из `origin/dev` и несёт +// `validate.yml`, без которого `ci-proof.mjs` отвечает `failed (#622)` на +// каждом слиянии. Самодостаточность снимка проверяется исполнением: шаг как +// есть, настоящим bash и git, на временном origin с нынешними `scripts/`. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const WORKFLOW = join(ROOT, '.github', 'workflows', '_process.yml'); +const TOOLS_STEP = 'Скрипты конвейера — из dev (#749)'; +const TOOLS_ENV = 'TOOLS: ${{ steps.tools.outputs.dir }}'; + +/** Блок job: от ` :` до следующей job на том же отступе. */ +function jobBlock(text, id) { + const start = text.indexOf(`\n ${id}:\n`); + assert.ok(start >= 0, `job ${id}`); + const rest = text.slice(start + 1); + const next = rest.slice(1).search(/\n {2}[\w-]+:\n/); + return next < 0 ? rest : rest.slice(0, next + 2); +} + +/** + * Шаги job: имя, id, if, env и тело `run` так, как его прочтёт YAML (блок + * кончается на первой непустой строке с отступом меньше тела). Комментарии + * тела отброшены: судится то, что исполняется. + */ +function stepsOf(job) { + const lines = job.slice(job.indexOf('\n steps:\n') + 1).split('\n').slice(1); + const steps = []; + for (const line of lines) { + if (/^ {6}- /.test(line)) steps.push([line]); + else if (steps.length) steps.at(-1).push(line); + } + return steps.map((stepLines) => { + const text = stepLines.join('\n'); + const field = (key) => text.match(new RegExp(`^ {6}(?:- | {2})${key}: (.+)$`, 'm'))?.[1]; + const env = []; + const envAt = stepLines.indexOf(' env:'); + if (envAt >= 0) { + for (const line of stepLines.slice(envAt + 1)) { + if (/^ {10}#/.test(line)) continue; + if (!/^ {10}\S/.test(line)) break; + env.push(line.trim()); + } + } + let run = ''; + const runAt = stepLines.indexOf(' run: |'); + if (runAt >= 0) { + const body = []; + for (const line of stepLines.slice(runAt + 1)) { + if (line.trim() && !/^ {10}/.test(line)) break; + body.push(line.slice(10)); + } + run = body.join('\n'); + } else { + run = field('run') ?? ''; + } + const code = run.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n'); + return { text, name: field('name') ?? field('uses'), id: field('id'), if: field('if'), env, run, code }; + }); +} + +const integrateSteps = () => stepsOf(jobBlock(readFileSync(WORKFLOW, 'utf8'), 'integrate')); + +test('#749 AC2: в job integrate ни один шаг не зовёт скрипт из рабочей копии — только из снимка dev', () => { + const steps = integrateSteps(); + const calls = []; + for (const step of steps) { + assert.doesNotMatch(step.code, /(? m[1]); + if (used.length || /\$TOOLS\b/.test(step.code)) { + assert.ok(step.env.includes(TOOLS_ENV), `«${step.name}»: каталог снимка — из выхода шага tools`); + } + for (const script of used) calls.push(`${step.name}: ${script}`); + } + // Все вызовы, которые issue называет, — через снимок (#749 К2). + const byScript = (name) => calls.filter((call) => call.endsWith(`: ${name}`)).length; + assert.deepEqual( + Object.fromEntries(['review-result-gate.mjs', 'review-doc-guard.mjs', 'reviews-index.mjs', 'merge-candidate.mjs', 'process-track.mjs', 'status-label.mjs'] + .map((name) => [name, byScript(name)])), + // review-doc-guard: якорь, рубеж индекса, два рубежа диапазона (до и после + // ребейза) и --doc=- шага #413; merge-candidate: два разбора отказа push и слияние. + { 'review-result-gate.mjs': 1, 'review-doc-guard.mjs': 5, 'reviews-index.mjs': 1, 'merge-candidate.mjs': 3, 'process-track.mjs': 1, 'status-label.mjs': 1 }, + calls.join('\n'), + ); +}); + +test('#749 AC2: снимок — один на job, из origin/dev, с validate.yml, до первого шага со скриптом', () => { + const steps = integrateSteps(); + const at = steps.findIndex((step) => step.name === TOOLS_STEP); + assert.ok(at >= 0, `шаг «${TOOLS_STEP}»`); + const snapshot = steps[at]; + assert.equal(snapshot.id, 'tools'); + const checkout = steps.find((step) => /^actions\/checkout@/.test(step.name)); + assert.equal(snapshot.if, checkout.if, 'снимок есть всякий раз, когда есть рабочая копия'); + assert.ok(steps.findIndex((step) => /^actions\/setup-node@/.test(step.name)) < at, 'после setup-node'); + const firstUser = steps.findIndex((step) => /\$TOOLS\b/.test(step.code)); + assert.ok(firstUser > at, 'до первого шага, который зовёт скрипт'); + assert.match(snapshot.code, /^set -euo pipefail$/m, 'сбой git archive не проходит молча через | tar'); + assert.match(snapshot.code, /^git fetch -q origin dev$/m); + assert.match(snapshot.code, /^git archive origin\/dev scripts \.github\/workflows\/validate\.yml \| tar -x -C "\$tools"$/m, + 'снимок из origin/dev; validate.yml читает workflow-jobs.mjs по пути от себя'); + assert.match(snapshot.code, /^echo "dir=\$tools" >> "\$GITHUB_OUTPUT"$/m); + const archives = steps.flatMap((step) => [...step.code.matchAll(/git archive/g)].map(() => step.name)); + assert.deepEqual(archives, [TOOLS_STEP], 'одна версия скриптов на весь заход'); +}); + +const hasTools = () => process.platform !== 'win32' + && ['bash', 'tar', 'git'].every((tool) => spawnSync(tool, ['--version']).status === 0); + +// Окружение git без GIT_* родителя и без глобального конфига (урок #633, #496). +const GIT_ENV = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))), + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'init.defaultBranch', GIT_CONFIG_VALUE_0: 'dev', +}; + +test('#749 AC2: снимок самодостаточен — шаг как есть даёт каталог, из которого ci-proof читает контракт validate.yml', async (t) => { + if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } + const root = mkdtempSync(join(tmpdir(), 'hp-749-tools-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const git = (cwd, ...args) => { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV }); + assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); + }; + const origin = join(root, 'origin.git'); + const work = join(root, 'work'); + const temp = join(root, 'runner'); + mkdirSync(temp); + git(root, 'init', '--bare', '-q', origin); + git(root, 'clone', '-q', origin, work); + git(work, 'checkout', '-q', '-b', 'dev'); + // dev временного origin несёт нынешние scripts/ и validate.yml — то, что + // снимок возьмёт из dev настоящего. + cpSync(join(ROOT, 'scripts'), join(work, 'scripts'), { + recursive: true, filter: (src) => !['node_modules', '__pycache__'].includes(basename(src)), + }); + mkdirSync(join(work, '.github', 'workflows'), { recursive: true }); + cpSync(join(ROOT, '.github', 'workflows', 'validate.yml'), join(work, '.github', 'workflows', 'validate.yml')); + git(work, 'add', '-A'); + git(work, 'commit', '-q', '-m', 'dev'); + git(work, 'push', '-q', 'origin', 'dev'); + const snapshot = integrateSteps().find((step) => step.name === TOOLS_STEP); + assert.ok(snapshot, `шаг «${TOOLS_STEP}»`); + const output = join(temp, 'output'); + // Шаг без `shell:` GitHub исполняет как `bash -e {0}`. + const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', snapshot.run], { + cwd: work, encoding: 'utf8', env: { ...GIT_ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output }, + }); + assert.equal(r.status, 0, r.stderr); + const dir = readFileSync(output, 'utf8').match(/^dir=(.+)$/m)?.[1]; + assert.ok(dir, 'шаг назвал каталог снимка'); + assert.ok(!dir.startsWith(work), 'снимок — вне рабочей копии'); + assert.ok(existsSync(join(dir, '.github', 'workflows', 'validate.yml')), 'validate.yml в снимке'); + const { resolveJobRules } = await import(pathToFileURL(join(dir, 'scripts', 'ci-proof.mjs')).href); + assert.doesNotThrow(() => resolveJobRules(), 'контракт имён job читается из validate.yml снимка (#622)'); + // Каждый скрипт, который зовут шаги, импортируется из снимка без node_modules. + for (const name of ['review-result-gate', 'review-doc-guard', 'reviews-index', 'merge-candidate', 'process-track', 'status-label']) { + await import(pathToFileURL(join(dir, 'scripts', `${name}.mjs`)).href); + } +}); diff --git a/test/process-track.test.mjs b/test/process-track.test.mjs index b9097d17..49153a80 100644 --- a/test/process-track.test.mjs +++ b/test/process-track.test.mjs @@ -611,6 +611,7 @@ function stepRun(workflow, marker) { const TRACK_STEP = ' - name: "Трек задачи и рамки ship (#696)"\n'; const GUARD_STEP = ' - id: decide\n'; const DECIDE_STEP = ' - name: Решение по вердикту\n'; +const TOOLS_STEP = ' - name: Скрипты конвейера — из dev (#749)\n'; const PUBLISH_STEP = ' - name: Опубликовать документ ревью\n'; test('#707 AC4: изменённые run шага трека, guard и решения по вердикту проходят bash -n', async (t) => { @@ -713,6 +714,9 @@ function trackSandbox(t, { change, base = () => {} }) { assert.ok(file.startsWith(SCRIPTS_DIR), `${file} вне scripts/`); writeFileSync(join(work, 'scripts', file.slice(SCRIPTS_DIR.length + 1)), readFileSync(file)); } + // #749: снимок скриптов integrate берёт из dev и validate.yml. + mkdirSync(join(work, '.github', 'workflows'), { recursive: true }); + writeFileSync(join(work, '.github', 'workflows', 'validate.yml'), readFileSync(join(dirname(WORKFLOW), 'validate.yml'))); mkdirSync(join(work, 'src', 'styles'), { recursive: true }); writeFileSync(join(work, 'src', 'pointer-modality.ts'), 'export const a = 1;\nexport const b = 2;\n'); writeFileSync(join(work, 'src', 'styles', 'plan.styles.ts'), 'export const css = `\n .x { color: red; }\n`;\n'); @@ -740,6 +744,7 @@ function trackSandbox(t, { change, base = () => {} }) { '', ].join('\n'), { mode: 0o755 }); const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : ''); + let tools = ''; return { work, fake, run(script, env) { @@ -749,7 +754,7 @@ function trackSandbox(t, { change, base = () => {} }) { cwd: work, encoding: 'utf8', env: { ...GIT_ENV, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, FAKE_DIR: fake, GH_TOKEN: 'x', NUM: '7', - GITHUB_OUTPUT: join(temp, 'output'), GITHUB_STEP_SUMMARY: join(temp, 'summary'), ...env, + GITHUB_OUTPUT: join(temp, 'output'), GITHUB_STEP_SUMMARY: join(temp, 'summary'), ...(tools ? { TOOLS: tools } : {}), ...env, }, }); return { @@ -766,6 +771,14 @@ function trackSandbox(t, { change, base = () => {} }) { if (list === null) rmSync(join(fake, 'labels'), { force: true }); else writeFileSync(join(fake, 'labels'), `${list.join(',')}\n`); }, + /** #749: шаг снимка job integrate как есть; его каталог дальше идёт шагам как TOOLS. */ + snapshot() { + const r = this.run(stepRun(readFileSync(WORKFLOW, 'utf8'), TOOLS_STEP), {}); + assert.equal(r.status, 0, `снимок скриптов dev: ${r.stderr}`); + assert.ok(r.output.dir && existsSync(join(r.output.dir, 'scripts', 'process-track.mjs')), 'снимок несёт скрипт трека'); + tools = r.output.dir; + return tools; + }, }; } @@ -1107,8 +1120,9 @@ test('#726 AC5: шаг решения — один вызов process-track.mjs const workflow = readFileSync(WORKFLOW, 'utf8'); const run = stepRun(workflow, DECIDE_STEP); assert.equal((run.match(/process-track\.mjs/g) || []).length, 1, 'один вызов скрипта'); - assert.match(run, /route=\$\(node "\$tools\/scripts\/process-track\.mjs" route --stage="\$STAGE" --track="\$TRACK" \\\n\s+--confirmed="\$CONFIRMED" --labels="\$labels" --verdict="\$RUNNER_TEMP\/route-verdict\.json"/); - assert.match(run, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev'); + assert.match(run, /route=\$\(node "\$TOOLS\/scripts\/process-track\.mjs" route --stage="\$STAGE" --track="\$TRACK" \\\n\s+--confirmed="\$CONFIRMED" --labels="\$labels" --verdict="\$RUNNER_TEMP\/route-verdict\.json"/); + // #749: скрипт — из снимка dev на всю job, своего извлечения у шага нет. + assert.doesNotMatch(run, /git archive/, 'скрипт — из снимка dev'); assert.equal((run.match(/gh issue edit/g) || []).length, 1, 'метки меняются в одном месте'); assert.match(run, /if \[ -n "\$add" \]; then edit\+=\(--add-label "\$add"\); fi\n\s+if \[ -n "\$remove" \]; then edit\+=\(--remove-label "\$remove"\); fi/); assert.match(run, /add=\$\(field add_labels\); remove=\$\(field remove_labels\); comment=\$\(field comment\)/); @@ -1121,7 +1135,7 @@ test('#726 AC5: шаг решения — один вызов process-track.mjs const step = workflow.slice(workflow.indexOf(DECIDE_STEP), workflow.indexOf(' - name: dev ушёл вперёд')); for (const env of ['TRACK: ${{ needs.prepare.outputs.track }}', 'CONFIRMED: ${{ needs.prepare.outputs.confirmed }}', 'SPENT: ${{ needs.guard.outputs.spent }}', 'LIMIT: ${{ needs.guard.outputs.limit }}', 'CYCLE: ${{ needs.guard.outputs.cycle }}', - 'BRANCH: ${{ needs.prepare.outputs.branch }}', 'LABELS: ${{ needs.guard.outputs.labels }}']) { + 'BRANCH: ${{ needs.prepare.outputs.branch }}', 'LABELS: ${{ needs.guard.outputs.labels }}', 'TOOLS: ${{ steps.tools.outputs.dir }}']) { assert.ok(step.includes(` ${env}\n`), env); } // Многострочного текста в новой ветке нет: heredoc — только прежний комментарий слияния ship. @@ -1147,6 +1161,7 @@ const LABELS_VIEW = 'issue view 7 --repo o/r --json labels --jq [.labels[].name] test('#726 AC5: шаг решения на настоящем bash — reclassify: ask, S3-spec, комментарий с hp:route и перечнем CODE-REVIEW', async (t) => { if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) }); + box.snapshot(); const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP); box.labels(['track:show', 'S7-code-review', 'P2']); const r = box.run(run, decideEnv({ OUT: verdictOut({ route: 'reclassify', criterion: 'undocumented' }) })); @@ -1177,6 +1192,7 @@ test('#726 AC5: шаг решения на настоящем bash — reclassif test('#726 AC5: шаг решения на настоящем bash — исчерпание, вопрос владельцу, fix, зелёный и сбой скрипта', async (t) => { if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) }); + box.snapshot(); const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP); box.labels(['track:show', 'S7-code-review']); const commentPath = join(dirname(box.work), 'runner', 'route', 'comment.md'); diff --git a/test/publish-push-refusal.test.mjs b/test/publish-push-refusal.test.mjs index 828cb83d..494742ca 100644 --- a/test/publish-push-refusal.test.mjs +++ b/test/publish-push-refusal.test.mjs @@ -99,6 +99,9 @@ function stepRun(file, name) { const RELEASE_STEP = () => stepRun('release-review.yml', 'Опубликовать документ'); const REVIEW_DOC_STEP = () => stepRun('_process.yml', 'Опубликовать документ ревью'); +// #749: скрипты job integrate — одним снимком dev; шаги получают каталог выходом `dir`. +const TOOLS_STEP = () => stepRun('_process.yml', 'Скрипты конвейера — из dev (#749)'); +const REPRO_STEP = () => stepRun('_process.yml', '"Материал раунда воспроизводим (#413)"'); /** * Песочница: bare origin, рабочая копия, соседний клон и bin с подменами. @@ -126,6 +129,9 @@ function sandbox(root) { // Скрипты шага — из репозитория как есть: их несёт dev временного origin. mkdirSync(join(work, 'scripts')); for (const file of STEP_SCRIPTS) copyFileSync(file, join(work, 'scripts', relative(SCRIPTS, file))); + // #749: снимок скриптов integrate берёт из dev и validate.yml (его читает workflow-jobs.mjs). + mkdirSync(join(work, '.github', 'workflows'), { recursive: true }); + copyFileSync(join(WORKFLOWS, 'validate.yml'), join(work, '.github', 'workflows', 'validate.yml')); mkdirSync(join(work, 'docs', 'reviews'), { recursive: true }); writeFileSync(join(work, 'docs', 'reviews', 'CODE-REVIEW-1-r1.md'), '# CODE-REVIEW-1-r1\nВердикт: **зелёный** · High: 0 · Medium: 0\n'); writeFileSync(join(work, 'docs', 'reviews', 'INDEX.md'), buildIndex(join(work, 'docs', 'reviews'))); @@ -303,12 +309,24 @@ function taskBranch(box) { git(box.work, 'checkout', '-q', 'dev'); } +/** + * #749: шаг снимка как есть — на рабочей копии dev, как после checkout в + * integrate. Возвращает каталог из его выхода `dir`: его шаги получают env TOOLS. + */ +function devTools(box) { + const r = box.run(TOOLS_STEP(), {}); + assert.equal(r.status, 0, `снимок скриптов dev: ${r.stderr}${r.stdout}`); + const dir = readFileSync(join(box.temp, 'output'), 'utf8').match(/^dir=(.+)$/m)?.[1]; + assert.ok(dir && existsSync(join(dir, 'scripts')), 'снимок назвал каталог со scripts/'); + return dir; +} + function runReviewDoc(box, out = '{"verdict":"green","high":0}', extra = {}) { const source = join(box.temp, 'review-result', 'review-document.md'); mkdirSync(join(box.temp, 'review-result')); writeFileSync(source, '# Код-ревью #9, раунд 1\n\nВердикт: **зелёный** · High: 0 · Medium: 0\n'); return box.run(REVIEW_DOC_STEP(), { - BRANCH, NUM: '9', STAGE: 'code', CYCLE: '1', SOURCE: source, + BRANCH, NUM: '9', STAGE: 'code', CYCLE: '1', SOURCE: source, TOOLS: extra.TOOLS ?? devTools(box), MATERIAL_SHA: git(box.origin, 'rev-parse', BRANCH), MATERIAL_TREE: git(box.origin, 'rev-parse', `${BRANCH}^{tree}`), MATERIAL_SPECS: '', MATERIAL_ISSUE_BODY: '', OUT: out, ...extra, }); @@ -429,6 +447,50 @@ test('#737 AC3 _process.yml на настоящем bash: ребейз и вто assert.equal(doc.split(USAGE).length - 1, 1, 'строка одна'); }); +// ---------- #749: скрипты job integrate — из снимка dev ---------- + +// Ветка show/ship с чистым слиянием до ревью не ребейзится и может нести +// отставший review-doc-guard.mjs: такой молча терял флаги якоря (маршрут #726, +// расход #737). Здесь её версия громкая — пишет маркер в документ и выходит 7. +const BRANCH_GUARD = [ + "import { appendFileSync } from 'node:fs';", + "const anchor = process.argv.find((arg) => arg.startsWith('--anchor='));", + "if (anchor) appendFileSync(anchor.slice('--anchor='.length), '\\nBRANCH-VERSION\\n');", + "appendFileSync(`${process.env.RUNNER_TEMP}/branch-version.log`, `BRANCH-VERSION ${process.argv.slice(2).join(' ')}\\n`);", + 'process.exit(7);', + '', +].join('\n'); + +test('#749 AC1 _process.yml на настоящем bash: якорь и проверка #413 — версия dev, скрипт ветки задачи не исполняется', async (t) => { + if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; } + const { ANCHOR_MARKER, materialAnchorsFrom } = await import('../scripts/review-doc-guard.mjs'); + const box = sandbox(tempRoot(t, 'hp-749-doc-')); + git(box.work, 'checkout', '-q', '-b', BRANCH); + writeFileSync(join(box.work, 'a.mjs'), 'export const a = 9;\n'); + writeFileSync(join(box.work, 'scripts', 'review-doc-guard.mjs'), BRANCH_GUARD); + commitAll(box.work, 'fix: a (#9)'); + git(box.work, 'push', '-q', 'origin', BRANCH); + git(box.work, 'checkout', '-q', 'dev'); + const tree = git(box.origin, 'rev-parse', `${BRANCH}^{tree}`); + const branchLog = join(box.temp, 'branch-version.log'); + // Один снимок на job: его каталог получают и публикация, и шаг #413. + const tools = devTools(box); + const out = JSON.stringify({ verdict: 'yellow', high: 0, medium: 1, summary: 's', route: 'reclassify', criterion: 'undocumented' }); + const r = runReviewDoc(box, out, { USAGE, TOOLS: tools }); + assert.equal(r.status, 0, r.stderr + r.stdout); + assert.ok(!existsSync(branchLog), `скрипт ветки задачи исполнялся: ${existsSync(branchLog) ? readFileSync(branchLog, 'utf8') : ''}`); + const doc = git(box.origin, 'show', `${BRANCH}:${REVIEW_DOC}`); + assert.doesNotMatch(doc, /BRANCH-VERSION/); + assert.ok(doc.includes(ANCHOR_MARKER), 'машинный блок якорей'); + assert.match(doc, /^- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify` \(критерий `undocumented`\)$/m, 'маршрут — из OUT'); + assert.deepEqual(materialAnchorsFrom(doc), [tree]); + assert.equal(lastLine(doc), USAGE, 'флаг --usage понят: версия dev'); + // Шаг #413 на том же origin: опубликованный документ судит та же версия dev. + const repro = box.run(REPRO_STEP(), { NUM: '9', STAGE: 'code', CYCLE: '1', BRANCH, TOOLS: tools }); + assert.equal(repro.status, 0, repro.stderr + repro.stdout); + assert.ok(!existsSync(branchLog), 'шаг #413 не исполнял скрипт ветки задачи'); +}); + // ---------- #730 _ship-review.yml: SHIP-REVIEW в dev ---------- const SHIP_STEP = () => stepRun('_ship-review.yml', 'Опубликовать документ'); @@ -601,14 +663,15 @@ for (const [label, stderr, kind, reason] of [ // ---------- AC3 и разбор: тексты — из кода, не из run ---------- test('#723 AC3: в run обоих шагов нет многострочного текста и heredoc; отказ разбирает код слияния', () => { - for (const [label, body, tools] of [['release-review.yml', RELEASE_STEP(), 'scripts'], ['_process.yml', REVIEW_DOC_STEP(), '"$tools/scripts']]) { + for (const [label, body, tools] of [['release-review.yml', RELEASE_STEP(), 'scripts'], ['_process.yml', REVIEW_DOC_STEP(), '"$TOOLS/scripts']]) { assert.doesNotMatch(body, /<<-?\s*['"]?[A-Za-z_]/, `${label}: heredoc в run`); assert.ok(body.includes(`kind=$(node ${tools}/merge-candidate.mjs`), `${label}: разбор — merge-candidate.mjs --push-refusal`); assert.match(body, /--push-refusal="\$push_err"[^\n]*\\\n[^\n]*--summary="\$GITHUB_STEP_SUMMARY"\) \|\| kind=unknown/, `${label}: сводку пишет код`); assert.match(body, /2> "\$push_err"; then/, `${label}: stderr push идёт в разбор`); } - // Шаг _process.yml берёт разбор из dev: ветка задачи, отставшая от dev, его может не нести. - assert.match(REVIEW_DOC_STEP(), /git archive origin\/dev scripts \| tar -x -C "\$tools"/); + // Шаг _process.yml берёт разбор из снимка dev (#749): ветка задачи, отставшая от dev, его может не нести. + assert.doesNotMatch(REVIEW_DOC_STEP(), /git archive/, 'своего извлечения у шага нет — снимок job'); + assert.match(TOOLS_STEP(), /git archive origin\/dev scripts \.github\/workflows\/validate\.yml \| tar -x -C "\$tools"/); // Блок run не обрезан: последняя строка каждого шага на месте. assert.match(RELEASE_STEP(), /echo "::error::документ ревью не опубликован в dev за три попытки"\nexit 1\n*$/); assert.match(REVIEW_DOC_STEP(), /echo "документ опубликован в \$target: \$doc"\n*$/); diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 787686fc..b25eefc9 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -77,7 +77,7 @@ test('шаг публикации в конвейере проверяет и и // Два рубежа: что проиндексировано и что пуш добавит в ветку. Расходились они // именно тогда, когда база оказывалась не той. assert.equal( - (step.match(/git diff --cached --name-only \| node scripts\/review-doc-guard\.mjs/g) || []).length, + (step.match(/git diff --cached --name-only \| node "\$TOOLS\/scripts\/review-doc-guard\.mjs"/g) || []).length, 1, 'индекс проверяется один раз, перед коммитом', ); // Дважды: push делается из двух мест — сразу и после ребейза при гонке. Одна @@ -85,7 +85,7 @@ test('шаг публикации в конвейере проверяет и и // именно он срабатывает, когда dev ушёл вперёд — то есть в тех самых // условиях, при которых случился bb2919f. assert.equal( - (step.match(/git diff --name-only "origin\/\$target\.\.\.HEAD" \| node scripts\/review-doc-guard\.mjs/g) || []).length, + (step.match(/git diff --name-only "origin\/\$target\.\.\.HEAD" \| node "\$TOOLS\/scripts\/review-doc-guard\.mjs"/g) || []).length, 2, 'диапазон проверяется перед каждым push', ); // Свежая база вместо той, что лежала здесь сорок минут назад. @@ -903,7 +903,7 @@ test('#551: gates, модель и интеграция имеют незави // YAML поставить было некуда (`test/review-result-gate.test.mjs`). Здесь // проверяется, что привилегированная стадия ходит через него и передаёт ему // весь паспорт, а не его часть. - assert.match(integrate, /node scripts\/review-result-gate\.mjs --dir="\$dir"/); + assert.match(integrate, /node "\$TOOLS\/scripts\/review-result-gate\.mjs" --dir="\$dir"/); for (const field of ['MATERIAL_SHA', 'MATERIAL_TREE', 'STAGE', 'CYCLE', 'BRANCH', 'ISSUE']) { assert.match(integrate, new RegExp(`^\\s+${field}: `, 'm'), `${field} передаётся гейту`); } diff --git a/test/review-result-gate.test.mjs b/test/review-result-gate.test.mjs index a861530c..72c08dbe 100644 --- a/test/review-result-gate.test.mjs +++ b/test/review-result-gate.test.mjs @@ -136,7 +136,7 @@ test('#556: integrate пропускает artifact только через ге integrate.indexOf(' # Ревьюер пишет только в docs/reviews/.'), ); assert.ok(step.length > 0, 'шаг проверки найден'); - assert.match(step, /^\s+node scripts\/review-result-gate\.mjs --dir="\$dir"$/m); + assert.match(step, /^\s+node "\$TOOLS\/scripts\/review-result-gate\.mjs" --dir="\$dir"$/m); for (const field of PASSPORT_FIELDS) { if (field === 'run_id' || field === 'run_attempt') continue; // приходят из GITHUB_* assert.match(step, new RegExp(`^\\s+${field.toUpperCase()}: `, 'm'), `${field} передаётся гейту`); diff --git a/test/reviews-index.test.mjs b/test/reviews-index.test.mjs index 1b4e3f43..8451e220 100644 --- a/test/reviews-index.test.mjs +++ b/test/reviews-index.test.mjs @@ -145,8 +145,8 @@ test('#635/#657 (1б): индекс пересобирается только к const wf = new URL('../.github/workflows/_process.yml', import.meta.url); const text = readFileSync(wf, 'utf8'); // Публикация документа: индекс — тем же коммитом, только если цель — dev - // (ревью ТЗ). В ветку задачи — один документ. - assert.match(text, /if \[ -f "\$doc" \] && \[ "\$target" = "dev" \]; then\n\s+node scripts\/reviews-index\.mjs --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/); + // (ревью ТЗ). В ветку задачи — один документ. Скрипт — из снимка dev (#749). + assert.match(text, /if \[ -f "\$doc" \] && \[ "\$target" = "dev" \]; then\n\s+node "\$TOOLS\/scripts\/reviews-index\.mjs" --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/); // Приведение ветки к dev индекс больше не коммитит: ветка задачи его не несёт. const rebase = text.slice(text.indexOf('- name: Привести ветку к dev'), text.indexOf('- name: Зафиксировать SHA материала ревью')); assert.doesNotMatch(rebase, /reviews-index\.mjs/, 'в ветке задачи индекс не пересобирается (#657)'); diff --git a/test/status-label.test.mjs b/test/status-label.test.mjs index 6ba26f40..61cce9a0 100644 --- a/test/status-label.test.mjs +++ b/test/status-label.test.mjs @@ -51,6 +51,7 @@ test('#706 шаг конвейера переставляет метку чер const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_process.yml', import.meta.url)), 'utf8'); const step = workflow.slice(workflow.indexOf('- name: Переставить метку'), workflow.indexOf('- name: Сводка длительности стадий')); assert.ok(step.length > 0, 'шаг найден'); - assert.match(step, /node scripts\/status-label\.mjs --repo="\$\{\{ github\.repository \}\}" \\\n\s+--issue="\$NUM" --from="\$FROM" --to="\$TO"/); + // #749: скрипт — из снимка dev job integrate, не из рабочей копии ветки задачи. + assert.match(step, /node "\$TOOLS\/scripts\/status-label\.mjs" --repo="\$\{\{ github\.repository \}\}" \\\n\s+--issue="\$NUM" --from="\$FROM" --to="\$TO"/); assert.doesNotMatch(step, /gh issue edit/, 'совмещённый вызов снимал ту же метку, которую ставил'); });