mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Относительная половина «Полных бенчмарков» сравнивала кандидата с прошлой вершиной `main`. Для стабильного релиза это давало круг, в котором гейт не может покраснеть дважды: прогон идёт только на push в `main`, кандидат обязан там оказаться, и следующий коммит той же линейки берёт базой первый — то есть линейку саму. На выпуске v1.76.0 это видно построчно: прогон 35097102695 на `c3d64789` честно показал resizePreview 603 → 981 и panZoom 91 → 205 в скрытой изометрии, а прогон на `9683a590` был зелёным и был бы зелёным без всякой правки бюджетов. Теперь база выбирается по намерению коммита: head несёт трейлер `Release:` без пре-релизного суффикса — сравниваем с предыдущим стабильным тегом. Бета, обычный push и ручной `comparison_ref` не меняются. Решение вынесено из shell в `scripts/performance-baseline.mjs` по тому же доводу, что и разбор вердикта ревью (#556): отрицательные случаи — тега нет, тег стоит на самой голове, база перестала быть предком, база старше HP-PERF-01 — в YAML не прогнать ни одним тестом. Обращения к git инжектируются, фикстуры описывают дерево. Отказы по-прежнему уводят в сторону БОЛЬШЕГО сравнения: непригодная база → родитель → последний достижимый релизный тег. Проверено исполнением на этом репозитории: стабильный кандидат v1.76.0 → `2c6410bb` (v1.75.0); бета v1.76.0-beta.5 и обычный push → `push before`; dispatch с `comparison_ref=v1.74.0` → `e63460f0`. Свидетели: `test/performance-baseline.test.mjs` (10 проверок, включая AC2 — второй коммит линейки не сравнивается сам с собой) и мутант `stable-candidate-compares-against-itself`, который возвращает прежнее поведение и обязан краснеть; проверено подменой руками — AC2 падает, оригинал проходит. AC4: других релизных гейтов, судящих о родителя, нет. `validate.yml` берёт `github.event.before` только для ДИАПАЗОНА файлов, и там база уже заменена доказанно зелёным предком (#387/#388), а не сырым родителем. npm test 2731/2730/0 fail, typecheck чистый, check-docs зелёный (кроме известного отпечатка скриншотов, #586). Issue: #587 User-Visible: no
210 lines
10 KiB
JavaScript
210 lines
10 KiB
JavaScript
// База сравнения «Полных бенчмарков» (#587).
|
||
//
|
||
// Гейт стабильного релиза требует зелёный прогон `performance.yml` на ТОЧНОМ
|
||
// SHA кандидата, а запускается этот workflow только на push в `main`. Из двух
|
||
// условий вместе следует порядок, в котором относительное сравнение теряет
|
||
// смысл:
|
||
//
|
||
// 1. кандидат уезжает в `main` → прогон сравнивает линейку с прошлой вершиной
|
||
// `main`, то есть с прошлым стабильным состоянием, и честно краснеет;
|
||
// 2. красный гейт чинят следующим коммитом — и он уезжает в ту же `main`;
|
||
// 3. базой этого второго прогона становится ПЕРВЫЙ коммит линейки, то есть
|
||
// она сама. Сравнение «сам с собой» зелёное всегда.
|
||
//
|
||
// Так вышло на выпуске v1.76.0 16.09.2026: прогон 35097102695 показал на
|
||
// скрытой изометрии resizePreview 603 → 981 мс и panZoom 91 → 205 мс, а
|
||
// следующий прогон — на коммите с принятыми бюджетами — был бы зелёным и без
|
||
// правки бюджетов: его базой стал предыдущий кандидат.
|
||
//
|
||
// Лечится выбором базы, а не порогами: кандидат СТАБИЛЬНОГО релиза (head-коммит
|
||
// несёт трейлер `Release:` без пре-релизного суффикса) сравнивается с
|
||
// предыдущим стабильным тегом. Тогда каждый коммит линейки судится об одну и ту
|
||
// же точку — ту, от которой пользователь и почувствует разницу.
|
||
//
|
||
// Почему решение живёт здесь, а не в shell шага. Отрицательные случаи этой
|
||
// логики — «тега нет», «база не предок», «тег стоит на самой голове» — в YAML
|
||
// нельзя прогнать ни одним тестом: шаг исполняется только внутри прогона. Тот
|
||
// же довод уже перенёс в `scripts/` разбор вердикта ревью (#556) и
|
||
// классификацию изменений (#473). Обращения к git инжектируются, поэтому
|
||
// фикстуры описывают дерево, а не подменяют бинарник.
|
||
|
||
import { appendFileSync } from 'node:fs';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { isMainModule } from './spawn-portable.mjs';
|
||
|
||
/** Трейлер `Release: vX.Y.Z[-pre]` в сообщении коммита; иначе `null`. */
|
||
export function releaseTrailerTag(message) {
|
||
const match = /^Release:\s*(v?\d+\.\d+\.\d+\S*)\s*$/m.exec(String(message || ''));
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
/** Стабильный тег — без пре-релизного суффикса: `v1.76.0`, но не `v1.76.0-beta.5`. */
|
||
export function isStableTag(tag) {
|
||
return /^v?\d+\.\d+\.\d+$/.test(String(tag || ''));
|
||
}
|
||
|
||
/**
|
||
* Чего просит событие. Разрешение в SHA — отдельным шагом: запрос описывает
|
||
* НАМЕРЕНИЕ, и именно его проверяют фикстуры.
|
||
*/
|
||
export function requestedComparison({ eventName, manualBase, pushBefore, headMessage } = {}) {
|
||
if (eventName === 'workflow_dispatch' && manualBase) {
|
||
return { kind: 'manual', ref: manualBase, source: `manual comparison ref ${manualBase}` };
|
||
}
|
||
const candidateTag = releaseTrailerTag(headMessage);
|
||
if (candidateTag && isStableTag(candidateTag)) {
|
||
// #587: не «прошлый коммит», а прошлый стабильный релиз. Иначе второй
|
||
// коммит линейки сравнивается с первым и покраснеть не может.
|
||
return {
|
||
kind: 'previous-stable',
|
||
candidateTag,
|
||
source: `previous stable tag (stable candidate ${candidateTag})`,
|
||
};
|
||
}
|
||
if (eventName === 'push' && pushBefore && !/^0+$/.test(String(pushBefore))) {
|
||
return { kind: 'push-before', ref: pushBefore, source: 'push before' };
|
||
}
|
||
return { kind: 'parent', ref: 'HEAD^', source: 'candidate parent' };
|
||
}
|
||
|
||
/**
|
||
* Предыдущий стабильный тег: самый новый `vX.Y.Z`, слитый в HEAD, который
|
||
* стоит не на самой голове и не является тегом самого кандидата.
|
||
*
|
||
* Тег кандидата исключается отдельно от проверки «не голова»: при ремонте уже
|
||
* выпущенного релиза тег на голове уже стоит, и без этого условия база уехала
|
||
* бы на него же.
|
||
*/
|
||
export function previousStableTag(tags, { headSha, candidateTag } = {}) {
|
||
const wanted = String(candidateTag || '').replace(/^v/, '');
|
||
for (const entry of tags || []) {
|
||
if (!entry || !isStableTag(entry.tag) || !entry.sha) continue;
|
||
if (wanted && String(entry.tag).replace(/^v/, '') === wanted) continue;
|
||
if (headSha && entry.sha === headSha) continue;
|
||
return entry;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Полное разрешение базы. `git` — набор обращений к дереву; чистая логика
|
||
* остаётся выше, а здесь описан порядок отказов.
|
||
*
|
||
* Отказы ведут в сторону БОЛЬШЕГО объёма сравнения, а не меньшего: недоступная
|
||
* база превращается в родителя, отсутствующий родитель — в последний
|
||
* достижимый релизный тег. Молча пропустить сравнение нельзя.
|
||
*/
|
||
export function resolveComparisonBase({
|
||
eventName, manualBase, pushBefore, headMessage, git,
|
||
}) {
|
||
const request = requestedComparison({ eventName, manualBase, pushBefore, headMessage });
|
||
const headSha = git.revParse('HEAD');
|
||
const warnings = [];
|
||
let sha = null;
|
||
let source = request.source;
|
||
|
||
if (request.kind === 'previous-stable') {
|
||
const previous = previousStableTag(git.stableTags(), {
|
||
headSha, candidateTag: request.candidateTag,
|
||
});
|
||
if (previous) {
|
||
sha = previous.sha;
|
||
source = `previous stable tag ${previous.tag} (stable candidate ${request.candidateTag})`;
|
||
} else {
|
||
warnings.push(`No stable tag before ${request.candidateTag} is reachable from HEAD; `
|
||
+ 'using the candidate parent.');
|
||
sha = git.revParse('HEAD^');
|
||
source = 'candidate parent (no previous stable tag)';
|
||
}
|
||
} else {
|
||
sha = request.kind === 'parent' ? git.revParse('HEAD^') : git.revParse(request.ref);
|
||
}
|
||
|
||
const requested = sha;
|
||
let unusable = '';
|
||
if (!sha || !git.exists(sha)) {
|
||
unusable = 'commit is not present after fetching all remote refs';
|
||
} else if (request.kind === 'push-before' && !git.isAncestor(sha, 'HEAD')) {
|
||
unusable = 'commit is no longer an ancestor of the pushed revision';
|
||
}
|
||
|
||
if (unusable) {
|
||
const parent = git.revParse('HEAD^');
|
||
if (parent && parent !== headSha) {
|
||
warnings.push(`Comparison SHA ${requested || 'none'} is unusable (${unusable}); `
|
||
+ `using candidate parent ${parent}.`);
|
||
sha = parent;
|
||
source = 'candidate parent (unusable requested-base fallback)';
|
||
unusable = '';
|
||
}
|
||
}
|
||
|
||
if (unusable) {
|
||
const fallback = previousStableTag(git.stableTags(), { headSha });
|
||
if (!fallback) {
|
||
throw new Error('No usable comparison commit or previous release tag is reachable from HEAD.');
|
||
}
|
||
warnings.push(`Using ${fallback.tag} (${fallback.sha}) as the comparison base.`);
|
||
sha = fallback.sha;
|
||
source = `release tag ${fallback.tag}`;
|
||
}
|
||
|
||
// HP-PERF-01: до появления demo/bundle-freshness.mjs деревья несравнимы.
|
||
if (!git.hasPath(sha, 'demo/bundle-freshness.mjs')) {
|
||
warnings.push(`Comparison ${sha} predates HP-PERF-01; using candidate parent HEAD^.`);
|
||
sha = git.revParse('HEAD^');
|
||
source = 'candidate parent (HP-PERF-01 compatibility)';
|
||
}
|
||
|
||
return { sha, source, warnings };
|
||
}
|
||
|
||
/** Обращения к настоящему git; вынесены, чтобы фикстуры описывали дерево. */
|
||
export function gitAccessors(cwd = process.cwd()) {
|
||
const text = (args) => {
|
||
try {
|
||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||
} catch {
|
||
return '';
|
||
}
|
||
};
|
||
const ok = (args) => {
|
||
try {
|
||
execFileSync('git', args, { cwd, stdio: 'ignore' });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
return {
|
||
revParse: (ref) => text(['rev-parse', `${ref}^{commit}`]) || null,
|
||
exists: (sha) => ok(['cat-file', '-e', `${sha}^{commit}`]),
|
||
isAncestor: (sha, ref) => ok(['merge-base', '--is-ancestor', sha, ref]),
|
||
hasPath: (sha, path) => ok(['cat-file', '-e', `${sha}:${path}`]),
|
||
stableTags: () => text(['tag', '--merged', 'HEAD', '--sort=-version:refname'])
|
||
.split('\n')
|
||
.map((tag) => tag.trim())
|
||
.filter((tag) => isStableTag(tag))
|
||
.map((tag) => ({ tag, sha: text(['rev-list', '-n', '1', tag]) }))
|
||
.filter((entry) => !!entry.sha),
|
||
};
|
||
}
|
||
|
||
if (isMainModule(import.meta.url)) {
|
||
const { sha, source, warnings } = resolveComparisonBase({
|
||
eventName: process.env.EVENT_NAME,
|
||
manualBase: process.env.MANUAL_BASE,
|
||
pushBefore: process.env.PUSH_BEFORE_SHA,
|
||
headMessage: process.env.HEAD_MESSAGE,
|
||
git: gitAccessors(process.cwd()),
|
||
});
|
||
for (const warning of warnings) process.stdout.write(`::warning::${warning}\n`);
|
||
process.stdout.write(`Comparison base: ${sha} (${source})\n`);
|
||
if (process.env.GITHUB_OUTPUT) {
|
||
appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\nsource=${source}\n`);
|
||
}
|
||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Comparison base: ${sha} (${source})\n`);
|
||
}
|
||
}
|