Files
houseplan-card/scripts/performance-baseline.mjs
Codex 978035c491 ci: база стабильного кандидата — предыдущий стабильный тег (#587)
Относительная половина «Полных бенчмарков» сравнивала кандидата с прошлой
вершиной `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
2026-09-16 21:34:39 +03:00

210 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// База сравнения «Полных бенчмарков» (#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`);
}
}