diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index fdad477f..e5a3cbf0 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -537,6 +537,14 @@ jobs: path: dist - name: Разложить бандл по копиям run: node scripts/bundle-sync.mjs + # Маркер предыдущего падения с ТЕМ ЖЕ ключом (#386). Восстанавливается до + # прогона, потому что после падения шаги кэша уже не помогут отличить + # виновника от свидетеля. + - name: Прежнее падение на тех же входах + uses: actions/cache/restore@v6 + with: + path: .fail-marker + key: fail-golden-${{ needs.reuse.outputs.golden_key }} - name: Capture or verify golden matrix id: golden run: | @@ -553,6 +561,25 @@ jobs: with: name: golden-images path: artifacts/golden + # Красная golden обязана сказать, ЧЕЙ это долг (#386). Без этого письмо + # «Run failed» называет коммит, который пушнули следующим: 29 августа так + # был назван документ ревью `0f7b6f5`, а сцену без эталона добавил + # `dbbe94ae`. Вердикт job не меняется — меняется только формулировка. + - name: Кто уронил golden + id: fail_note + if: failure() + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node scripts/gate-reuse.mjs --job=golden --note --marker=.fail-marker + - name: Запомнить первопричину + if: failure() && steps.fail_note.outputs.first == 'true' + # Гонка двух прогонов даёт «Cache already exists»; для маркера падения + # это тем более не отказ гейта — job и так красная по существу. + continue-on-error: true + uses: actions/cache/save@v6 + with: + path: .fail-marker + key: fail-golden-${{ needs.reuse.outputs.golden_key }} # Маркер пишется последним шагом: он существует только если всё выше # прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job. - name: Записать маркер успеха diff --git a/scripts/gate-reuse.mjs b/scripts/gate-reuse.mjs index 0618a328..a7ff8cba 100644 --- a/scripts/gate-reuse.mjs +++ b/scripts/gate-reuse.mjs @@ -21,7 +21,7 @@ // Переиспользование физически не может ослабить релизный гейт. import { createHash } from 'node:crypto'; -import { appendFileSync, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { relative, resolve } from 'node:path'; import { sourceFingerprint } from './source-fingerprint.mjs'; @@ -101,13 +101,92 @@ export function reuseKey(root, job) { return hash.digest('hex'); } +/** + * Маркер падения (issue #386). Маркер успеха отвечает на вопрос «прогонять ли + * снова», этот — на другой: «с какого коммита эта job красная». + * + * Разница видна в почте. Упавший прогон маркера успеха не пишет, поэтому + * следующий коммит гонит job заново и падает так же — и письмо «Run failed» + * называет того, кто пушнул следующим. 29 августа так был назван `0f7b6f5`, + * документ ревью, который не мог изменить ни одного пикселя: сцену без эталона + * добавил `dbbe94ae` четырьмя коммитами раньше. + * + * Ключ здесь тот же, что у маркера успеха, а значит совпадение ключа доказывает + * равенство входов. Само падение при этом не кэшируется: job прогоняется всегда, + * иначе починка осталась бы незамеченной. Меняется только формулировка. + */ +export function parseFailureMarker(text) { + if (typeof text !== 'string' || !text.trim()) return null; + const field = (name) => text.match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1]?.trim() || ''; + const sha = field('SHA'); + return { sha, runUrl: field('прогон') }; +} + +const short = (sha) => (typeof sha === 'string' && /^[0-9a-f]{7,}$/i.test(sha) ? sha.slice(0, 8) : ''); + +/** + * Формулировка для упавшей job. `prior` — разобранный маркер предыдущего + * падения с тем же ключом либо null. + */ +export function inheritedFailureNote({ job, sha, runUrl, prior }) { + const here = short(sha); + if (!prior) { + return { + first: true, + notice: `${job} упала впервые на этих входах — причина в этом коммите${here ? ` (${here})` : ''}`, + summary: [ + `### ${job}: падение на новых входах`, + `Предыдущего падения с тем же ключом переиспользования не было — изменения этого коммита${here ? ` (\`${here}\`)` : ''} и есть причина.`, + ], + }; + } + const first = short(prior.sha); + const since = first ? `с \`${first}\`` : 'с более раннего прогона (SHA в маркере не записан)'; + const link = prior.runUrl ? ` Первый такой прогон: ${prior.runUrl}` : ''; + return { + first: false, + notice: `${job} красная на тех же входах ${first ? `с ${first}` : '(SHA первого падения не записан)'}` + + ` — этот коммит её не ронял`, + summary: [ + `### ${job}: унаследованное падение`, + `Входы этой job побайтово те же, что в первом падении ${since}: этот коммит её не ронял.`, + 'Красноту снимает либо приёмка эталонов, либо правка того, что job проверяет —' + + ' и то и другое меняет ключ переиспользования.' + link, + ], + }; +} + function main(argv) { const job = (argv.find((a) => a.startsWith('--job=')) || '').slice('--job='.length); const root = (argv.find((a) => a.startsWith('--repo=')) || '').slice('--repo='.length) || process.cwd(); if (!job) { - process.stderr.write(`usage: gate-reuse.mjs --job=<${JOBS.join('|')}> [--repo=]\n`); + process.stderr.write(`usage: gate-reuse.mjs --job=<${JOBS.join('|')}> [--note --marker=] [--repo=]\n`); process.exit(2); } + // Режим объяснения падения (#386): ключ уже посчитан job `reuse`, считать его + // здесь заново нельзя — дерево то же, но лишний проход по эталонам стоит + // секунд, а расхождение с ключом кэша было бы незаметным и вредным. + if (argv.includes('--note')) { + const marker = (argv.find((a) => a.startsWith('--marker=')) || '').slice('--marker='.length) + || '.fail-marker'; + const prior = existsSync(marker) ? parseFailureMarker(readFileSync(marker, 'utf8')) : null; + const sha = process.env.GITHUB_SHA || ''; + const runUrl = process.env.RUN_URL || ''; + const note = inheritedFailureNote({ job, sha, runUrl, prior }); + process.stdout.write(`::notice::${note.notice} (#386)\n`); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${note.summary.join('\n\n')}\n`); + } + // Первое падение оставляет маркер следующим прогонам; унаследованное не + // трогает файл, чтобы SHA первопричины не съехал на свидетеля. + if (note.first) { + writeFileSync(marker, `${job} упала\nSHA: ${sha}\nпрогон: ${runUrl}\n`); + } + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `first=${note.first}\n`); + } + return; + } const key = reuseKey(root, job); process.stdout.write(`${key}\n`); if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `key=${key}\n`); diff --git a/test/gate-reuse.test.mjs b/test/gate-reuse.test.mjs index 957ef83b..ccd033a5 100644 --- a/test/gate-reuse.test.mjs +++ b/test/gate-reuse.test.mjs @@ -6,7 +6,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { HARNESS, JOBS, harnessFiles, reuseKey } from '../scripts/gate-reuse.mjs'; +import { + HARNESS, JOBS, harnessFiles, inheritedFailureNote, parseFailureMarker, reuseKey, +} from '../scripts/gate-reuse.mjs'; /** * Дерево, минимально достаточное для sourceFingerprint плюс оснастка каждой @@ -192,3 +194,80 @@ test('HARNESS keeps scripts/** out of the keys on purpose', () => { rmSync(dir, { recursive: true, force: true }); } }); + +// --- маркер падения (#386) ------------------------------------------------- + +const GATE_REUSE = fileURLToPath(new URL('../scripts/gate-reuse.mjs', import.meta.url)); + +test('первое падение на этих входах называет виновником текущий коммит (#386)', () => { + const note = inheritedFailureNote({ job: 'golden', sha: 'dbbe94aeff00', runUrl: 'https://run/1', prior: null }); + assert.equal(note.first, true); + assert.match(note.notice, /впервые/); + assert.match(note.notice, /dbbe94ae/); + assert.match(note.summary.join('\n'), /этого коммита/); +}); + +test('унаследованное падение снимает вину с текущего коммита (#386)', () => { + const note = inheritedFailureNote({ + job: 'golden', + sha: '0f7b6f5aaaaa', + runUrl: 'https://run/2', + prior: { sha: 'dbbe94aeff00', runUrl: 'https://run/1' }, + }); + assert.equal(note.first, false); + // Оба SHA обязаны быть различимы: письмо называет второй, а причина в первом. + assert.match(note.notice, /dbbe94ae/); + assert.equal(note.notice.includes('0f7b6f5'), false, + 'свидетель не должен фигурировать как причина'); + assert.match(note.summary.join('\n'), /не ронял/); + assert.match(note.summary.join('\n'), /https:\/\/run\/1/); +}); + +test('маркер без SHA не выдумывает коммит (#386)', () => { + const note = inheritedFailureNote({ job: 'smoke', sha: 'abc1234567', runUrl: '', prior: { sha: '', runUrl: '' } }); + assert.equal(note.first, false); + assert.match(note.summary.join('\n'), /не записан/); + assert.equal(/`[0-9a-f]{8}`/.test(note.summary.join('\n')), false); +}); + +test('разбор маркера переживает пустой и мусорный вход (#386)', () => { + assert.equal(parseFailureMarker(''), null); + assert.equal(parseFailureMarker(undefined), null); + assert.deepEqual(parseFailureMarker('golden упала\nSHA: abc1234\nпрогон: https://run/9\n'), + { sha: 'abc1234', runUrl: 'https://run/9' }); + assert.deepEqual(parseFailureMarker('мусор\n'), { sha: '', runUrl: '' }); +}); + +test('CLI пишет маркер на первом падении и не трогает его на втором (#386)', () => { + const dir = mkdtempSync(join(tmpdir(), 'hp-failnote-')); + const marker = join(dir, '.fail-marker'); + const summary = join(dir, 'summary.md'); + const run = (sha) => spawnSync(process.execPath, [ + GATE_REUSE, '--job=golden', '--note', `--marker=${marker}`, + ], { + encoding: 'utf8', + env: { + ...process.env, + GITHUB_SHA: sha, + RUN_URL: `https://run/${sha}`, + GITHUB_STEP_SUMMARY: summary, + GITHUB_OUTPUT: join(dir, 'out.txt'), + }, + }); + try { + const first = run('dbbe94aeff00'); + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /::notice::/); + assert.match(readFileSync(marker, 'utf8'), /SHA: dbbe94aeff00/); + + const second = run('0f7b6f5aaaaa'); + assert.equal(second.status, 0, second.stderr); + // Главное свойство: свидетель не переписывает первопричину. + assert.match(readFileSync(marker, 'utf8'), /SHA: dbbe94aeff00/); + assert.match(second.stdout, /не ронял/); + assert.match(readFileSync(join(dir, 'out.txt'), 'utf8'), /first=false/); + assert.match(readFileSync(summary, 'utf8'), /унаследованное падение/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 5bdac5d0..c575e066 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -111,3 +111,33 @@ test('гейт «новый код не добавляет any» вызывае // merge-base не считается и диапазон выродится в «всё». assert.match(frontend, /fetch-depth: 0, filter: 'blob:none'/); }); + +test('упавшая golden называет первопричину, а не свидетеля (#386)', () => { + const workflow = read('validate.yml'); + const golden = workflow.slice( + workflow.indexOf('\n golden:\n'), workflow.indexOf('\n performance_smoke:\n'), + ); + const KEY = 'fail-golden-${{ needs.reuse.outputs.golden_key }}'; + // Ключ маркера падения обязан совпадать с ключом переиспользования: только + // это доказывает, что входы у двух падений действительно одни и те же. + assert.equal(golden.split(KEY).length - 1, 2, 'ключ маркера падения нужен и на restore, и на save'); + assert.equal(golden.includes('reuse-golden-${{ needs.reuse.outputs.golden_key }}'), true, + 'маркер успеха остаётся на своём ключе'); + + // Восстановление обязано стоять ДО прогона: после падения различить виновника + // и свидетеля уже нечем. + const restore = golden.indexOf('cache/restore@v6'); + const verify = golden.indexOf('npm run golden:verify'); + assert.ok(restore > 0 && verify > restore, 'маркер падения восстанавливается до прогона'); + + // Объяснение печатается только при падении и не подменяет вердикт. + const note = golden.slice(golden.indexOf('id: fail_note')); + assert.match(note, /^\s+if: failure\(\)$/m, 'шаг объяснения только при падении'); + assert.match(note, /gate-reuse\.mjs --job=golden --note/); + assert.equal(note.includes('continue-on-error: true\n run: node scripts/gate-reuse'), false, + 'объяснение не имеет права молча проглатывать свою ошибку до вердикта'); + + // Запись первопричины — только на первом падении: иначе SHA съедет на + // свидетеля и смысл сообщения перевернётся. + assert.match(golden, /if: failure\(\) && steps\.fail_note\.outputs\.first == 'true'/); +});