From b2470df5a75dac1c6249eedb3763c3e16c9cb1c8 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 30 Aug 2026 00:05:41 +0300 Subject: [PATCH] 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 --- scripts/process-gate.mjs | 69 ++++++++++++++++++++++++++++++++++---- test/process-gate.test.mjs | 54 ++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index 908f1df8..af07c3f3 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -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 }); })() diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index 978b0dfa..2e1bf6f1 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -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 с чужими