diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 078ff718..dc46c40f 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -57,6 +57,10 @@ jobs: EVENT_NAME: ${{ github.event_name }} PUSH_BEFORE_SHA: ${{ github.event.before }} MANUAL_BASE: ${{ inputs.comparison_ref }} + # #587: кандидат стабильного релиза судится о предыдущий стабильный + # тег, а не о прошлую вершину main. Признак кандидата — трейлер + # `Release:` в сообщении head-коммита, поэтому оно едет в скрипт. + HEAD_MESSAGE: ${{ github.event.head_commit.message }} run: | set -euo pipefail if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then @@ -64,71 +68,9 @@ jobs: else git fetch --force --tags --prune origin fi - - if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$MANUAL_BASE" ]; then - sha="$(git rev-parse "${MANUAL_BASE}^{commit}" 2>/dev/null || true)" - source="manual comparison ref $MANUAL_BASE" - elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then - sha="$PUSH_BEFORE_SHA" - source="push before" - else - sha="$(git rev-parse HEAD^ 2>/dev/null || true)" - source="candidate parent" - fi - requested_sha="$sha" - - usable=true - reason="" - if [ -z "$sha" ] || ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then - usable=false - reason="commit is not present after fetching all remote refs" - elif [ "$source" = "push before" ] && ! git merge-base --is-ancestor "$sha" HEAD; then - usable=false - reason="commit is no longer an ancestor of the pushed revision" - fi - - if [ "$usable" != true ]; then - parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)" - if [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then - sha="$parent_sha" - source="candidate parent (unusable requested-base fallback)" - echo "::warning::Comparison SHA ${requested_sha:-none} is unusable ($reason); using candidate parent $sha." - usable=true - fi - fi - - if [ "$usable" != true ]; then - fallback_tag="" - fallback_sha="" - head_sha="$(git rev-parse HEAD)" - while IFS= read -r tag; do - case "$tag" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) continue ;; - esac - tag_sha="$(git rev-list -n 1 "$tag")" - if [ "$tag_sha" != "$head_sha" ]; then - fallback_tag="$tag" - fallback_sha="$tag_sha" - break - fi - done < <(git tag --merged HEAD --sort=-version:refname) - if [ -z "$fallback_sha" ]; then - echo "::error::No usable comparison commit or previous release tag is reachable from HEAD." - exit 1 - fi - sha="$fallback_sha" - source="release tag $fallback_tag" - echo "::warning::Using $fallback_tag ($sha) as the comparison base." - fi - - if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then - echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^." - sha="$(git rev-parse HEAD^)" - source="candidate parent (HP-PERF-01 compatibility)" - fi - echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY" + # Решение целиком в скрипте: его отрицательные случаи проверяются + # фикстурами, а shell-развилку прогнать тестом нельзя (#556, #587). + node scripts/performance-baseline.mjs - name: Check out base SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/demo/performance/README.md b/demo/performance/README.md index d7d22370..c318a6e4 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -109,6 +109,21 @@ applies two limits: 1. a relative regression allowance against the base-SHA report; 2. an absolute safety ceiling from `budgets.json`. +Which base SHA the relative half compares against is decided by +`scripts/performance-baseline.mjs`, not by the workflow's shell (#587). A +**stable** release candidate — its head commit carries a `Release:` trailer +without a prerelease suffix — is compared against the **previous stable tag**; +everything else keeps the old base (`push before`, the candidate parent, or the +`comparison_ref` a manual dispatch names). The reason is the order the stable +gate imposes: the candidate must be on `main` before this workflow can run on +its exact SHA, so the *next* commit of the same line would otherwise take the +first one as its base and compare the line with itself — a red gate would be +cleared by any follow-up commit. The negative cases of that decision (no tag, +a tag sitting on HEAD, a base that is no longer an ancestor) cannot be exercised +from YAML, so they live in `test/performance-baseline.test.mjs` with an injected +git; the mutant `stable-candidate-compares-against-itself` puts the old +behaviour back and must be caught. + The tighter limit wins. The absolute values are catastrophic safety ceilings, not normal-performance targets; the base-relative comparison catches smaller regressions. Small fast operations receive an absolute noise diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 781ef9e9..22809fbd 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -9917,6 +9917,19 @@ const MUTANT_DEFINITIONS = [ replace: " if (settings?.fill_mode === 'never-a-fill-mode') return spaceFill;", }], }, + { + id: 'stable-candidate-compares-against-itself', + guard: 'node --test --test-name-pattern="AC2" test/performance-baseline.test.mjs', + because: '#587: кандидат стабильного релиза обязан сравниваться с предыдущим стабильным ' + + 'тегом. Если база снова берётся с прошлой вершины main, второй коммит линейки судится ' + + 'о первый — и красный перф-гейт снимается любым следующим коммитом, молча и зелёным', + patches: [{ + file: 'scripts/performance-baseline.mjs', + // рантайм-ложь, а не мёртвая ветка (#568): модуль обязан импортироваться + find: ' if (candidateTag && isStableTag(candidateTag)) {', + replace: " if (candidateTag && isStableTag(candidateTag) && candidateTag === 'v0.0.0-mutant') {", + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/scripts/performance-baseline.mjs b/scripts/performance-baseline.mjs new file mode 100644 index 00000000..57ede601 --- /dev/null +++ b/scripts/performance-baseline.mjs @@ -0,0 +1,209 @@ +// База сравнения «Полных бенчмарков» (#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`); + } +} diff --git a/test/performance-baseline.test.mjs b/test/performance-baseline.test.mjs new file mode 100644 index 00000000..be7dcb49 --- /dev/null +++ b/test/performance-baseline.test.mjs @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { + isStableTag, previousStableTag, releaseTrailerTag, requestedComparison, resolveComparisonBase, +} from '../scripts/performance-baseline.mjs'; + +const HEAD = 'a'.repeat(40); +const PARENT = 'b'.repeat(40); +const STABLE_175 = 'c'.repeat(40); +const STABLE_174 = 'd'.repeat(40); + +/** + * Дерево фикстуры описывается данными, а не подменой бинарника: ровно тот + * приём, ради которого решение вынесено из shell (#587). + */ +const fakeGit = ({ + head = HEAD, parent = PARENT, tags = [ + { tag: 'v1.75.0', sha: STABLE_175 }, { tag: 'v1.74.0', sha: STABLE_174 }, + ], + present = [HEAD, PARENT, STABLE_175, STABLE_174], ancestors = [PARENT, STABLE_175, STABLE_174], + withoutBundleFreshness = [], +} = {}) => ({ + revParse: (ref) => (ref === 'HEAD' ? head : ref === 'HEAD^' ? parent : ref), + exists: (sha) => present.includes(sha), + isAncestor: (sha) => ancestors.includes(sha), + hasPath: (sha) => !withoutBundleFreshness.includes(sha), + stableTags: () => tags, +}); + +test('трейлер релиза читается, пре-релиз отличается от стабильного', () => { + assert.equal(releaseTrailerTag('x\n\nRelease: v1.76.0\n'), 'v1.76.0'); + assert.equal(releaseTrailerTag('x\n\nRelease: v1.76.0-beta.5\n'), 'v1.76.0-beta.5'); + assert.equal(releaseTrailerTag('нет трейлера'), null); + assert.equal(releaseTrailerTag('Release: v1.76.0 и ещё текст'), null); + assert.ok(isStableTag('v1.76.0')); + assert.ok(isStableTag('1.76.0')); + assert.ok(!isStableTag('v1.76.0-beta.5')); + assert.ok(!isStableTag('')); +}); + +test('AC1: кандидат стабильного релиза судится о предыдущий стабильный тег', () => { + const resolved = resolveComparisonBase({ + eventName: 'push', + pushBefore: PARENT, + headMessage: 'Release v1.76.0\n\nIssue: #582\nUser-Visible: yes\nRelease: v1.76.0\n', + git: fakeGit(), + }); + assert.equal(resolved.sha, STABLE_175); + assert.match(resolved.source, /previous stable tag v1\.75\.0/); + assert.match(resolved.source, /stable candidate v1\.76\.0/); + assert.deepEqual(resolved.warnings, []); +}); + +test('AC2: второй коммит линейки не сравнивается сам с собой', () => { + // Ровно ситуация выпуска v1.76.0: `main` уже несёт первый коммит линейки, + // поэтому `push before` — он же. Если бы база бралась оттуда, гейт был бы + // зелёным независимо от кода. + const firstOfTheLine = PARENT; + const resolved = resolveComparisonBase({ + eventName: 'push', + pushBefore: firstOfTheLine, + headMessage: 'perf: принять шаг изометрии (#585)\n\nIssue: #585\nUser-Visible: no\nRelease: v1.76.0\n', + git: fakeGit(), + }); + assert.notEqual(resolved.sha, firstOfTheLine, 'база не должна быть предыдущим коммитом той же линейки'); + assert.equal(resolved.sha, STABLE_175); +}); + +test('AC3: бета-кандидат и обычный push сохраняют прежнюю базу', () => { + const beta = resolveComparisonBase({ + eventName: 'push', + pushBefore: PARENT, + headMessage: 'Prepare v1.76.0-beta.5\n\nRelease: v1.76.0-beta.5\n', + git: fakeGit(), + }); + assert.equal(beta.sha, PARENT); + assert.equal(beta.source, 'push before'); + + const ordinary = resolveComparisonBase({ + eventName: 'push', pushBefore: PARENT, headMessage: 'fix: что-то (#1)\n', git: fakeGit(), + }); + assert.equal(ordinary.sha, PARENT); + assert.equal(ordinary.source, 'push before'); + + const dispatch = resolveComparisonBase({ + eventName: 'workflow_dispatch', headMessage: 'fix: что-то (#1)\n', git: fakeGit(), + }); + assert.equal(dispatch.sha, PARENT); + assert.equal(dispatch.source, 'candidate parent'); +}); + +test('ручная база сильнее всего, в том числе стабильного кандидата', () => { + const resolved = resolveComparisonBase({ + eventName: 'workflow_dispatch', + manualBase: STABLE_174, + headMessage: 'Release v1.76.0\n\nRelease: v1.76.0\n', + git: fakeGit(), + }); + assert.equal(resolved.sha, STABLE_174); + assert.match(resolved.source, /manual comparison ref/); +}); + +test('свой тег и тег на голове предыдущим не считаются', () => { + const onHead = [{ tag: 'v1.76.0', sha: HEAD }, { tag: 'v1.75.0', sha: STABLE_175 }]; + assert.equal(previousStableTag(onHead, { headSha: HEAD })?.tag, 'v1.75.0'); + assert.equal(previousStableTag(onHead, { headSha: 'z'.repeat(40), candidateTag: 'v1.76.0' })?.tag, 'v1.75.0'); + assert.equal(previousStableTag(onHead, { headSha: 'z'.repeat(40), candidateTag: '1.76.0' })?.tag, 'v1.75.0', + 'тег кандидата узнаётся и без ведущей v'); + assert.equal(previousStableTag([{ tag: 'v1.76.0-beta.5', sha: STABLE_175 }], {}), null, + 'бета стабильным тегом не является'); + assert.equal(previousStableTag([], {}), null); +}); + +test('стабильный кандидат без предыдущего тега честно откатывается к родителю', () => { + const resolved = resolveComparisonBase({ + eventName: 'push', + pushBefore: PARENT, + headMessage: 'Release v1.0.0\n\nRelease: v1.0.0\n', + git: fakeGit({ tags: [] }), + }); + assert.equal(resolved.sha, PARENT); + assert.match(resolved.source, /no previous stable tag/); + assert.equal(resolved.warnings.length, 1); + assert.match(resolved.warnings[0], /No stable tag before v1\.0\.0/); +}); + +test('непригодная база уводит в сторону большего сравнения, а не меньшего', () => { + const rebased = resolveComparisonBase({ + eventName: 'push', pushBefore: 'e'.repeat(40), headMessage: 'fix: x\n', + git: fakeGit({ present: [HEAD, PARENT, 'e'.repeat(40)], ancestors: [PARENT] }), + }); + assert.equal(rebased.sha, PARENT, 'не предок — берём родителя'); + assert.match(rebased.warnings[0], /no longer an ancestor/); + + const missing = resolveComparisonBase({ + eventName: 'push', pushBefore: 'e'.repeat(40), headMessage: 'fix: x\n', + git: fakeGit({ present: [HEAD, PARENT] }), + }); + assert.equal(missing.sha, PARENT); + assert.match(missing.warnings[0], /not present after fetching/); + + assert.throws(() => resolveComparisonBase({ + eventName: 'push', pushBefore: 'e'.repeat(40), headMessage: 'fix: x\n', + git: fakeGit({ parent: null, present: [HEAD], tags: [] }), + }), /No usable comparison commit/); +}); + +test('база старше HP-PERF-01 заменяется родителем', () => { + const resolved = resolveComparisonBase({ + eventName: 'push', + pushBefore: PARENT, + headMessage: 'Release v1.76.0\n\nRelease: v1.76.0\n', + git: fakeGit({ withoutBundleFreshness: [STABLE_175] }), + }); + assert.equal(resolved.sha, PARENT); + assert.match(resolved.source, /HP-PERF-01/); +}); + +test('workflow берёт базу из скрипта, а не из собственного shell (#587)', () => { + const workflow = readFileSync(new URL('../.github/workflows/performance.yml', import.meta.url), 'utf8'); + assert.match(workflow, /scripts\/performance-baseline\.mjs/); + assert.match(workflow, /HEAD_MESSAGE:/, 'сообщение head-коммита обязано доехать до скрипта'); + assert.ok(!/source="candidate parent"/.test(workflow), + 'прежняя shell-развилка должна быть убрана целиком, иначе решений снова два'); +});