mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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: Записать маркер успеха
|
||||
|
||||
+81
-2
@@ -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`);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user