ci: name the commit that actually broke golden

Упавший тяжёлый гейт не пишет маркер переиспользования — и правильно, иначе
починка осталась бы незамеченной. Но следствие в том, что следующий коммит
гонит ту же job на тех же входах, падает так же, и письмо «Run failed»
называет его. 29 августа так был назван 0f7b6f5, документ ревью, который не
может изменить ни одного пикселя: сцену без эталона добавил dbbe94ae.

Теперь падение оставляет второй маркер — с тем же ключом, что у маркера
успеха. Совпадение ключа доказывает равенство входов, поэтому повторное
падение может честно сказать «красная с такого-то SHA, этот коммит её не
ронял», а первое — «причина здесь». Само падение по-прежнему не кэшируется:
job прогоняется всегда, меняется только формулировка в notice и summary.

Первопричина записывается только на первом падении: иначе SHA съехал бы на
свидетеля и сообщение перевернулось бы смыслом.

Issue: #386
User-Visible: no
This commit is contained in:
Matysh
2026-08-30 11:23:35 +03:00
parent 75d11d7c2e
commit fbbea47545
4 changed files with 218 additions and 3 deletions
+81 -2
View File
@@ -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=<path>]\n`);
process.stderr.write(`usage: gate-reuse.mjs --job=<${JOBS.join('|')}> [--note --marker=<path>] [--repo=<path>]\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`);