Files
houseplan-card/scripts/release-prerelease.mjs
2026-09-13 10:35:43 +03:00

636 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { inflateRawSync } from 'node:zlib';
import {
closeSync, mkdtempSync, openSync, readFileSync, rmSync,
unlinkSync, writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { assertReleaseContract } from './release-contract.mjs';
import { classifyValidateProofs } from './release-gate.mjs';
import { assertBundleManifest } from './bundle-tree.mjs';
import { SUMS_FILE, compareSums, formatSums, parseSums, sumsOfDirectory } from './release-assets.mjs';
import {
MEMBERSHIP_FILE, buildReleaseMembership, readCandidateHistory,
verifyReleaseMembershipAgainstGit,
} from './release-membership.mjs';
const SUBPROCESS_MAX_BUFFER = 64 * 1024 * 1024;
class ReleaseAssetContentError extends Error {}
export function parseIssueList(value = '') {
if (!value.trim()) return [];
const issues = value.split(',').map((part) => part.trim()).filter(Boolean);
if (issues.some((part) => !/^[1-9]\d*$/.test(part)))
throw new Error(`--issues must be a comma-separated list of positive issue numbers: ${value}`);
return [...new Set(issues.map(Number))];
}
export function parsePrereleaseArgs(args) {
const values = new Map();
const switches = new Set();
const positionals = [];
const valueNames = new Set(['repo', 'branch', 'issues']);
const switchNames = new Set(['check', 'yes']);
for (const arg of args) {
if (!arg.startsWith('--')) { positionals.push(arg); continue; }
const match = /^--([^=]+)(?:=(.*))?$/.exec(arg);
const name = match?.[1] || '';
const value = match?.[2];
if (switchNames.has(name) && value === undefined) {
if (switches.has(name)) throw new Error(`Duplicate option: --${name}`);
switches.add(name);
} else if (valueNames.has(name) && value !== undefined && value !== '') {
if (values.has(name)) throw new Error(`Duplicate option: --${name}`);
values.set(name, value);
} else {
throw new Error(`Unknown or malformed option: ${arg}`);
}
}
if (positionals.length !== 1)
throw new Error(`Exactly one prerelease tag is required, got ${positionals.length}`);
return {
tag: positionals[0],
repo: values.get('repo') || 'Matysh/houseplan-card',
branch: values.get('branch') || 'dev',
issueOption: values.get('issues') || '',
checkOnly: switches.has('check'),
confirmed: switches.has('yes'),
};
}
/** GitHub's prerelease discovery order is lexicographic inside a beta line:
* beta.10 can be returned behind beta.9 and HACS will keep offering beta.9.
* Stop before any tag or release is created; after beta.9 the line continues
* as rc.1 (or on a new patch/minor version). */
export function assertHacsDiscoverableTag(tag) {
const beta = /^v\d+\.\d+\.\d+-beta\.([1-9]\d*)$/.exec(tag);
if (beta && Number(beta[1]) >= 10) {
throw new Error(
`${tag} is not HACS-discoverable after beta.9; use rc.1 or a new version line`,
);
}
return tag;
}
export function verifyReleaseProjection(release, { tag }) {
if (!release || release.tagName !== tag) throw new Error(`GitHub release ${tag} is missing`);
if (release.isDraft) throw new Error(`GitHub release ${tag} is still a draft`);
if (!release.isPrerelease) throw new Error(`GitHub release ${tag} is not marked as a prerelease`);
const assets = new Map((release.assets || []).map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip', MEMBERSHIP_FILE, SUMS_FILE]) {
const asset = assets.get(name);
if (!asset || !(Number(asset.size) > 0)) throw new Error(`Release asset ${name} is missing or empty`);
}
return release;
}
/**
* Read selected root entries from an ordinary ZIP archive without relying on
* platform-specific `tar`/`unzip` executables. GitHub runners, Git Bash, WSL
* and Windows consequently validate the exact same bytes. ZIP64 and exotic
* compression methods are rejected deliberately: release archives are small
* and are produced by either `git archive` or Info-ZIP.
*/
const inspectZip = (zipPath, requiredNames) => {
const archive = readFileSync(zipPath);
if (!archive.length) throw new Error('houseplan.zip is empty');
const required = new Set(requiredNames);
const found = new Map();
const names = [];
const eocdSignature = 0x06054b50;
const centralSignature = 0x02014b50;
const localSignature = 0x04034b50;
const minimumEocd = 22;
const searchStart = Math.max(0, archive.length - minimumEocd - 0xffff);
let eocd = -1;
for (let offset = archive.length - minimumEocd; offset >= searchStart; offset--) {
if (archive.readUInt32LE(offset) === eocdSignature
&& offset + minimumEocd + archive.readUInt16LE(offset + 20) === archive.length) {
eocd = offset;
break;
}
}
if (eocd < 0) throw new Error('houseplan.zip has no end-of-central-directory record');
const disk = archive.readUInt16LE(eocd + 4);
const centralDisk = archive.readUInt16LE(eocd + 6);
const entriesOnDisk = archive.readUInt16LE(eocd + 8);
const entryCount = archive.readUInt16LE(eocd + 10);
const centralSize = archive.readUInt32LE(eocd + 12);
const centralOffset = archive.readUInt32LE(eocd + 16);
if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount)
throw new Error('houseplan.zip must be a single-disk archive');
if (entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff)
throw new Error('houseplan.zip unexpectedly requires ZIP64');
if (centralOffset + centralSize > eocd || centralOffset > archive.length)
throw new Error('houseplan.zip central directory is outside the archive');
let cursor = centralOffset;
for (let index = 0; index < entryCount; index++) {
if (cursor + 46 > archive.length || archive.readUInt32LE(cursor) !== centralSignature)
throw new Error('houseplan.zip has a malformed central directory');
const flags = archive.readUInt16LE(cursor + 8);
const method = archive.readUInt16LE(cursor + 10);
const compressedSize = archive.readUInt32LE(cursor + 20);
const uncompressedSize = archive.readUInt32LE(cursor + 24);
const nameLength = archive.readUInt16LE(cursor + 28);
const extraLength = archive.readUInt16LE(cursor + 30);
const commentLength = archive.readUInt16LE(cursor + 32);
const localOffset = archive.readUInt32LE(cursor + 42);
const next = cursor + 46 + nameLength + extraLength + commentLength;
if (next > archive.length) throw new Error('houseplan.zip has a truncated central entry');
const name = archive.subarray(cursor + 46, cursor + 46 + nameLength)
.toString('utf8').replace(/^\.\//, '');
names.push(name);
cursor = next;
if (!required.has(name)) continue;
if (found.has(name)) throw new Error(`houseplan.zip contains duplicate ${name}`);
if ((flags & 0x1) !== 0) throw new Error(`houseplan.zip entry ${name} is encrypted`);
if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff
|| localOffset === 0xffffffff) throw new Error(`houseplan.zip entry ${name} requires ZIP64`);
if (uncompressedSize > 50 * 1024 * 1024)
throw new Error(`houseplan.zip entry ${name} is unexpectedly large`);
if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== localSignature)
throw new Error(`houseplan.zip entry ${name} has no valid local header`);
const localNameLength = archive.readUInt16LE(localOffset + 26);
const localExtraLength = archive.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLength + localExtraLength;
const dataEnd = dataStart + compressedSize;
if (dataEnd > archive.length) throw new Error(`houseplan.zip entry ${name} is truncated`);
const compressed = archive.subarray(dataStart, dataEnd);
let contents;
if (method === 0) contents = Buffer.from(compressed);
else if (method === 8) contents = inflateRawSync(compressed, { maxOutputLength: 50 * 1024 * 1024 });
else throw new Error(`houseplan.zip entry ${name} uses unsupported compression method ${method}`);
if (contents.length !== uncompressedSize)
throw new Error(`houseplan.zip entry ${name} has an invalid uncompressed size`);
found.set(name, contents);
}
if (cursor !== centralOffset + centralSize)
throw new Error('houseplan.zip central directory size is inconsistent');
const missing = [...required].filter((name) => !found.has(name));
if (missing.length) throw new Error(`houseplan.zip is missing ${missing.join(', ')}`);
return { found, names };
};
export function readZipEntries(zipPath, requiredNames) {
return inspectZip(zipPath, requiredNames).found;
}
export function listZipEntries(zipPath) {
return inspectZip(zipPath, []).names;
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
try {
const {
tag, repo, branch, issueOption, checkOnly, confirmed,
} = parsePrereleaseArgs(process.argv.slice(2));
let issues = [];
const root = process.cwd();
const run = (command, commandArgs, { allowFailure = false, inherit = false, cwd = root } = {}) => {
const result = spawnSync(command, commandArgs, {
cwd,
encoding: 'utf8',
maxBuffer: SUBPROCESS_MAX_BUFFER,
stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
});
if (result.error) throw result.error;
if (result.status !== 0 && !allowFailure) {
const detail = `${result.stderr || result.stdout || ''}`.trim();
throw new Error(`${command} ${commandArgs.join(' ')} failed${detail ? `: ${detail}` : ''}`);
}
return {
ok: result.status === 0,
status: result.status,
stdout: `${result.stdout || ''}`.trim(),
stderr: `${result.stderr || ''}`.trim(),
};
};
const runBytes = (command, commandArgs, { cwd = root } = {}) => {
const result = spawnSync(command, commandArgs, {
cwd,
maxBuffer: SUBPROCESS_MAX_BUFFER,
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.error) throw result.error;
if (result.status !== 0) {
const detail = Buffer.concat([result.stderr || Buffer.alloc(0), result.stdout || Buffer.alloc(0)])
.toString('utf8').trim();
throw new Error(`${command} ${commandArgs.join(' ')} failed${detail ? `: ${detail}` : ''}`);
}
return result.stdout;
};
const ghJson = (commandArgs, options) => JSON.parse(run('gh', commandArgs, options).stdout);
const owner = repo.split('/')[0];
const remoteTag = () => {
const result = run('git', [
'ls-remote', '--tags', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`,
]);
if (!result.stdout) return { exists: false, commit: null };
const lines = result.stdout.split(/\r?\n/).map((line) => line.split(/\s+/));
const direct = lines.find(([, ref]) => ref === `refs/tags/${tag}`)?.[0];
const peeled = lines.find(([, ref]) => ref === `refs/tags/${tag}^{}`)?.[0];
if (!peeled) throw new Error(`Remote tag ${tag} exists but is not annotated (${direct})`);
return { exists: true, commit: peeled };
};
const releaseView = () => {
const result = run('gh', [
'release', 'view', tag, '--repo', repo,
'--json', 'url,isPrerelease,isDraft,tagName,assets',
], { allowFailure: true });
return result.ok ? JSON.parse(result.stdout) : null;
};
const sha256Bytes = (contents) => createHash('sha256').update(contents).digest('hex');
const sha256Path = (name) => sha256Bytes(readFileSync(name));
const committedFile = (sha, name) => runBytes('git', ['show', `${sha}:${name}`]);
const assertBundleSnapshots = (sha) => {
// Копия стенда (`demo/srv/assets`) больше не коммитится (#255): её собирает
// `npm run bundle:sync` перед браузерными прогонами. В релиз входят ровно
// две копии — артефакт сборки и та, что ставит HACS.
const distManifestBytes = committedFile(sha, 'dist/houseplan-assets.json');
const frontendManifestBytes = committedFile(
sha, 'custom_components/houseplan/frontend/houseplan-assets.json',
);
if (!distManifestBytes.equals(frontendManifestBytes)) {
throw new Error('Committed bundle manifests differ');
}
const manifest = assertBundleManifest(
JSON.parse(distManifestBytes.toString('utf8')),
'committed bundle manifest',
);
const listed = new Set(manifest.files.map((file) => file.path));
for (const tree of ['dist', 'custom_components/houseplan/frontend']) {
const rootNames = run('git', ['ls-tree', '-r', '--name-only', `${sha}:${tree}`]).stdout
.split(/\r?\n/).filter(Boolean);
const orphan = rootNames.find((name) => (
/^houseplan-.*\.js$/.test(name) || /^houseplan-assets\/.*\.js$/.test(name)
) && !listed.has(name));
if (orphan) throw new Error(`Committed bundle has orphan asset: ${tree}/${orphan}`);
}
for (const file of manifest.files) {
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|| file.path.includes('..') || file.path.includes('\\')) {
throw new Error(`Committed bundle asset path is invalid: ${String(file?.path)}`);
}
const dist = committedFile(sha, `dist/${file.path}`);
const frontend = committedFile(sha, `custom_components/houseplan/frontend/${file.path}`);
if (!dist.equals(frontend)) throw new Error(`Committed bundle asset differs: ${file.path}`);
const actual = sha256Bytes(dist);
if (actual !== file.sha256) {
throw new Error(`Committed bundle asset hash mismatch: ${file.path}`);
}
}
const entry = manifest.files.find((file) => file.path === manifest.entry);
return { manifest, entrySha256: entry.sha256 };
};
const materializeCommittedBundle = (sha, expectedSha256, artifactsDir) => {
const contents = committedFile(sha, 'dist/houseplan-card.js');
const actual = sha256Bytes(contents);
if (actual !== expectedSha256)
throw new Error(`Committed bundle hash ${actual} != preflight ${expectedSha256}`);
const bundlePath = resolve(artifactsDir, 'houseplan-card.js');
writeFileSync(bundlePath, contents);
return bundlePath;
};
const verifyZipContents = (zipPath, version, bundleSnapshot) => {
const header = readZipEntries(zipPath, [
'manifest.json', 'frontend/houseplan-assets.json',
]);
const frontendManifest = JSON.parse(header.get('frontend/houseplan-assets.json').toString('utf8'));
if (JSON.stringify(frontendManifest) !== JSON.stringify(bundleSnapshot.manifest)) {
throw new Error('houseplan.zip frontend manifest differs from committed bundle');
}
const required = [
'manifest.json', 'frontend/houseplan-assets.json',
...bundleSnapshot.manifest.files.map((file) => `frontend/${file.path}`),
];
const requiredSet = new Set(required);
const orphan = listZipEntries(zipPath)
.find((name) => (
/^frontend\/houseplan-.*\.js$/.test(name)
|| /^frontend\/houseplan-assets\/.*\.js$/.test(name)
) && !requiredSet.has(name));
if (orphan) throw new Error(`houseplan.zip contains orphan bundle asset: ${orphan}`);
const entries = readZipEntries(zipPath, required);
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
if (manifest.version !== version)
throw new Error(`houseplan.zip manifest version ${manifest.version} != ${version}`);
for (const file of bundleSnapshot.manifest.files) {
const bundledHash = sha256Bytes(entries.get(`frontend/${file.path}`));
if (bundledHash !== file.sha256) {
throw new Error(`houseplan.zip frontend hash mismatch: ${file.path}`);
}
}
};
const buildZip = (sha, version, bundleSnapshot, artifactsDir) => {
const zipPath = resolve(artifactsDir, 'houseplan.zip');
run('git', [
// `git archive` on Windows otherwise applies local core.autocrlf and
// produces bytes that differ from the tagged blobs/Linux fallback.
'-c', 'core.autocrlf=false', 'archive', '--format=zip', `--output=${zipPath}`,
`${sha}:custom_components/houseplan`,
]);
verifyZipContents(zipPath, version, bundleSnapshot);
return zipPath;
};
const verifyRemoteAssetContents = (version, bundleSnapshot, candidate) => {
const download = mkdtempSync(resolve(tmpdir(), 'houseplan-release-check-'));
try {
run('gh', [
'release', 'download', tag, '--repo', repo, '--dir', download,
'--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip',
'--pattern', MEMBERSHIP_FILE, '--pattern', SUMS_FILE, '--clobber',
]);
try {
const cardPath = resolve(download, 'houseplan-card.js');
const cardHash = sha256Path(cardPath);
if (cardHash !== bundleSnapshot.entrySha256)
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSnapshot.entrySha256}`);
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSnapshot);
const membership = verifyReleaseMembershipAgainstGit(
JSON.parse(readFileSync(resolve(download, MEMBERSHIP_FILE), 'utf8')),
{ tag, candidate },
);
if (issues.length) {
const actual = membership.issues.map((row) => row.number);
if (JSON.stringify(actual) !== JSON.stringify([...issues].sort((a, b) => a - b))) {
throw new Error(`Published membership ${actual.join(',')} != requested ${issues.join(',')}`);
}
}
// #540: паспорт обязан быть и обязан описывать ровно эти байты.
const expectedSums = parseSums(readFileSync(resolve(download, SUMS_FILE), 'utf8'));
const passport = compareSums(
expectedSums,
sumsOfDirectory(download, Object.keys(expectedSums)),
);
if (!passport.ok) throw new Error(`Published ${SUMS_FILE} disagrees with the assets: ${JSON.stringify(passport)}`);
return membership;
} catch (error) {
throw new ReleaseAssetContentError(
error instanceof Error ? error.message : String(error),
{ cause: error },
);
}
} finally {
rmSync(download, { recursive: true, force: true });
}
};
const localTag = () => {
const exists = run('git', ['show-ref', '--verify', '--quiet', `refs/tags/${tag}`], { allowFailure: true });
if (!exists.ok) return { exists: false, commit: null };
const type = run('git', ['cat-file', '-t', `refs/tags/${tag}`]).stdout;
const commit = run('git', ['rev-list', '-n', '1', tag]).stdout;
return { exists: true, annotated: type === 'tag', commit };
};
const validateIssues = () => {
for (const issue of issues) {
const row = ghJson(['issue', 'view', String(issue), '--repo', repo, '--json', 'number,state,url,title']);
if (row.number !== issue) throw new Error(`Issue #${issue} could not be verified`);
}
};
const assertGreenValidate = async (sha) => {
const runs = ghJson([
'run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', sha,
'--limit', '100', '--json', 'databaseId,status,conclusion,url,headSha,event,attempt,startedAt,createdAt',
]);
const tree = run('git', ['rev-parse', `${sha}^{tree}`]).stdout;
const token = run('gh', ['auth', 'token']).stdout;
const verdict = await classifyValidateProofs({ runs, repo, sha, tree, token });
if (verdict.status !== 'green') {
throw new Error(`Exact-SHA Validate proof is ${verdict.status} for ${sha}: ${verdict.note}`);
}
return runs;
};
// #540: после публикации никто больше не ждёт релизные workflow на событии:
// независимых републикаторов нет, ассеты беты выкладывает только этот путь,
// и сверка выложенного с кандидатом (sha256) делается здесь же ниже.
const verifyHacsDiscovery = () => {
const pages = ghJson(['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`]);
const releases = pages.flat();
const first = releases.find((release) => release.prerelease && !release.draft);
if (first?.tag_name !== tag) {
throw new Error(
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name || 'none'} before ${tag}`,
);
}
};
// Project v2 больше не используется (решение владельца 2026-08-14). Раньше
// здесь выяснялся id проекта, список его элементов и опция Status=Done, а
// отсутствие задачи в проекте роняло публикацию. Статус живёт в метках
// (PROCESS.md §9), и релизу нечего синхронизировать: он закрывает issue и
// снимает статусную метку, как это делает job close-merged (#120).
const finishIssues = (releaseUrl, sha, membershipPath) => {
run(process.execPath, [
'scripts/release-bookkeeping.mjs', `--repo=${repo}`, `--tag=${tag}`,
`--candidate=${sha}`, `--url=${releaseUrl}`, `--membership=${membershipPath}`,
], { inherit: true });
};
const acquireReleaseLock = () => {
const raw = run('git', ['rev-parse', '--git-path', `houseplan-release-${tag}.lock`]).stdout;
const lockPath = resolve(root, raw);
let fd;
try {
fd = openSync(lockPath, 'wx');
} catch (error) {
if (error?.code === 'EEXIST')
throw new Error(`Another local publication of ${tag} is active (${lockPath})`);
throw error;
}
writeFileSync(fd, `${process.pid}\n`, 'utf8');
return { fd, lockPath };
};
const releaseLock = ({ fd, lockPath }) => {
closeSync(fd);
try { unlinkSync(lockPath); } catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
};
const main = async () => {
issues = parseIssueList(issueOption);
assertHacsDiscoverableTag(tag);
const contract = assertReleaseContract({ root, tag, repo, requirePrerelease: true });
run('gh', ['auth', 'status']);
const currentBranch = run('git', ['branch', '--show-current']).stdout;
if (currentBranch !== branch) throw new Error(`Expected branch ${branch}, got ${currentBranch || '(detached)'}`);
if (run('git', ['status', '--porcelain']).stdout)
throw new Error('Working tree must be clean before publication');
run('git', ['fetch', 'origin', branch]);
const sha = run('git', ['rev-parse', 'HEAD']).stdout;
const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout;
const existingTag = remoteTag();
if (existingTag.exists) {
if (existingTag.commit !== sha) {
throw new Error(`Remote tag ${tag} points to ${existingTag.commit}; check out that candidate before retrying`);
}
} else if (sha !== remoteBranch) {
throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`);
}
const bundleSnapshot = assertBundleSnapshots(sha);
const bundleSha256 = bundleSnapshot.entrySha256;
const validateRuns = await assertGreenValidate(sha);
validateIssues();
const history = readCandidateHistory(sha);
const generatedMembership = buildReleaseMembership({
tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues,
}).manifest;
const existingRelease = releaseView();
console.log(JSON.stringify({
ready: true, tag, version: contract.version, sha, branch, bundleSha256,
validateRuns: validateRuns.map((runRow) => ({ id: runRow.databaseId, url: runRow.url })),
remoteTag: existingTag.exists, release: existingRelease?.url || null, issues,
}, null, 2));
if (checkOnly) return;
if (!confirmed) {
if (!stdin.isTTY) throw new Error('Publication requires --yes in a non-interactive shell');
const prompt = createInterface({ input: stdin, output: stdout });
const answer = await prompt.question(`Type ${tag} to publish exact SHA ${sha}: `);
prompt.close();
if (answer.trim() !== tag) throw new Error('Publication cancelled');
}
const lock = acquireReleaseLock();
let artifactsDir = null;
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (artifactsDir) rmSync(artifactsDir, { recursive: true, force: true });
releaseLock(lock);
};
const signalHandlers = new Map([
['SIGHUP', () => { try { cleanup(); } finally { process.exit(129); } }],
['SIGINT', () => { try { cleanup(); } finally { process.exit(130); } }],
['SIGTERM', () => { try { cleanup(); } finally { process.exit(143); } }],
]);
for (const [signal, handler] of signalHandlers) process.once(signal, handler);
try {
artifactsDir = mkdtempSync(resolve(tmpdir(), 'houseplan-release-'));
const bundlePath = materializeCommittedBundle(sha, bundleSha256, artifactsDir);
const membershipPath = resolve(artifactsDir, MEMBERSHIP_FILE);
writeFileSync(membershipPath, `${JSON.stringify(generatedMembership, null, 2)}\n`);
if (existingRelease && !existingRelease.isDraft) {
let complete;
try {
complete = verifyReleaseProjection(existingRelease, { tag });
} catch (error) {
if (!/Release asset .+ is missing or empty/.test(String(error?.message || error))) throw error;
console.log(`Published release needs asset recovery: ${error.message}`);
}
if (complete) {
// A matching name and non-zero size are insufficient: bind both
// downloadable assets to this exact candidate before closing issues.
try {
const publishedMembership = verifyRemoteAssetContents(contract.version, bundleSnapshot, sha);
writeFileSync(membershipPath, `${JSON.stringify(publishedMembership, null, 2)}\n`);
} catch (error) {
if (!(error instanceof ReleaseAssetContentError)) throw error;
console.log(`Published release needs stale-asset recovery: ${error.message}`);
complete = null;
}
if (complete) {
verifyHacsDiscovery();
finishIssues(complete.url, sha, membershipPath);
console.log(`Already published and content-verified: ${complete.url}`);
return;
}
}
}
const zipPath = buildZip(sha, contract.version, bundleSnapshot, artifactsDir);
if (!existingTag.exists) {
const local = localTag();
if (local.exists && (!local.annotated || local.commit !== sha)) {
throw new Error(
`Local tag ${tag} is ${local.annotated ? `at ${local.commit}` : 'not annotated'}, expected annotated ${sha}`,
);
}
if (!local.exists) run('git', ['tag', '-a', tag, sha, '-m', tag]);
run('git', ['push', 'origin', tag], { inherit: true });
}
let release = releaseView();
if (!release) {
run('gh', [
'release', 'create', tag, '--repo', repo, '--verify-tag', '--draft', '--prerelease',
'--title', tag, '--notes-file', 'docs/RELEASE-NOTES.md',
], { inherit: true });
release = releaseView();
}
// #540: паспорт ассетов — единый вид релиза с release.yml. Считается с
// тех самых файлов, что уходят наверх, и сверяется после публикации.
const sumsPath = resolve(artifactsDir, SUMS_FILE);
writeFileSync(sumsPath, formatSums({
'houseplan-card.js': sha256Path(bundlePath),
'houseplan.zip': sha256Path(zipPath),
[MEMBERSHIP_FILE]: sha256Path(membershipPath),
}));
run('gh', [
'release', 'upload', tag, bundlePath, zipPath, membershipPath, sumsPath,
'--repo', repo, '--clobber',
], { inherit: true });
const staged = releaseView();
const stagedAssets = new Map((staged?.assets || []).map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip', MEMBERSHIP_FILE, SUMS_FILE]) {
if (!(Number(stagedAssets.get(name)?.size) > 0))
throw new Error(`Draft release asset ${name} is missing or empty`);
}
run('gh', [
'release', 'edit', tag, '--repo', repo, '--draft=false', '--prerelease',
'--title', tag, '--notes-file', 'docs/RELEASE-NOTES.md',
], { inherit: true });
const published = verifyReleaseProjection(releaseView(), { tag });
const finalTag = remoteTag();
if (!finalTag.exists || finalTag.commit !== sha)
throw new Error(`Published tag ${tag} no longer resolves to exact SHA ${sha}`);
verifyRemoteAssetContents(contract.version, bundleSnapshot, sha);
verifyHacsDiscovery();
finishIssues(published.url, sha, membershipPath);
console.log(`Published and content-verified: ${published.url}`);
} finally {
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
cleanup();
}
};
main().catch((error) => {
console.error(`prerelease publication failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
} catch (error) {
console.error(`prerelease publication failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}