mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Fix stable promotion version-source gate
Allow only mechanically proven version-declaration changes in the three canonical source files while keeping every other release source diff fail-closed. Issue: #379 User-Visible: no
This commit is contained in:
@@ -106,7 +106,10 @@ export const FS = '\x1f';
|
||||
export const RS = '\x1e';
|
||||
export const LOG_FORMAT = `%H${FS}%s${FS}%aI${FS}%b${RS}`;
|
||||
|
||||
export function makeCommit({ sha = '', subject = '', body = '', files = [], authorDate = '' }) {
|
||||
export function makeCommit({
|
||||
sha = '', subject = '', body = '', files = [], authorDate = '',
|
||||
releaseSourceViolations = null,
|
||||
}) {
|
||||
const text = `${subject}\n${body}`;
|
||||
const all = (name) =>
|
||||
[...text.matchAll(new RegExp(`^${name}:\\s*(.+)$`, 'gmi'))].map((m) => m[1].trim());
|
||||
@@ -123,6 +126,9 @@ export function makeCommit({ sha = '', subject = '', body = '', files = [], auth
|
||||
release: one('Release'),
|
||||
baselineReviewed: one('Baseline-Reviewed'),
|
||||
gates: one('Gates'),
|
||||
// null = вызывающий не доказал содержимое diff. Для stable release это
|
||||
// намеренно fail-closed: одного имени разрешённого version source мало.
|
||||
releaseSourceViolations,
|
||||
// Кандидат беты несёт работу и живёт по общим правилам — решение 1.
|
||||
isRelease:
|
||||
(/^Release v\d/.test(subject) && !/-(beta|rc|alpha)\.|candidate/i.test(subject))
|
||||
@@ -130,7 +136,9 @@ export function makeCommit({ sha = '', subject = '', body = '', files = [], auth
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRecords(raw, filesOf = () => []) {
|
||||
export function parseRecords(
|
||||
raw, filesOf = () => [], releaseSourceViolationsOf = () => null,
|
||||
) {
|
||||
if (!raw.trim()) return [];
|
||||
return raw
|
||||
.split(RS)
|
||||
@@ -138,10 +146,42 @@ export function parseRecords(raw, filesOf = () => []) {
|
||||
.filter((r) => r.trim())
|
||||
.map((rec) => {
|
||||
const [sha, subject, authorDate = '', body = ''] = rec.split(FS);
|
||||
return makeCommit({ sha, subject, body, files: filesOf(sha), authorDate });
|
||||
const files = filesOf(sha);
|
||||
return makeCommit({
|
||||
sha, subject, body, files, authorDate,
|
||||
releaseSourceViolations: releaseSourceViolationsOf(sha, files),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const RELEASE_VERSION_DECLARATIONS = new Map([
|
||||
['src/houseplan-card.ts', /^const CARD_VERSION = '[^'\r\n]+';$/gm],
|
||||
['src/houseplan-editor-runtime.ts', /^const CARD_VERSION = '[^'\r\n]+';$/gm],
|
||||
['custom_components/houseplan/const.py', /^VERSION = "[^"\r\n]+"$/gm],
|
||||
]);
|
||||
|
||||
// Stable promotion действительно обязан менять эти три строки: они входят в
|
||||
// шесть канонических version sources (§9.3). Сравнение целого blob до/после с
|
||||
// нормализованной декларацией доказывает, что под видом bump не проехало ни
|
||||
// одного другого изменения продукта. Ровно одно совпадение с обеих сторон —
|
||||
// часть доказательства; неоднозначный или недоступный diff остаётся fail-closed.
|
||||
export function isReleaseVersionOnlyChange(path, before, after) {
|
||||
const pattern = RELEASE_VERSION_DECLARATIONS.get(path);
|
||||
if (!pattern || typeof before !== 'string' || typeof after !== 'string') return false;
|
||||
const normalize = (source) => {
|
||||
let count = 0;
|
||||
pattern.lastIndex = 0;
|
||||
const normalized = source.replace(pattern, () => {
|
||||
count += 1;
|
||||
return '__HOUSEPLAN_RELEASE_VERSION__';
|
||||
});
|
||||
return { count, normalized };
|
||||
};
|
||||
const left = normalize(before);
|
||||
const right = normalize(after);
|
||||
return left.count === 1 && right.count === 1 && left.normalized === right.normalized;
|
||||
}
|
||||
|
||||
// --- проверки по одному коммиту: 1, 4, 5, 6, 9 ---
|
||||
export function evaluateCommit(c) {
|
||||
const out = [];
|
||||
@@ -159,8 +199,11 @@ export function evaluateCommit(c) {
|
||||
if (!c.release) {
|
||||
fail(5, `релизный коммит «${c.subject.slice(0, 50)}» без трейлера «Release: vX.Y.Z»`);
|
||||
}
|
||||
if (sources.length) {
|
||||
fail(6, `релизный коммит содержит продуктовый исходник: ${sources.slice(0, 3).join(', ')}`);
|
||||
const violations = Array.isArray(c.releaseSourceViolations)
|
||||
? c.releaseSourceViolations
|
||||
: sources;
|
||||
if (violations.length) {
|
||||
fail(6, `релизный коммит содержит не-версионное изменение продукта: ${violations.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
if ((c.gates ?? '').toLowerCase() === 'light') {
|
||||
fail(9, '«Gates: light» на релизном коммите запрещён');
|
||||
@@ -610,8 +653,21 @@ function main(argv) {
|
||||
const filesOf = (sha) =>
|
||||
git(['show', '--name-only', '--pretty=format:', sha], repo)
|
||||
.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const blobOf = (revision, path) => {
|
||||
const r = spawnSync('git', ['-C', repo, 'show', `${revision}:${path}`], {
|
||||
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
};
|
||||
const releaseSourceViolationsOf = (sha, files) => files
|
||||
.filter((file) => /^src\//.test(file) || /^custom_components\/houseplan\/.*\.py$/.test(file))
|
||||
.filter((file) => !isReleaseVersionOnlyChange(
|
||||
file, blobOf(`${sha}^`, file), blobOf(sha, file),
|
||||
));
|
||||
const commits = parseRecords(
|
||||
git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, range], repo), filesOf,
|
||||
git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, range], repo),
|
||||
filesOf,
|
||||
releaseSourceViolationsOf,
|
||||
);
|
||||
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim();
|
||||
|
||||
@@ -649,6 +705,7 @@ function main(argv) {
|
||||
const own = parseRecords(
|
||||
git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, 'origin/dev..HEAD'], repo),
|
||||
filesOf,
|
||||
releaseSourceViolationsOf,
|
||||
);
|
||||
return commitsNeedingTargetValidation(own, { targetRef, isCommitOnMain });
|
||||
})()
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
commitsUnderRuleOne,
|
||||
evaluateCommit,
|
||||
isInfrastructureRange,
|
||||
isReleaseVersionOnlyChange,
|
||||
makeCommit,
|
||||
parseRecords,
|
||||
FS,
|
||||
@@ -113,6 +114,35 @@ test('a release commit carrying product source fails rule 6', () => {
|
||||
assert.deepEqual(rules(evaluateCommit(bad)), [6]);
|
||||
});
|
||||
|
||||
test('a release commit allows only proven canonical version declarations', () => {
|
||||
const path = 'src/houseplan-card.ts';
|
||||
const before = "const CARD_VERSION = '1.69.0-beta.5';\nexport const value = 1;\n";
|
||||
const after = "const CARD_VERSION = '1.69.0';\nexport const value = 1;\n";
|
||||
assert.equal(isReleaseVersionOnlyChange(path, before, after), true);
|
||||
assert.equal(isReleaseVersionOnlyChange(
|
||||
path, before, "const CARD_VERSION = '1.69.0';\nexport const value = 2;\n",
|
||||
), false);
|
||||
assert.equal(isReleaseVersionOnlyChange('src/another.ts', before, after), false);
|
||||
|
||||
const stable = makeCommit({
|
||||
sha: 'deadbeefcafe',
|
||||
subject: 'Release v1.69.0',
|
||||
body: 'Release: v1.69.0',
|
||||
files: [path, 'src/houseplan-editor-runtime.ts', 'custom_components/houseplan/const.py'],
|
||||
releaseSourceViolations: [],
|
||||
});
|
||||
assert.deepEqual(rules(evaluateCommit(stable)), []);
|
||||
|
||||
const mixed = makeCommit({
|
||||
sha: 'deadbeefcafe',
|
||||
subject: 'Release v1.69.0',
|
||||
body: 'Release: v1.69.0',
|
||||
files: [path, 'src/houseplan-editor-runtime.ts', 'custom_components/houseplan/const.py'],
|
||||
releaseSourceViolations: ['src/houseplan-card.ts'],
|
||||
});
|
||||
assert.deepEqual(rules(evaluateCommit(mixed)), [6]);
|
||||
});
|
||||
|
||||
test('Gates: light is refused on release and generated commits', () => {
|
||||
assert.deepEqual(
|
||||
rules(evaluateCommit(commit('Release v1.62.0', 'Release: v1.62.0\nGates: light', ['dist/a.js']))),
|
||||
@@ -390,6 +420,28 @@ test('the CLI exits 0 on a clean range and 1 on a broken one', (t) => {
|
||||
assert.equal(broken.status, 1, broken.stdout + broken.stderr);
|
||||
assert.match(broken.stdout, /FAIL п\.1/);
|
||||
|
||||
// Stable promotion меняет канонические строки версии внутри исходников,
|
||||
// но не несёт никакого другого продуктового diff.
|
||||
git('checkout', '-q', 'dev');
|
||||
git('reset', '-q', '--hard', base);
|
||||
write('src/houseplan-card.ts', "const CARD_VERSION = '1.69.0-beta.5';\nexport const a = 1;\n");
|
||||
write('src/houseplan-editor-runtime.ts', "const CARD_VERSION = '1.69.0-beta.5';\nexport const b = 1;\n");
|
||||
write('custom_components/houseplan/const.py', 'VERSION = "1.69.0-beta.5"\nVALUE = 1\n');
|
||||
commitAll('Prerelease tree\n\nIssue: #1\nUser-Visible: no');
|
||||
const prerelease = git('rev-parse', 'HEAD').trim();
|
||||
write('src/houseplan-card.ts', "const CARD_VERSION = '1.69.0';\nexport const a = 1;\n");
|
||||
write('src/houseplan-editor-runtime.ts', "const CARD_VERSION = '1.69.0';\nexport const b = 1;\n");
|
||||
write('custom_components/houseplan/const.py', 'VERSION = "1.69.0"\nVALUE = 1\n');
|
||||
commitAll('Release v1.69.0\n\nRelease: v1.69.0\nUser-Visible: yes');
|
||||
const stable = runGate(`${prerelease}..HEAD`);
|
||||
assert.equal(stable.status, 0, stable.stdout + stable.stderr);
|
||||
|
||||
write('src/houseplan-card.ts', "const CARD_VERSION = '1.69.1';\nexport const a = 2;\n");
|
||||
commitAll('Release v1.69.1\n\nRelease: v1.69.1\nUser-Visible: yes');
|
||||
const polluted = runGate('HEAD^..HEAD');
|
||||
assert.equal(polluted.status, 1, polluted.stdout + polluted.stderr);
|
||||
assert.match(polluted.stdout, /FAIL п\.6/);
|
||||
|
||||
// --report печатает то же, но не краснеет.
|
||||
const report = spawnSync(process.execPath,
|
||||
[gate, '--repo', dir, '--range', `${base}..HEAD`, '--report'], { encoding: 'utf8' });
|
||||
@@ -399,7 +451,7 @@ test('the CLI exits 0 on a clean range and 1 on a broken one', (t) => {
|
||||
[gate, '--repo', dir, '--range', `${base}..HEAD`, '--json'], { encoding: 'utf8' });
|
||||
const parsed = JSON.parse(asJson.stdout);
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.equal(parsed.commits, 2);
|
||||
assert.equal(parsed.commits, 3);
|
||||
|
||||
// Проверка 2 судит только коммиты самой ветки. Диапазон из события CI шире:
|
||||
// после ребейза merge-base уезжает назад и втягивает коммиты dev с чужими
|
||||
|
||||
Reference in New Issue
Block a user