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
+80 -1
View File
@@ -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 });
}
});
+30
View File
@@ -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'/);
});