mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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-развилка должна быть убрана целиком, иначе решений снова два');
|
||||
});
|
||||
Reference in New Issue
Block a user