import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export function versionFromTag(tag) { if (typeof tag !== 'string' || !tag.startsWith('v')) throw new Error(`Release tag must start with v: ${String(tag)}`); const version = tag.slice(1); const match = SEMVER.exec(version); if (!match) throw new Error(`Release tag is not valid SemVer: ${tag}`); if (match[4]?.split('.').some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) throw new Error(`Numeric prerelease identifiers must not contain leading zeroes: ${tag}`); return { version, prerelease: !!match[4] }; } export function parseVersionSources({ packageJson, packageLock, manifest, constSource, cardSource, editorRuntimeSource, }) { const pkg = JSON.parse(packageJson); const lock = JSON.parse(packageLock); const integration = JSON.parse(manifest); const constMatch = /^VERSION\s*=\s*["']([^"']+)["']/m.exec(constSource); const cardMatch = /\bCARD_VERSION\s*=\s*["']([^"']+)["']/m.exec(cardSource); const editorRuntimeMatch = /\bCARD_VERSION\s*=\s*["']([^"']+)["']/m.exec(editorRuntimeSource); if (!constMatch) throw new Error('VERSION is missing from custom_components/houseplan/const.py'); if (!cardMatch) throw new Error('CARD_VERSION is missing from src/houseplan-card.ts'); if (!editorRuntimeMatch) throw new Error('CARD_VERSION is missing from src/houseplan-editor-runtime.ts'); return { 'package.json': pkg.version, 'package-lock.json': lock.version, 'package-lock.json packages[""]': lock.packages?.['']?.version, 'custom_components/houseplan/manifest.json': integration.version, 'custom_components/houseplan/const.py': constMatch[1], 'src/houseplan-card.ts': cardMatch[1], 'src/houseplan-editor-runtime.ts': editorRuntimeMatch[1], }; } export function validateVersionSources(tag, sources, { requirePrerelease = true, requireStable = false } = {}) { const parsed = versionFromTag(tag); if (requirePrerelease && !parsed.prerelease) throw new Error(`Prerelease publication requires a prerelease SemVer tag: ${tag}`); // #540: стабильный путь (release.yml) — зеркальное требование: тег без // пре-релизного суффикса. Режима «любой тег» нет: публикатор всегда знает, // что выпускает. if (requireStable && parsed.prerelease) throw new Error(`Stable publication requires a stable SemVer tag, got prerelease ${tag}`); const mismatches = Object.entries(sources) .filter(([, value]) => value !== parsed.version) .map(([name, value]) => `${name}=${JSON.stringify(value)}`); if (mismatches.length) { throw new Error( `Version ${parsed.version} is not synchronized: ${mismatches.join(', ')}`, ); } return parsed; } export function changelogContainsVersion(changelog, tag) { const heading = new RegExp(`^##\\s+${escapeRegExp(tag)}\\s+[—-]\\s+(\\d{4})-(\\d{2})-(\\d{2})\\s*$`); const lines = changelog.split(/\r?\n/); let fenced = false; for (let index = 0; index < lines.length; index++) { if (/^\s*```/.test(lines[index])) { fenced = !fenced; continue; } if (fenced) continue; const match = heading.exec(lines[index]); if (!match) continue; const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); const date = new Date(Date.UTC(year, month - 1, day)); if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return false; let bodyFence = false; for (let body = index + 1; body < lines.length; body++) { if (/^\s*```/.test(lines[body])) { bodyFence = !bodyFence; continue; } if (!bodyFence && /^##\s+/.test(lines[body])) break; if (!bodyFence && /^-\s+\S/.test(lines[body])) return true; } return false; } return false; } export function validateReleaseNotes(notes, { tag, repo }) { const marker = ``; if (notes.split(/\r?\n/, 1)[0] !== marker) throw new Error(`Release notes must start with the exact ${marker} marker`); if (/^##\s+(Русский|English)\s*$/m.test(notes)) throw new Error('Legacy release-note headings are forbidden; use the canonical bilingual template'); const mainIndex = notes.indexOf('## Основное'); const highlightsIndex = notes.indexOf('## Highlights'); if (mainIndex < 0 || highlightsIndex < 0 || highlightsIndex <= mainIndex) throw new Error('Release notes must contain canonical ## Основное then ## Highlights sections'); if (notes.slice(marker.length, mainIndex).trim()) throw new Error('Release notes cannot contain text or bullets before ## Основное'); const main = notes.slice(mainIndex, highlightsIndex); const highlights = notes.slice(highlightsIndex); const mainBullets = [...main.matchAll(/^-\s+(.+)$/gm)].map((match) => match[1].trim()); const highlightBullets = [...highlights.matchAll(/^-\s+(.+)$/gm)].map((match) => match[1].trim()); if (!mainBullets.length || !highlightBullets.length) throw new Error('Both release-note language sections must contain at least one bullet'); if (mainBullets.length !== highlightBullets.length || mainBullets.length > 4) throw new Error('Release notes must contain equivalent RU/EN lists with at most four bullets each'); if (mainBullets.at(-1) !== 'Мелкие исправления и улучшения.') throw new Error('Russian release notes must end with the grouped small-fixes bullet'); if (highlightBullets.at(-1) !== 'Small fixes and improvements.') throw new Error('English release notes must end with the grouped small-fixes bullet'); const ruLink = `https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.ru.md`; const enLink = `https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.md`; if (!notes.includes(ruLink) || !notes.includes(enLink)) throw new Error(`Release notes must link both changelogs at the immutable ${tag} tag`); return notes.trim() + '\n'; } export function readReleaseContract(root = process.cwd()) { const read = (name) => readFileSync(resolve(root, name), 'utf8'); return { sources: parseVersionSources({ packageJson: read('package.json'), packageLock: read('package-lock.json'), manifest: read('custom_components/houseplan/manifest.json'), constSource: read('custom_components/houseplan/const.py'), cardSource: read('src/houseplan-card.ts'), editorRuntimeSource: read('src/houseplan-editor-runtime.ts'), }), changelogRu: read('docs/CHANGELOG.ru.md'), changelogEn: read('docs/CHANGELOG.md'), notes: read('docs/RELEASE-NOTES.md'), }; } export function assertReleaseContract({ root = process.cwd(), tag, repo = 'Matysh/houseplan-card', requirePrerelease = true, requireStable = false, } = {}) { const contract = readReleaseContract(root); const parsed = validateVersionSources(tag, contract.sources, { requirePrerelease, requireStable }); if (!changelogContainsVersion(contract.changelogRu, tag)) throw new Error(`docs/CHANGELOG.ru.md has no dated ${tag} section`); if (!changelogContainsVersion(contract.changelogEn, tag)) throw new Error(`docs/CHANGELOG.md has no dated ${tag} section`); validateReleaseNotes(contract.notes, { tag, repo }); return { ...parsed, tag, repo, sources: contract.sources }; } const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); if (invokedDirectly) { try { const args = process.argv.slice(2); const positionals = args.filter((arg) => !arg.startsWith('--')); const repoArgs = args.filter((arg) => arg.startsWith('--repo=')); // #540: `--stable` — контракт стабильного релиза (release.yml). Тот же // набор проверок; отличие одно — тег обязан быть БЕЗ пре-релизного суффикса, // как без флага он обязан быть с ним. Третьего режима «любой тег» нет. const stable = args.includes('--stable'); const unknown = args.filter((arg) => arg.startsWith('--') && !arg.startsWith('--repo=') && arg !== '--stable'); if (positionals.length !== 1) throw new Error('Exactly one release tag is required'); if (repoArgs.length > 1 || unknown.length) throw new Error(`Unknown or duplicate release-contract arguments: ${[...repoArgs.slice(1), ...unknown].join(', ')}`); const tag = positionals[0]; const repo = repoArgs[0]?.slice('--repo='.length) || process.env.GITHUB_REPOSITORY || 'Matysh/houseplan-card'; const result = assertReleaseContract({ tag, repo, requirePrerelease: !stable, requireStable: stable }); console.log(JSON.stringify({ ok: true, tag: result.tag, version: result.version, prerelease: result.prerelease, sources: result.sources, }, null, 2)); } catch (error) { console.error(`release contract failed: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; } }