mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
`release-zip.yml` выкладывал `houseplan.zip` в ту же секунду, когда релиз становился публичным — до Validate, Full Performance и E2E; `release.yml` параллельно пересобирал `houseplan-card.js`, а E2E требовал публичного ZIP, чтобы вообще начаться. Публикаторов было четыре, порядок — ни одного. Теперь публикатор стабильных один — `release.yml`: закрепить SHA → релиз в черновике (опубликованный руками немедленно возвращается в черновик) → гейты на SHA (трейлер `Release: <tag>`, контракт `--stable`, Validate, Full Performance, E2E на коммите-кандидате через tarball codeload) → одна сборка, `git archive` ZIP из того же дерева, `SHA256SUMS` → загрузка в черновик → публикация → скачать публичное и сверить с паспортом → анонс. Dispatch на публичный тег — ремонт: догружается только недостающее, расходящийся хеш — отказ. Беты кладут тот же паспорт; локальный публикатор больше не ждёт републикаторов — их нет. - `.github/workflows/release-zip.yml` удалён - `scripts/release-assets.mjs` — паспорт ассетов (`sums`/`check`), чистые функции под юнитами - `scripts/e2e-gate.mjs --ref=<sha>` — под тестом кандидат, `--tag` только для выбора `upgrade_from` - `scripts/release-contract.mjs --stable` - мутанты: независимый публикатор, снятая зависимость от гейта, релиз без возврата в черновик, `--clobber` в ремонте, E2E на теге, слепой паспорт Issue: #540 User-Visible: no
181 lines
9.2 KiB
JavaScript
181 lines
9.2 KiB
JavaScript
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 = `<!-- release: ${tag} -->`;
|
|
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;
|
|
}
|
|
}
|