mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
Review r1 (High): actions/checkout passes `git fetch --no-tags` unless `fetch-tags: true`, even with fetch-depth 0, so releaseTaggedShas() was always empty in CI and a candidate outside the 100-run API window fell back to event.before instead of the last release tag. Preflight and changes now fetch tags; the workflow contract pins the option. The AC2 dev-push case now uses its own input (dev runs only, an older `before`) instead of repeating the main call (review r1, Low). Issue: #703 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
191 lines
10 KiB
JavaScript
191 lines
10 KiB
JavaScript
// #703: граница проверенного материала для гейтов диапазона на `dev` и `main`.
|
||
// Stable-промоушен — пуш в `main` SHA, уже судимого на `dev`. До #703 база
|
||
// бралась по прогонам одной `main` (прошлый stable), и вся бета-линия судилась
|
||
// заново по сегодняшним правилам: run 36468413524 предъявил 55 «новых» записей
|
||
// в смоках, появившихся до #629.
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { execFileSync, spawnSync } from 'node:child_process';
|
||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import {
|
||
RELEASE_TAG, baseSummary, judgedShas, mergeRunPayloads, pickRangeBase, releaseTaggedShas,
|
||
} from '../scripts/classify-base.mjs';
|
||
import { addedLinesByFile } from '../scripts/no-new-any.mjs';
|
||
import { findNewPrivateWriteViolations, isGatedPath } from '../scripts/no-new-private-writes.mjs';
|
||
|
||
const SCRIPT = fileURLToPath(new URL('../scripts/classify-base.mjs', import.meta.url));
|
||
const SMOKE = 'demo/smoke_probe.mjs';
|
||
const run = (sha, conclusion) => ({ head_sha: sha, status: 'completed', conclusion });
|
||
|
||
/**
|
||
* Бета-линия поверх прошлого stable: две записи в приватное состояние смока
|
||
* появились в бетах (до правила), три prerelease-тега, кандидат промоушена.
|
||
*/
|
||
function betaLine(dir) {
|
||
const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim();
|
||
git('init', '-q', '-b', 'dev');
|
||
git('config', 'user.email', 'test@example.com');
|
||
git('config', 'user.name', 'test');
|
||
git('config', 'tag.gpgSign', 'false');
|
||
mkdirSync(join(dir, 'demo'));
|
||
const lines = ['// смок-проба', 'const c = document.querySelector("houseplan-card");'];
|
||
const commit = (message, extra) => {
|
||
if (extra) lines.push(extra);
|
||
writeFileSync(join(dir, SMOKE), `${lines.join('\n')}\n`);
|
||
writeFileSync(join(dir, 'notes.txt'), `${message}\n`);
|
||
git('add', '-A');
|
||
git('commit', '-qm', message);
|
||
return git('rev-parse', 'HEAD');
|
||
};
|
||
const stable = commit('stable v1.0.0');
|
||
git('tag', '-a', 'v1.0.0', '-m', 'v1.0.0');
|
||
const beta1 = commit('beta 1', 'c._layout = {};');
|
||
git('tag', '-a', 'v1.1.0-beta.1', '-m', 'beta 1');
|
||
const beta2 = commit('beta 2', 'c._serverCfg = { spaces: [] };');
|
||
git('tag', '-a', 'v1.1.0-beta.2', '-m', 'beta 2');
|
||
const beta3 = commit('beta 3');
|
||
git('tag', 'v1.1.0-beta.3'); // лёгкий тег тоже считается
|
||
git('tag', 'wip-not-a-release'); // а этот — нет
|
||
const candidate = commit('release: promote v1.1.0');
|
||
return { dir, git, commit, stable, beta1, beta2, beta3, candidate };
|
||
}
|
||
|
||
/** Фикстура живёт ровно столько, сколько тело теста (#646). */
|
||
function withBetaLine(body) {
|
||
const dir = mkdtempSync(join(tmpdir(), 'hp-promotion-'));
|
||
try {
|
||
body(betaLine(dir));
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
/** Прогнать CLI режима range в фикстуре и прочитать выбранную базу. */
|
||
function rangeBase(fx, head, runsByBranch, fallback) {
|
||
const files = Object.entries(runsByBranch).map(([branch, runs]) => {
|
||
const file = join(fx.dir, `runs-${branch}.json`);
|
||
writeFileSync(file, JSON.stringify({ workflow_runs: runs }));
|
||
return `--runs=${file}`;
|
||
});
|
||
const out = join(fx.dir, 'out.txt');
|
||
writeFileSync(out, '');
|
||
const result = spawnSync(process.execPath, [
|
||
SCRIPT, `--head=${head}`, '--mode=range', '--name=range_base', `--fallback=${fallback}`, ...files,
|
||
], { cwd: fx.dir, encoding: 'utf8', env: { ...process.env, GITHUB_OUTPUT: out } });
|
||
assert.equal(result.status, 0, result.stderr);
|
||
const output = readFileSync(out, 'utf8');
|
||
return { base: /range_base=([0-9a-f]*)\n/.exec(output)?.[1], summary: result.stdout };
|
||
}
|
||
|
||
/** Тот же суд, что `no-new-private-writes --base --head`, но над фикстурой. */
|
||
function privateWrites(fx, base, head) {
|
||
const diff = fx.git('diff', '--unified=0', '--no-color', `${base}...${head}`, '--', 'demo');
|
||
const files = [];
|
||
for (const [path, addedLines] of addedLinesByFile(diff)) {
|
||
if (!isGatedPath(path) || !addedLines.size) continue;
|
||
files.push({ path, text: fx.git('show', `${head}:${path}`), addedLines, movedLines: new Set() });
|
||
}
|
||
return findNewPrivateWriteViolations({ files });
|
||
}
|
||
|
||
test('#703 AC1: промоушен проверенного SHA в main — пустой диапазон и ноль нарушений', () => {
|
||
withBetaLine((fx) => {
|
||
// Фикстура не пустая: от прошлого stable гейт находит обе старые записи.
|
||
// Ровно так судил main до #703.
|
||
assert.equal(privateWrites(fx, fx.stable, fx.candidate).length, 2);
|
||
const main = [run(fx.stable, 'success')];
|
||
const dev = [run(fx.beta1, 'success'), run(fx.beta2, 'failure'),
|
||
run(fx.beta3, 'success'), run(fx.candidate, 'success')];
|
||
const { base, summary } = rangeBase(fx, fx.candidate, { dev, main }, fx.stable);
|
||
assert.equal(base, fx.candidate, 'успешный Validate того же SHA на dev — граница');
|
||
assert.match(summary, /#703/);
|
||
assert.equal(privateWrites(fx, base, fx.candidate).length, 0);
|
||
// Тот же SHA, та же граница на dev: один вердикт на одно дерево.
|
||
assert.equal(rangeBase(fx, fx.candidate, { dev, main }, fx.beta3).base, fx.candidate);
|
||
});
|
||
});
|
||
|
||
test('#703 AC1: без прогонов dev в окне API граница — последний тег релиза, а не прошлый stable', () => {
|
||
withBetaLine((fx) => {
|
||
const { base, summary } = rangeBase(fx, fx.candidate, { main: [run(fx.stable, 'success')] }, fx.stable);
|
||
assert.equal(base, fx.beta3, 'лёгкий тег v1.1.0-beta.3 — опубликованный материал');
|
||
assert.match(summary, /опубликованный тег релиза/);
|
||
assert.equal(privateWrites(fx, base, fx.candidate).length, 0);
|
||
});
|
||
});
|
||
|
||
test('#703 AC2: новое нарушение после границы красное — hotfix на main и пуш в dev', () => {
|
||
withBetaLine((fx) => {
|
||
const hotfix = fx.commit('hotfix', "c._tool = 'wall';");
|
||
const main = [run(fx.stable, 'success'), run(fx.candidate, 'success')];
|
||
const dev = [run(fx.candidate, 'success')];
|
||
const onMain = rangeBase(fx, hotfix, { dev, main }, fx.candidate);
|
||
assert.equal(onMain.base, fx.candidate);
|
||
const found = privateWrites(fx, onMain.base, hotfix);
|
||
assert.equal(found.length, 1);
|
||
assert.equal(found[0].field, '_tool');
|
||
// Пуш в dev: другой вход — только прогоны dev и `before` раньше кандидата.
|
||
const onDev = rangeBase(fx, hotfix, { dev }, fx.beta3);
|
||
assert.equal(onDev.base, fx.candidate);
|
||
assert.equal(privateWrites(fx, onDev.base, hotfix).length, 1);
|
||
});
|
||
});
|
||
|
||
test('#703 AC2: SHA с упавшим прогоном судится заново тем же диапазоном, а не пустым', () => {
|
||
withBetaLine((fx) => {
|
||
const bad = fx.commit('новая запись', 'c._mode = "edit";');
|
||
// Прогон этого SHA упал на гейте. Перезапуск не имеет права стать зелёным
|
||
// оттого, что SHA «уже судили».
|
||
const dev = [run(fx.candidate, 'success'), run(bad, 'failure')];
|
||
const { base } = rangeBase(fx, bad, { dev, main: [] }, fx.candidate);
|
||
assert.equal(base, fx.candidate);
|
||
assert.equal(privateWrites(fx, base, bad).length, 1);
|
||
});
|
||
});
|
||
|
||
test('#703: HEAD засчитывает только успех или тег; предки — любой завершённый суд или тег', () => {
|
||
const judged = judgedShas({ workflow_runs: [run('h', 'failure'), run('p', 'failure')] });
|
||
assert.equal(pickRangeBase({
|
||
candidates: ['p', 'q'], green: judged, head: 'h', headGreen: new Set(), fallback: 'before',
|
||
}).base, 'p', 'упавший HEAD не граница; упавший предок — граница (#388)');
|
||
const proven = pickRangeBase({
|
||
candidates: ['p'], green: judged, head: 'h', headGreen: new Set(['h']), fallback: 'before',
|
||
});
|
||
assert.deepEqual([proven.base, proven.reason, proven.proven], ['h', 'head-proven', true]);
|
||
assert.equal(pickRangeBase({
|
||
candidates: ['p'], green: new Set(), head: 'h', tagged: new Set(['h']), fallback: 'before',
|
||
}).base, 'h', 'опубликованный HEAD уже прошёл Validate на точном SHA (§8)');
|
||
const tag = pickRangeBase({
|
||
candidates: ['p', 't', 'q'], green: new Set(['q']), tagged: new Set(['t']), head: 'h',
|
||
});
|
||
assert.deepEqual([tag.base, tag.reason, tag.skipped], ['t', 'release-tag', 1]);
|
||
// Прежние вызовы без новых полей ведут себя как до #703.
|
||
assert.equal(pickRangeBase({ candidates: ['a'], green: new Set(), fallback: 'b' }).base, 'b');
|
||
assert.match(baseSummary(tag, { head: 'hhhhhhhh', mode: 'range' }).join('\n'), /тег релиза/);
|
||
assert.match(baseSummary(proven, { head: 'hhhhhhhh', mode: 'range' }).join('\n'), /диапазон пуст/);
|
||
});
|
||
|
||
test('#703: теги релиза и слияние прогонов двух веток', () => {
|
||
const a = 'a'.repeat(40);
|
||
const b = 'b'.repeat(40);
|
||
const c = 'c'.repeat(40);
|
||
const tags = releaseTaggedShas([
|
||
`v1.2.3 ${a} `, // лёгкий: коммит — второе поле
|
||
`v1.3.0-beta.2 ${'9'.repeat(40)} ${b}`, // аннотированный: третье
|
||
`v2.0.0-rc.1 ${c}`,
|
||
`v1.3.0-beta.0 ${'d'.repeat(40)}`, // beta.0 не бывает
|
||
`wip ${'e'.repeat(40)}`,
|
||
'',
|
||
].join('\n'));
|
||
assert.deepEqual([...tags].sort(), [a, b, c]);
|
||
assert.ok(RELEASE_TAG.test('v1.79.0-beta.1'));
|
||
assert.equal(RELEASE_TAG.test('v1.79.0-beta'), false);
|
||
const merged = mergeRunPayloads([{ workflow_runs: [run('x', 'success')] }, null, 'мусор',
|
||
{ workflow_runs: [run('y', 'failure')] }]);
|
||
assert.deepEqual([...judgedShas(merged)].sort(), ['x', 'y']);
|
||
});
|