Files
houseplan-card/test/promotion-range.test.mjs
T
Claudeandclaude[bot] 3d99f261ff fix(ci): fetch release tags where the range base reads them (#703)
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
2026-09-30 18:27:54 +00:00

191 lines
10 KiB
JavaScript
Raw 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.
// #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']);
});