From ccfd2565a7f5e5f8c130b8096de2ae5c42a0f33f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 12 Sep 2026 13:54:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B8=D1=81=D0=BF=D0=B0=D1=82=D1=87=20?= =?UTF-8?q?=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D1=82=D0=BE=D0=B3=D0=BE,=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=B2=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=B5=D1=85=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `workflow_dispatch` в API принимает только ref: SHA туда передать нельзя, имя ветки резолвится на стороне GitHub в момент запуска. Конвейер перед этим сам переписывает ветку ребейзом — и 12.09 на #536 диспатч, отправленный через три секунды после force-push, встал на ДОпушевый SHA. Гейт искал прогон строго на SHA материала, не нашёл и вернул задачу автору со словами «материал сменился». Чинить было нечего: дерево задачи не менялось ни на байт, материал сдвинул сам конвейер. Две меры, у каждой своя роль. Шаг ребейза не заканчивается, пока REST не отдаст новую вершину — именно REST, потому что через него же идёт диспатч. Минута ожидания, после чего отказ, а не молчание: диспатч на устаревший SHA стоит трёх минут гейта и потерянного захода. Гейт, не дождавшись прогона на материале и увидев на ветке диспатч на другом SHA, сначала пробует запустить ещё раз. Своя гонка этим закрывается, чужой коммит переживает и вторую попытку, а формулировка отказа больше не называет сменой материала то, что ею не является. Issue: #539 User-Visible: no --- .github/workflows/process.yml | 21 +++++++++++++++++++++ scripts/mutation-gate.mjs | 25 +++++++++++++++++++++++++ scripts/validate-gate.mjs | 32 +++++++++++++++++++++++++++++--- test/review-doc-guard.test.mjs | 22 ++++++++++++++++++++++ test/validate-gate.test.mjs | 24 ++++++++++++++++++++++-- 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 6d2287c3..a9384849 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -347,6 +347,8 @@ jobs: if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != '' env: TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + # #539: тем же токеном спрашивается REST — через него идёт и диспатч. + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} # rebase, в отличие от commit, не принимает -c user.*: он запускает # свои процессы и требует личность в окружении, иначе падает с @@ -389,6 +391,25 @@ jobs: echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора" exit 1 fi + # #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже + # просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать + # нельзя. 12.09 на #536 диспатч, отправленный через три секунды после + # этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на + # материале и вернул задачу автору, которому чинить было нечего. + # Поэтому шаг не заканчивается, пока REST не отдаст новую вершину — + # именно REST, потому что через него же идёт и сам диспатч. + after=$(git rev-parse HEAD) + settled=false + for _ in $(seq 1 30); do + seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \ + --jq .object.sha 2>/dev/null || true) + if [ "$seen" = "$after" ]; then settled=true; break; fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA" + exit 1 + fi # Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH, # и без этого он ребейзил бы заново уже приведённое. git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index f122ff2a..d97bcacb 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -7710,6 +7710,31 @@ const MUTANT_DEFINITIONS = [ replace: '', }], }, + { + id: 'gate-blames-the-author-for-its-own-stale-dispatch', + guard: 'node --test test/validate-gate.test.mjs', + because: '#539: `workflow_dispatch` принимает только ref, и конвейер сам переписывает ' + + 'ветку ребейзом за секунды до запуска. Без повторной попытки собственная гонка ' + + 'выглядит как «материал сменился», и задача уезжает автору, которому чинить нечего — ' + + 'ровно это случилось с #536 12.09', + patches: [{ + file: 'scripts/validate-gate.mjs', + find: ' if (elsewhere && attempts < DISPATCH_ATTEMPTS) {', + replace: ' if (false && elsewhere && attempts < DISPATCH_ATTEMPTS) {', + }], + }, + { + id: 'rebase-dispatches-before-the-ref-settles', + guard: 'node --test test/review-doc-guard.test.mjs', + because: '#539: ссылка на стороне GitHub доезжает не мгновенно, а диспатч идёт по имени ' + + 'ветки. Без ожидания шаг возвращается сразу после push, и запуск встаёт на вершину, ' + + 'которой на ветке уже нет', + patches: [{ + file: '.github/workflows/process.yml', + find: ' if [ "$seen" = "$after" ]; then settled=true; break; fi', + replace: ' settled=true; break', + }], + }, { id: 'render-invalidation-unknown-key-ignored', guard: 'node --test test/render-invalidation.test.mjs', diff --git a/scripts/validate-gate.mjs b/scripts/validate-gate.mjs index 7382b835..3e0e871d 100755 --- a/scripts/validate-gate.mjs +++ b/scripts/validate-gate.mjs @@ -20,6 +20,17 @@ import { resolve } from 'node:path'; import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs'; export const POLL_MS = 20_000; +/** + * Сколько раз гейт пробует запустить свой dispatch (#539). + * + * `workflow_dispatch` в API принимает только ref, а не SHA: имя ветки + * резолвится на стороне GitHub в момент запуска. Конвейер перед этим сам + * переписывает ветку ребейзом, и 12.09 на #536 диспатч, отправленный через + * три секунды после force-push, встал на ДОпушевый SHA — гейт не нашёл прогона + * на материале и вернул задачу автору, которому чинить было нечего. Вторая + * попытка закрывает это окно: ссылка к тому времени доезжает гарантированно. + */ +export const DISPATCH_ATTEMPTS = 2; /** Кандидат в доказательства: dispatch — только там мутанты могут быть запрошены. */ export function isMutantRun(run) { @@ -51,6 +62,7 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M const ignored = new Set(); // завершённые dispatch, которые ничего не доказывают: отменённые и зелёные без мутантов let tracked = null; let dispatchedAt = null; + let attempts = 0; while (ops.now() - started < totalMs) { const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId)); const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0]; @@ -75,13 +87,27 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M } else if (dispatchedAt === null) { await ops.dispatch(ref); dispatchedAt = ops.now(); + attempts = 1; } else if (ops.now() - dispatchedAt > appearMs) { - // Прогон должен был появиться. Если на ветке появился dispatch на другом - // SHA — материал сменился под ногами; иначе запуск просто не прошёл. + // Прогон должен был появиться. Если на ветке стоит dispatch на другом + // SHA — либо ссылка ветки в момент запуска ещё отдавала прежнюю вершину + // (#539: конвейер сам переписал её ребейзом за секунды до этого), либо + // материал действительно сменился. Различать гадательно нечем, поэтому + // гейт сначала пробует ещё раз: собственная гонка этим закрывается, а + // чужой коммит переживёт и вторую попытку. const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha); + if (elsewhere && attempts < DISPATCH_ATTEMPTS) { + await ops.dispatch(ref); + dispatchedAt = ops.now(); + attempts += 1; + await ops.sleep(pollMs); + continue; + } return { result: 'missing', url: elsewhere?.url || null, - note: elsewhere ? `материал сменился: dispatch-прогон стоит на ${String(elsewhere.headSha).slice(0, 8)}` : 'dispatch-прогон не появился за 3 минуты', + note: elsewhere + ? `ссылка ветки не указывает на материал: ${attempts} попыт(ки) диспатча встали на ${String(elsewhere.headSha).slice(0, 8)}` + : 'dispatch-прогон не появился за 3 минуты', }; } await ops.sleep(pollMs); diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 30d975af..58a2326a 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -743,3 +743,25 @@ test('#517 AC4: документы процесса не требуют файл assert.equal((readme.match(/^\| \[#\d+\]/gm) || []).length, 0, 'таблица-индекс удалена'); assert.doesNotMatch(readme, /Статус ТЗ/); }); + +// #539: `workflow_dispatch` принимает только ref, а не SHA. Конвейер сам +// переписывает ветку ребейзом и тут же просит GitHub разрешить эту же ссылку: +// 12.09 на #536 диспатч встал на ДОпушевый SHA, гейт не нашёл прогона на +// материале и вернул задачу автору, которому чинить было нечего. Шаг ребейза +// обязан дождаться, что ссылка доехала, и спрашивать об этом REST — через него +// же идёт диспатч. +test('конвейер: ребейз не заканчивается, пока ссылка не укажет на новую вершину (#539)', () => { + const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const rebase = workflow.slice( + workflow.indexOf(' - name: Привести ветку к dev\n'), + workflow.indexOf(' - name: Зафиксировать SHA материала ревью\n'), + ); + assert.ok(rebase.length > 0, 'шаг ребейза найден'); + assert.match(rebase, /gh api "repos\/\$\{\{ github\.repository \}\}\/git\/ref\/heads\/\$BRANCH"/, + 'вершина спрашивается у REST, а не у git ls-remote'); + assert.match(rebase, /^\s+GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}$/m, 'токен для REST есть'); + const wait = rebase.indexOf('git api') >= 0 ? -1 : rebase.indexOf('gh api'); + const fetchLocal = rebase.indexOf('git fetch -q origin "+refs/heads/$BRANCH'); + assert.ok(wait > 0 && wait < fetchLocal, 'ожидание стоит после push и до конца шага'); + assert.match(rebase, /ссылка \$BRANCH за минуту не стала указывать/, 'не доехавшая ссылка — отказ, а не молчание'); +}); diff --git a/test/validate-gate.test.mjs b/test/validate-gate.test.mjs index 9f033b20..a937616e 100755 --- a/test/validate-gate.test.mjs +++ b/test/validate-gate.test.mjs @@ -84,8 +84,28 @@ test('#510 AC2: the dispatch that never appears is reported as missing, naming a const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 1000 }); assert.equal(outcome.result, 'missing'); assert.equal(outcome.url, 'https://run/other'); - assert.match(outcome.note, /материал сменился: dispatch-прогон стоит на bbbbbbbb/); - assert.deepEqual(fake.dispatched, ['issue/1']); + assert.match(outcome.note, /ссылка ветки не указывает на материал/); + assert.match(outcome.note, /bbbbbbbb/); + // #539: прежде чем обвинять автора, гейт пробует ещё раз — своя гонка + // закрывается, чужой коммит переживает и вторую попытку. + assert.deepEqual(fake.dispatched, ['issue/1', 'issue/1']); +}); + +// #539: `workflow_dispatch` принимает только ref, а имя ветки резолвится на +// стороне GitHub. Конвейер сам переписывает ветку ребейзом, и 12.09 на #536 +// диспатч встал на ДОпушевый SHA — задача вернулась автору, которому чинить +// было нечего. Вторая попытка обязана попасть на материал. +test('#539: диспатч, промахнувшийся по устаревшей ссылке, повторяется и принимается', async () => { + const landed = run({ databaseId: 7, url: 'https://run/7' }); + const fake = fakeOps({ + // пусто → пусто (промах, повтор) → прогон на материале + snapshots: [[], [], [landed]], + onRef: [run({ headSha: 'b'.repeat(40), url: 'https://run/other' })], + }); + const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, appearMs: 5000, pollMs: 6000 }); + assert.equal(outcome.result, 'green', 'повторный диспатч попал на материал'); + assert.equal(outcome.url, 'https://run/7'); + assert.deepEqual(fake.dispatched, ['issue/1', 'issue/1'], 'ровно две попытки, не бесконечно'); }); test('#510 AC2: a dispatch that never finishes is red after the total window', async () => {