mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Приёмка эталонов на beta.3 (`ad4000f9`) стоила второго полного Validate —
22 минуты, из них 17–22 на шард мутантов. Причина одна: корпус отпечатка
(`source-fingerprint.mjs`) называет `demo/golden` строкой-каталогом, а
замыкание входов раскрывало каталог во все текстовые файлы под ним, включая
`baselines-index.json`. Индекс становился входом smoke, performance_smoke и
каждого гарда через `serve.mjs`: на реальной паре C→B ключи smoke/perf были
DIFFERENT, отпечатки 181 из 183 браузерных свидетелей менялись, журнал их не
пропускал.
- `check-inputs.mjs`: `BASELINE_OVERLAY` — раскрытие каталога не выдаёт
overlay; явный корень golden и явная ссылка на файл — как были. На паре
C→B: ключи smoke/perf/parity/backend same, golden DIFFERENT; отпечатки
743 из 744 равны; план мутантов B с журналом C — 0–1 на шард вместо 38–44
- `ci-proof.mjs`: составное evidence — product tree без overlay, overlay
(tree, sha256 индекса, run из `Baseline-Reviewed`), content-ключи всех
реюзных job (исполненных тоже); `evaluateCiProof({expected, reviewedRun})`
сверяет с локальным расчётом, fail-closed на ключ, tree, индекс, reviewed
run, маркер с чужим ключом; proof без evidence при ожиданиях — stale
- `release-gate.mjs` / `release-prerelease.mjs`: ожидания считаются на
checkout кандидата (`candidateExpectations`), чужой checkout — notice
- мутанты: `baseline-overlay-leaks-into-every-key`,
`proof-trusts-evidence-it-could-verify`,
`reused-marker-key-unchecked-against-candidate`,
`product-tree-identity-counts-baselines`; перенацелен
`ci-proof-ignores-run-attempt`
- docs: TESTING (правило overlay), DEVELOPMENT (evidence в release proof),
STATUS
Issue: #573
User-Visible: no
638 lines
30 KiB
JavaScript
638 lines
30 KiB
JavaScript
#!/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 { candidateExpectations, 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;
|
||
// #573: локальный публикатор стоит на checkout кандидата — сверяет evidence.
|
||
const expected = candidateExpectations({ sha, root });
|
||
const verdict = await classifyValidateProofs({ runs, repo, sha, tree, token, expected });
|
||
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;
|
||
}
|
||
}
|