build: stable release notes follow the owner's aggregation rules (#328)

A stable body aggregates the changelog since the previous STABLE release,
not since the last beta; in-beta-only bugfixes are excluded by the curator
(the draft lists every candidate with its source section); the small-fixes
filler line is legal only when the range carries user-visible work not
itemised in the body — a single-issue hotfix ships without it, and body
bullets must link their issues so the rule stays checkable.
scripts/release-notes.mjs prints the aggregation draft and verifies
docs/RELEASE-NOTES.md (npm run release:notes -- <tag> [--verify]); the
verifier rejects the v1.68.1-style empty filler by execution. STATUS.md
release mechanics updated in the same commit.

Issue: #328
User-Visible: no
This commit is contained in:
Codex
2026-08-27 18:55:41 +03:00
parent 6a3ac52258
commit 1ac91e43fd
4 changed files with 351 additions and 1 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Version | **v1.68.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — owner-approved emergency patch release for space creation and deletion (#324) |
| Current local cycle | v1.68.1 is a promotion-only emergency hotfix over v1.68.0: canonical empty spaces now include the required v8/v9 wall catalogue, and rejected space writes restore server-backed state before another create or delete. No other product behaviour is added in the release commit. The complete local suite plus exact-SHA Validate and Full Performance remain the publication gates. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
| HACS | **In the default catalog since 2026-08-25** (hacs/default#9004 merged). Install = plain HACS search. Post-merge checklist: run the manual zip workflow on the next stable tag after merging to main; forum/4pda announcement |
+1
View File
@@ -32,6 +32,7 @@
"golden:accept": "node demo/golden/accept.mjs",
"release:check": "node scripts/release-prerelease.mjs --check",
"release:prerelease": "node scripts/release-prerelease.mjs",
"release:notes": "node scripts/release-notes.mjs",
"prepare": "node scripts/install-hooks.mjs"
},
"devDependencies": {
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* Release notes for STABLE releases (#328).
*
* Owner rules (2026-08-27):
* 1. A stable release aggregates the changelog since the PREVIOUS STABLE
* release, not since the last beta: every feature/fix described in the
* line's beta changelogs must reach the stable body.
* 2. A bug that was introduced AND fixed inside the beta line (never present
* in any stable release) must not appear in the stable body. That judgment
* needs a human: the draft lists every candidate item with its source
* section so the curator can strike the in-line-only fixes.
* 3. «Мелкие исправления и улучшения» / «Small fixes and improvements» is
* allowed ONLY when such work really exists — user-visible commits in the
* range whose issues the body does not mention explicitly. A single-issue
* hotfix ships without the filler line.
*
* Usage:
* node scripts/release-notes.mjs v1.69.0 # print an aggregation draft
* node scripts/release-notes.mjs v1.69.0 --verify # verify docs/RELEASE-NOTES.md
*/
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
const SMALL_FIXES_RU = 'Мелкие исправления и улучшения';
const SMALL_FIXES_EN = 'Small fixes and improvements';
export function parseVersion(tag) {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(tag).trim());
if (!match) return null;
return {
numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4] ?? null,
};
}
export function compareVersions(a, b) {
const va = parseVersion(a), vb = parseVersion(b);
if (!va || !vb) throw new Error(`unparsable version: ${a} / ${b}`);
for (let index = 0; index < 3; index++) {
if (va.numbers[index] !== vb.numbers[index]) return va.numbers[index] - vb.numbers[index];
}
if (!va.prerelease && !vb.prerelease) return 0;
if (!va.prerelease) return 1; // release > its prereleases
if (!vb.prerelease) return -1;
return va.prerelease.localeCompare(vb.prerelease, 'en', { numeric: true });
}
export const isStable = (tag) => {
const version = parseVersion(tag);
return !!version && version.prerelease === null;
};
/** The latest stable tag strictly below `target`. */
export function previousStableTag(target, tags) {
const below = tags.filter((tag) => isStable(tag) && parseVersion(tag)
&& compareVersions(tag, target) < 0);
if (!below.length) return null;
return below.sort(compareVersions).at(-1);
}
/** Split a CHANGELOG file into ordered sections: {version|null, title, items}. */
export function parseChangelog(text) {
const sections = [];
let current = null;
for (const line of text.split('\n')) {
const heading = /^## (.+)$/.exec(line);
if (heading) {
const title = heading[1].trim();
const versionMatch = /^(v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(title);
current = {
title,
version: versionMatch ? versionMatch[1] : null,
unreleased: /^(Unreleased|Не выпущено)/i.test(title),
items: [],
};
sections.push(current);
continue;
}
if (!current) continue;
if (/^- /.test(line)) current.items.push(line);
else if (/^\s+\S/.test(line) && current.items.length) {
current.items[current.items.length - 1] += `\n${line}`;
}
}
return sections;
}
export const issuesOf = (text) => [...String(text)
.matchAll(/(?:issues|pull)\/(\d+)|#(\d+)/g)]
.map((match) => Number(match[1] ?? match[2]))
.filter(Boolean);
/** Sections newer than prevStable and not newer than target (or unreleased). */
export function sectionsInRange(sections, prevStable, target) {
return sections.filter((section) => {
if (section.unreleased) return true;
if (!section.version) return false;
if (prevStable && compareVersions(section.version, prevStable) <= 0) return false;
return compareVersions(section.version, target) <= 0;
});
}
/** Aggregate items across the range; the newest wording per issue set wins.
* Sections come newest-first in the changelog, so first occurrence wins. */
export function aggregateItems(sections) {
const seen = new Set();
const out = [];
for (const section of sections) {
for (const item of section.items) {
const key = issuesOf(item).sort((a, b) => a - b).join(',') || item.trim();
if (seen.has(key)) continue;
seen.add(key);
out.push({ item, source: section.title });
}
}
return out;
}
/** Issues from `Issue: #N` trailers of user-visible commits in a git range. */
export function visibleIssuesInRange(range, gitRunner = defaultGit) {
const log = gitRunner(['log', '--format=%B%x1e', range]);
const issues = new Set();
for (const message of log.split('\x1e')) {
if (!/^User-Visible:\s*yes\s*$/im.test(message)) continue;
for (const match of message.matchAll(/^Issue:\s*#(\d+)\s*$/gim)) issues.add(Number(match[1]));
}
return issues;
}
const defaultGit = (args) => execFileSync('git', ['-C', ROOT, ...args], {
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
});
export function verifyReleaseNotes({
tag, notes, changelogRu, changelogEn, tags, gitRunner = defaultGit,
}) {
const errors = [];
const warnings = [];
if (!isStable(tag)) errors.push(`тег ${tag} не стабильный — правила #328 применяются к стабильным релизам`);
if (!notes.includes(`<!-- release: ${tag} -->`))
errors.push(`docs/RELEASE-NOTES.md не помечен «<!-- release: ${tag} -->»`);
const prevStable = previousStableTag(tag, tags);
// Verify against the TAG when it already exists; against HEAD before
// publication (the candidate is the branch tip).
const endRef = tags.includes(tag) ? tag : 'HEAD';
const range = prevStable ? `${prevStable}..${endRef}` : endRef;
const visible = visibleIssuesInRange(range, gitRunner);
const ruSections = sectionsInRange(parseChangelog(changelogRu), prevStable, tag);
const enSections = sectionsInRange(parseChangelog(changelogEn), prevStable, tag);
const changelogIssues = new Set();
for (const section of [...ruSections, ...enSections]) {
for (const item of section.items) for (const issue of issuesOf(item)) changelogIssues.add(issue);
}
const mentioned = new Set(issuesOf(notes));
for (const issue of mentioned) {
if (!visible.has(issue) && !changelogIssues.has(issue)) {
errors.push(`#${issue} упомянут в теле, но не встречается ни в user-visible коммитах `
+ `диапазона ${range}, ни в ченджлоге линейки — тело шире релиза`);
}
}
const unmentioned = [...visible].filter((issue) => !mentioned.has(issue));
const hasFillerRu = notes.includes(SMALL_FIXES_RU);
const hasFillerEn = notes.includes(SMALL_FIXES_EN);
if ((hasFillerRu || hasFillerEn) && mentioned.size === 0 && visible.size > 0) {
errors.push('пункты тела не ссылаются на issues (#NN) — законность приписки о мелких '
+ 'улучшениях непроверяема; добавь ссылки в пункты (правило #328)');
}
if ((hasFillerRu || hasFillerEn) && unmentioned.length === 0) {
errors.push('приписка «мелкие исправления и улучшения» присутствует, но каждый '
+ `user-visible issue диапазона ${range} уже упомянут в теле — приписка пустая, убрать`);
}
if (!hasFillerRu && !hasFillerEn && unmentioned.length > 0) {
warnings.push(`в диапазоне ${range} есть user-visible работы, не упомянутые в теле `
+ `(#${unmentioned.join(', #')}) — либо допиши пункты, либо верни приписку`);
}
if (hasFillerRu !== hasFillerEn) {
errors.push('приписка о мелких улучшениях есть только в одном языке');
}
return { errors, warnings, prevStable, range, unmentioned };
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const args = process.argv.slice(2);
const tag = args.find((argument) => !argument.startsWith('--'));
const verify = args.includes('--verify');
if (!tag) {
console.error('usage: node scripts/release-notes.mjs <stable-tag> [--verify]');
process.exit(2);
}
const changelogRu = readFileSync(resolve(ROOT, 'docs/CHANGELOG.ru.md'), 'utf8');
const changelogEn = readFileSync(resolve(ROOT, 'docs/CHANGELOG.md'), 'utf8');
const tags = defaultGit(['tag', '--list', 'v*']).split('\n').filter((line) => parseVersion(line));
if (verify) {
const notes = readFileSync(resolve(ROOT, 'docs/RELEASE-NOTES.md'), 'utf8');
const report = verifyReleaseNotes({ tag, notes, changelogRu, changelogEn, tags });
for (const warning of report.warnings) console.warn(`ПРЕДУПРЕЖДЕНИЕ: ${warning}`);
if (report.errors.length) {
for (const error of report.errors) console.error(`ОШИБКА: ${error}`);
process.exit(1);
}
console.log(`Тело релиза ${tag} проходит правила #328 `
+ `(диапазон ${report.range}, скрытых user-visible issue: ${report.unmentioned.length})`);
process.exit(0);
}
const prevStable = previousStableTag(tag, tags);
console.log(`# Черновик тела ${tag} — агрегат от предыдущего стабильного ${prevStable ?? '(нет)'}\n`);
console.log('# Правь руками: вычеркни багфиксы, чей баг жил ТОЛЬКО внутри бета-линейки');
console.log('# (не встречался ни в одном стабильном релизе) — им в стабильном теле не место.\n');
for (const [label, changelog] of [['RU', changelogRu], ['EN', changelogEn]]) {
console.log(`## Кандидаты (${label})\n`);
const sections = sectionsInRange(parseChangelog(changelog), prevStable, tag);
for (const { item, source } of aggregateItems(sections)) {
console.log(`${item}\n ^ из секции: ${source}\n`);
}
}
const visible = visibleIssuesInRange(prevStable ? `${prevStable}..HEAD` : 'HEAD');
console.log(`# User-visible issues диапазона: ${[...visible].sort((a, b) => a - b)
.map((issue) => `#${issue}`).join(', ') || '(нет)'}`);
console.log('# Приписка о мелких улучшениях законна только если часть из них не попала в тело.');
}
+116
View File
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
aggregateItems, compareVersions, isStable, issuesOf, parseChangelog,
previousStableTag, sectionsInRange, verifyReleaseNotes, visibleIssuesInRange,
} from '../scripts/release-notes.mjs';
// #328: правила тела стабильного релиза, решение владельца 2026-08-27.
test('versions compare with prereleases below their release', () => {
assert.ok(compareVersions('v1.68.0-beta.4', 'v1.68.0') < 0);
assert.ok(compareVersions('v1.68.1', 'v1.68.0') > 0);
assert.ok(compareVersions('v1.68.0-beta.2', 'v1.68.0-beta.10') < 0);
assert.equal(isStable('v1.68.0'), true);
assert.equal(isStable('v1.68.0-rc.1'), false);
assert.equal(previousStableTag('v1.69.0',
['v1.68.0', 'v1.68.1', 'v1.69.0-beta.1', 'v1.67.0']), 'v1.68.1');
assert.equal(previousStableTag('v1.0.0', ['v1.0.0-beta.1']), null);
});
const CHANGELOG = `# Changelog
## Unreleased
- New drawing tool ([#400](https://github.com/x/y/issues/400)).
## v1.69.0-beta.2 — 2026-09-01
- Reworded drawing tool ([#400](https://github.com/x/y/issues/400)).
- Fix a beta-only crash ([#401](https://github.com/x/y/issues/401)).
## v1.69.0-beta.1 — 2026-08-30
- New drawing tool, first wording ([#400](https://github.com/x/y/issues/400)).
## v1.68.1 — 2026-08-27
- Stable hotfix ([#390](https://github.com/x/y/issues/390)).
`;
test('the stable range takes every section after the previous stable and dedupes by issue', () => {
const sections = sectionsInRange(parseChangelog(CHANGELOG), 'v1.68.1', 'v1.69.0');
assert.deepEqual(sections.map((section) => section.title.split(' ')[0]),
['Unreleased', 'v1.69.0-beta.2', 'v1.69.0-beta.1']);
const items = aggregateItems(sections);
// #400 появляется трижды — выигрывает самая новая формулировка (Unreleased),
// #401 входит один раз; хотфикс #390 предыдущего стабильного не входит.
assert.deepEqual(items.map(({ item }) => issuesOf(item)[0]), [400, 401]);
assert.match(items[0].item, /New drawing tool/);
});
const gitStub = (visibleIssues) => (args) => {
assert.equal(args[0], 'log');
return visibleIssues.map((issue) =>
`feat: something\n\nIssue: #${issue}\nUser-Visible: yes\n\x1e`).join('')
+ 'chore: infra\n\nIssue: #999\nUser-Visible: no\n\x1e';
};
test('visible issues come only from User-Visible commits', () => {
const issues = visibleIssuesInRange('a..b', gitStub([400, 401]));
assert.deepEqual([...issues].sort(), [400, 401]);
});
const notesFor = (body) => `<!-- release: v1.69.0 -->\n\n## Основное\n\n${body}\n`;
test('an empty filler line is an error; a justified one passes (#328 rule 3)', () => {
const base = {
tag: 'v1.69.0',
changelogRu: CHANGELOG, changelogEn: CHANGELOG,
tags: ['v1.68.0', 'v1.68.1'],
};
// Хотфикс одной задачи: всё упомянуто, приписка запрещена.
const empty = verifyReleaseNotes({
...base,
notes: notesFor('- Одна задача ([#400](https://github.com/x/y/issues/400)).\n- Мелкие исправления и улучшения.\n- Small fixes and improvements.'),
gitRunner: gitStub([400]),
});
assert.equal(empty.errors.length, 1, JSON.stringify(empty.errors));
assert.match(empty.errors[0], /приписка.*пустая/);
// Есть непопавшая в тело user-visible работа — приписка законна.
const justified = verifyReleaseNotes({
...base,
notes: notesFor('- Одна задача ([#400](https://github.com/x/y/issues/400)).\n- Мелкие исправления и улучшения.\n\n## Highlights\n\n- One item ([#400](https://github.com/x/y/issues/400)).\n- Small fixes and improvements.'),
gitRunner: gitStub([400, 401]),
});
assert.deepEqual(justified.errors, []);
// Пункты без ссылок при наличии приписки — непроверяемо, ошибка.
const unlinkable = verifyReleaseNotes({
...base,
notes: notesFor('- Просто текст без ссылок.\n- Мелкие исправления и улучшения.\n- Small fixes and improvements.'),
gitRunner: gitStub([400]),
});
assert.equal(unlinkable.errors.length, 1, JSON.stringify(unlinkable.errors));
assert.match(unlinkable.errors[0], /не ссылаются на issues/);
// Issue вне диапазона и ченджлога — тело шире релиза.
const foreign = verifyReleaseNotes({
...base,
notes: notesFor('- Чужая задача ([#777](https://github.com/x/y/issues/777)).'),
gitRunner: gitStub([400]),
});
assert.equal(foreign.errors.length, 1, JSON.stringify(foreign.errors));
assert.match(foreign.errors[0], /#777/);
// Скрытая работа без приписки — предупреждение, не ошибка.
const missing = verifyReleaseNotes({
...base,
notes: notesFor('- Одна задача ([#400](https://github.com/x/y/issues/400)).'),
gitRunner: gitStub([400, 401]),
});
assert.deepEqual(missing.errors, []);
assert.equal(missing.warnings.length, 1);
});