mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
fix(release): bind beta bookkeeping to candidate (#547)
Issue: #547 User-Visible: no
This commit is contained in:
@@ -7859,6 +7859,29 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' else if (false) mismatched.push(name);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'prerelease-membership-keeps-unproven-s8',
|
||||
guard: 'node --test test/release-membership.test.mjs',
|
||||
because: '#547: the mutable S8 queue is only a hint. Keeping an issue without an '
|
||||
+ 'Issue trailer in the pinned candidate recreates the A -> B -> publish A race and '
|
||||
+ 'closes B as if it had shipped',
|
||||
patches: [{
|
||||
file: 'scripts/release-membership.mjs',
|
||||
find: ' .filter((number) => evidence.get(number).length > 0)',
|
||||
replace: ' .filter(() => true) // mutant: trust the live S8 snapshot',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'prerelease-bookkeeping-duplicates-release-comment',
|
||||
guard: 'node --test test/release-bookkeeping.test.mjs',
|
||||
because: '#547: a failure after commenting but before label removal must be retryable; '
|
||||
+ 'without the marker check every retry adds another public release comment',
|
||||
patches: [{
|
||||
file: 'scripts/release-bookkeeping.mjs',
|
||||
find: ' comment: !comments.some((body) => String(body).includes(marker)),',
|
||||
replace: ' comment: true, // mutant: retries duplicate the comment',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'render-invalidation-unknown-key-ignored',
|
||||
guard: 'node --test test/render-invalidation.test.mjs',
|
||||
|
||||
@@ -19,9 +19,12 @@ import { createHash } from 'node:crypto';
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { MEMBERSHIP_FILE } from './release-membership.mjs';
|
||||
|
||||
/** Установочные ассеты — то, что скачивает HACS и человек. Ровно эти два. */
|
||||
export const INSTALLABLE_ASSETS = ['houseplan-card.js', 'houseplan.zip'];
|
||||
/** Candidate identity is not installable, but is part of the verified release. */
|
||||
export const PASSPORTED_ASSETS = [...INSTALLABLE_ASSETS, MEMBERSHIP_FILE];
|
||||
export const SUMS_FILE = 'SHA256SUMS';
|
||||
|
||||
export const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
@@ -83,8 +86,9 @@ if (isMainModule(import.meta.url)) { // #496: переносимо для Window
|
||||
const [command, dir, sumsPath] = positionals;
|
||||
if (command === 'sums') {
|
||||
if (!dir) throw new Error('usage: release-assets.mjs sums <dir> [--out=<file>]');
|
||||
const entries = sumsOfDirectory(dir);
|
||||
for (const name of INSTALLABLE_ASSETS) {
|
||||
const names = flag('include-membership') ? PASSPORTED_ASSETS : INSTALLABLE_ASSETS;
|
||||
const entries = sumsOfDirectory(dir, names);
|
||||
for (const name of names) {
|
||||
if (!entries[name]) throw new Error(`${name} отсутствует в ${dir} — паспорт не выписывается на неполный набор`);
|
||||
}
|
||||
const out = value('out') || resolve(dir, SUMS_FILE);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
/** Idempotent issue bookkeeping driven only by a verified release manifest. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { MEMBERSHIP_FILE, verifyReleaseMembershipAgainstGit } from './release-membership.mjs';
|
||||
|
||||
export const releaseCommentMarker = (tag) => `<!-- houseplan-release:${tag} -->`;
|
||||
|
||||
export function planIssueBookkeeping({ state, labels = [], comments = [] }, tag) {
|
||||
const marker = releaseCommentMarker(tag);
|
||||
return {
|
||||
comment: !comments.some((body) => String(body).includes(marker)),
|
||||
removeLabels: labels.filter((name) => /^S\d+-/.test(name)).sort(),
|
||||
close: state === 'OPEN',
|
||||
};
|
||||
}
|
||||
|
||||
export function finishManifestIssues({ manifest, tag, url, ops }) {
|
||||
const marker = releaseCommentMarker(tag);
|
||||
const body = `Выпущено в \`${tag}\` · [релиз](${url})\n\n${marker}`;
|
||||
for (const { number } of manifest.issues) {
|
||||
const action = planIssueBookkeeping(ops.load(number), tag);
|
||||
if (action.comment) ops.comment(number, body);
|
||||
for (const label of action.removeLabels) ops.removeLabel(number, label);
|
||||
if (action.close) ops.close(number);
|
||||
const final = ops.load(number);
|
||||
if (final.state !== 'CLOSED'
|
||||
|| final.labels.some((label) => /^S\d+-/.test(label))
|
||||
|| !final.comments.some((comment) => comment.includes(marker))) {
|
||||
throw new Error(`bookkeeping for #${number} is incomplete after the attempted repair`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseArgs = (args) => {
|
||||
const values = new Map();
|
||||
for (const arg of args) {
|
||||
const match = /^--([^=]+)=(.*)$/.exec(arg);
|
||||
if (!match) throw new Error(`unexpected argument: ${arg}`);
|
||||
values.set(match[1], match[2]);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
try {
|
||||
const values = parseArgs(process.argv.slice(2));
|
||||
const repo = values.get('repo');
|
||||
const tag = values.get('tag');
|
||||
const candidate = values.get('candidate');
|
||||
const url = values.get('url');
|
||||
const membershipPath = values.get('membership') || MEMBERSHIP_FILE;
|
||||
if (!repo || !tag || !candidate || !url) {
|
||||
throw new Error('usage: release-bookkeeping.mjs --repo=... --tag=... --candidate=... --url=... [--membership=...]');
|
||||
}
|
||||
const run = (args) => {
|
||||
const result = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`);
|
||||
}
|
||||
return String(result.stdout || '').trim();
|
||||
};
|
||||
const manifest = verifyReleaseMembershipAgainstGit(
|
||||
JSON.parse(readFileSync(membershipPath, 'utf8')),
|
||||
{ tag, candidate },
|
||||
);
|
||||
const load = (number) => {
|
||||
const issue = JSON.parse(run([
|
||||
'issue', 'view', String(number), '--repo', repo, '--json', 'state,labels',
|
||||
]));
|
||||
const pages = JSON.parse(run([
|
||||
'api', '--paginate', '--slurp', `repos/${repo}/issues/${number}/comments?per_page=100`,
|
||||
]) || '[]');
|
||||
return {
|
||||
state: issue.state,
|
||||
labels: (issue.labels || []).map((label) => label.name),
|
||||
comments: pages.flat().map((comment) => comment.body || ''),
|
||||
};
|
||||
};
|
||||
finishManifestIssues({
|
||||
manifest, tag, url,
|
||||
ops: {
|
||||
load,
|
||||
comment: (number, body) => run([
|
||||
'issue', 'comment', String(number), '--repo', repo, '--body', body,
|
||||
]),
|
||||
removeLabel: (number, label) => run([
|
||||
'issue', 'edit', String(number), '--repo', repo, '--remove-label', label,
|
||||
]),
|
||||
close: (number) => run([
|
||||
'issue', 'close', String(number), '--repo', repo, '--reason', 'completed',
|
||||
]),
|
||||
},
|
||||
});
|
||||
for (const { number } of manifest.issues) console.log(`bookkeeping complete for #${number}`);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Candidate-bound issue membership for prereleases (#547).
|
||||
*
|
||||
* The manifest is deliberately deterministic and contains git evidence for
|
||||
* every issue. An S8 label is only an input hint; it is never proof that an
|
||||
* issue belongs to the pinned candidate.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
export const MEMBERSHIP_FILE = 'RELEASE-MEMBERSHIP.json';
|
||||
export const MEMBERSHIP_SCHEMA = 1;
|
||||
|
||||
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
||||
const TAG_RE = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
export function parseMembershipIssueList(value = '') {
|
||||
if (!String(value).trim()) return [];
|
||||
const parts = String(value).split(',').map((part) => part.trim()).filter(Boolean);
|
||||
if (parts.some((part) => !/^[1-9]\d*$/.test(part))) {
|
||||
throw new Error(`issues must be comma-separated positive numbers: ${value}`);
|
||||
}
|
||||
return [...new Set(parts.map(Number))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function issueTrailers(message = '') {
|
||||
const found = [];
|
||||
for (const line of String(message).split(/\r?\n/)) {
|
||||
const match = /^Issue:\s*#([1-9]\d*)\s*$/.exec(line.trim());
|
||||
if (match) found.push(Number(match[1]));
|
||||
}
|
||||
return [...new Set(found)];
|
||||
}
|
||||
|
||||
export function buildReleaseMembership({
|
||||
tag, candidate, base = null, commits = [], issueNumbers = [], allowUnmatched = false,
|
||||
}) {
|
||||
const requested = [...new Set(issueNumbers.map(Number))].sort((a, b) => a - b);
|
||||
const evidence = new Map(requested.map((number) => [number, []]));
|
||||
for (const commit of commits) {
|
||||
if (!SHA_RE.test(commit.sha)) throw new Error(`invalid commit SHA: ${commit.sha}`);
|
||||
for (const number of issueTrailers(commit.message)) {
|
||||
if (evidence.has(number)) evidence.get(number).push(commit.sha);
|
||||
}
|
||||
}
|
||||
const unmatched = requested.filter((number) => evidence.get(number).length === 0);
|
||||
if (unmatched.length && !allowUnmatched) {
|
||||
throw new Error(
|
||||
`issues are not proven in candidate ${candidate}: ${unmatched.map((n) => `#${n}`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
const manifest = {
|
||||
schema: MEMBERSHIP_SCHEMA,
|
||||
tag,
|
||||
candidate,
|
||||
base,
|
||||
issues: requested
|
||||
.filter((number) => evidence.get(number).length > 0)
|
||||
.map((number) => ({ number, commits: [...new Set(evidence.get(number))].sort() })),
|
||||
};
|
||||
validateReleaseMembership(manifest, { tag, candidate });
|
||||
return { manifest, unmatched };
|
||||
}
|
||||
|
||||
export function validateReleaseMembership(manifest, expected = {}) {
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||||
throw new Error('release membership must be a JSON object');
|
||||
}
|
||||
if (manifest.schema !== MEMBERSHIP_SCHEMA) {
|
||||
throw new Error(`unsupported release membership schema: ${manifest.schema}`);
|
||||
}
|
||||
if (!TAG_RE.test(manifest.tag || '')) throw new Error(`invalid membership tag: ${manifest.tag}`);
|
||||
if (!SHA_RE.test(manifest.candidate || '')) {
|
||||
throw new Error(`invalid membership candidate: ${manifest.candidate}`);
|
||||
}
|
||||
if (expected.tag && manifest.tag !== expected.tag) {
|
||||
throw new Error(`membership tag ${manifest.tag} != expected ${expected.tag}`);
|
||||
}
|
||||
if (expected.candidate && manifest.candidate !== expected.candidate) {
|
||||
throw new Error(`membership candidate ${manifest.candidate} != expected ${expected.candidate}`);
|
||||
}
|
||||
if (manifest.base !== null) {
|
||||
if (!manifest.base || typeof manifest.base !== 'object'
|
||||
|| !TAG_RE.test(manifest.base.tag || '') || !SHA_RE.test(manifest.base.sha || '')) {
|
||||
throw new Error('membership base must be null or { tag, sha }');
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(manifest.issues)) throw new Error('membership issues must be an array');
|
||||
let previous = 0;
|
||||
for (const issue of manifest.issues) {
|
||||
if (!Number.isInteger(issue?.number) || issue.number <= previous) {
|
||||
throw new Error('membership issues must be unique positive numbers in ascending order');
|
||||
}
|
||||
if (!Array.isArray(issue.commits) || issue.commits.length === 0
|
||||
|| issue.commits.some((sha) => !SHA_RE.test(sha))) {
|
||||
throw new Error(`membership issue #${issue.number} has no valid commit evidence`);
|
||||
}
|
||||
if ([...new Set(issue.commits)].sort().join(',') !== issue.commits.join(',')) {
|
||||
throw new Error(`membership issue #${issue.number} commit evidence must be unique and sorted`);
|
||||
}
|
||||
previous = issue.number;
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
const git = (args, { allowFailure = false } = {}) => {
|
||||
const result = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`);
|
||||
}
|
||||
return { ok: result.status === 0, stdout: String(result.stdout || '').trim() };
|
||||
};
|
||||
|
||||
export function readCandidateHistory(candidate) {
|
||||
if (!SHA_RE.test(candidate)) throw new Error(`invalid candidate SHA: ${candidate}`);
|
||||
const previous = git([
|
||||
'describe', '--tags', '--abbrev=0', '--first-parent', '--match=v*', `${candidate}^`,
|
||||
], { allowFailure: true });
|
||||
const base = previous.ok && previous.stdout
|
||||
? { tag: previous.stdout, sha: git(['rev-list', '-n', '1', previous.stdout]).stdout }
|
||||
: null;
|
||||
const range = base ? `${base.sha}..${candidate}` : candidate;
|
||||
const raw = git(['log', '--first-parent', '--format=%H%x1f%B%x1e', range]).stdout;
|
||||
const commits = raw.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const separator = record.indexOf('\x1f');
|
||||
if (separator < 0) throw new Error('cannot parse candidate git history');
|
||||
return { sha: record.slice(0, separator).trim(), message: record.slice(separator + 1) };
|
||||
});
|
||||
return { base, commits };
|
||||
}
|
||||
|
||||
export function verifyReleaseMembershipAgainstGit(manifest, expected = {}) {
|
||||
validateReleaseMembership(manifest, expected);
|
||||
const history = readCandidateHistory(manifest.candidate);
|
||||
if (JSON.stringify(history.base) !== JSON.stringify(manifest.base)) {
|
||||
throw new Error('membership base does not match the candidate history');
|
||||
}
|
||||
const all = new Set(history.commits.map((commit) => commit.sha));
|
||||
const byIssue = new Map();
|
||||
for (const commit of history.commits) {
|
||||
for (const number of issueTrailers(commit.message)) {
|
||||
if (!byIssue.has(number)) byIssue.set(number, new Set());
|
||||
byIssue.get(number).add(commit.sha);
|
||||
}
|
||||
}
|
||||
for (const issue of manifest.issues) {
|
||||
for (const sha of issue.commits) {
|
||||
if (!all.has(sha) || !byIssue.get(issue.number)?.has(sha)) {
|
||||
throw new Error(`membership evidence ${sha} does not prove issue #${issue.number}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
const parseArgs = (args) => {
|
||||
const [command, ...rest] = args;
|
||||
const values = new Map();
|
||||
const switches = new Set();
|
||||
for (const arg of rest) {
|
||||
const match = /^--([^=]+)(?:=(.*))?$/.exec(arg);
|
||||
if (!match) throw new Error(`unexpected argument: ${arg}`);
|
||||
if (match[2] === undefined) switches.add(match[1]);
|
||||
else values.set(match[1], match[2]);
|
||||
}
|
||||
return { command, values, switches };
|
||||
};
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
try {
|
||||
const { command, values, switches } = parseArgs(process.argv.slice(2));
|
||||
const tag = values.get('tag');
|
||||
const candidate = values.get('candidate');
|
||||
const input = resolve(values.get('input') || MEMBERSHIP_FILE);
|
||||
if (command === 'create') {
|
||||
const { base, commits } = readCandidateHistory(candidate);
|
||||
const { manifest, unmatched } = buildReleaseMembership({
|
||||
tag,
|
||||
candidate,
|
||||
base,
|
||||
commits,
|
||||
issueNumbers: parseMembershipIssueList(values.get('issues')),
|
||||
allowUnmatched: switches.has('allow-unmatched'),
|
||||
});
|
||||
const output = resolve(values.get('output') || MEMBERSHIP_FILE);
|
||||
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
if (unmatched.length) {
|
||||
console.log(`Excluded without candidate proof: ${unmatched.map((n) => `#${n}`).join(', ')}`);
|
||||
}
|
||||
console.log(`Membership: ${manifest.issues.map((row) => `#${row.number}`).join(', ') || '(empty)'}`);
|
||||
} else if (command === 'verify' || command === 'list') {
|
||||
const manifest = JSON.parse(readFileSync(input, 'utf8'));
|
||||
verifyReleaseMembershipAgainstGit(manifest, { tag, candidate });
|
||||
if (command === 'list') console.log(manifest.issues.map((row) => row.number).join(','));
|
||||
else console.log(`Verified ${input}: ${manifest.issues.length} issue(s)`);
|
||||
} else {
|
||||
throw new Error('usage: release-membership.mjs create|verify|list --tag=... --candidate=...');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ 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;
|
||||
|
||||
@@ -80,7 +84,7 @@ export function verifyReleaseProjection(release, { tag }) {
|
||||
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', SUMS_FILE]) {
|
||||
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`);
|
||||
}
|
||||
@@ -347,12 +351,13 @@ if (invokedDirectly) {
|
||||
return zipPath;
|
||||
};
|
||||
|
||||
const verifyRemoteAssetContents = (version, bundleSnapshot) => {
|
||||
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', SUMS_FILE, '--clobber',
|
||||
'--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip',
|
||||
'--pattern', MEMBERSHIP_FILE, '--pattern', SUMS_FILE, '--clobber',
|
||||
]);
|
||||
try {
|
||||
const cardPath = resolve(download, 'houseplan-card.js');
|
||||
@@ -360,12 +365,24 @@ if (invokedDirectly) {
|
||||
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(
|
||||
parseSums(readFileSync(resolve(download, SUMS_FILE), 'utf8')),
|
||||
sumsOfDirectory(download),
|
||||
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),
|
||||
@@ -426,26 +443,11 @@ if (invokedDirectly) {
|
||||
// отсутствие задачи в проекте роняло публикацию. Статус живёт в метках
|
||||
// (PROCESS.md §9), и релизу нечего синхронизировать: он закрывает issue и
|
||||
// снимает статусную метку, как это делает job close-merged (#120).
|
||||
const finishIssues = (releaseUrl) => {
|
||||
if (!issues.length) return;
|
||||
for (const issue of issues) {
|
||||
const row = ghJson(['issue', 'view', String(issue), '--repo', repo, '--json', 'state,labels']);
|
||||
if (row.state === 'OPEN') {
|
||||
const status = (row.labels || [])
|
||||
.map((label) => label.name)
|
||||
.filter((name) => /^S\d-/.test(name));
|
||||
// Метка снимается ДО закрытия: инвариант «закрытый issue не несёт
|
||||
// статусных меток» ломался уже дважды, и оба раза из-за обратного
|
||||
// порядка в ручном шаге.
|
||||
for (const name of status) {
|
||||
run('gh', ['issue', 'edit', String(issue), '--repo', repo, '--remove-label', name]);
|
||||
}
|
||||
run('gh', [
|
||||
'issue', 'close', String(issue), '--repo', repo, '--reason', 'completed',
|
||||
'--comment', `Реализовано и опубликовано в [${tag}](${releaseUrl}).`,
|
||||
], { inherit: true });
|
||||
}
|
||||
}
|
||||
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 = () => {
|
||||
@@ -482,14 +484,22 @@ if (invokedDirectly) {
|
||||
run('git', ['fetch', 'origin', branch]);
|
||||
const sha = run('git', ['rev-parse', 'HEAD']).stdout;
|
||||
const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout;
|
||||
if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`);
|
||||
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 existingTag = remoteTag();
|
||||
if (existingTag.exists && existingTag.commit !== sha)
|
||||
throw new Error(`Remote tag ${tag} points to ${existingTag.commit}, expected ${sha}`);
|
||||
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({
|
||||
@@ -525,6 +535,8 @@ if (invokedDirectly) {
|
||||
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 {
|
||||
@@ -537,7 +549,8 @@ if (invokedDirectly) {
|
||||
// A matching name and non-zero size are insufficient: bind both
|
||||
// downloadable assets to this exact candidate before closing issues.
|
||||
try {
|
||||
verifyRemoteAssetContents(contract.version, bundleSnapshot);
|
||||
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}`);
|
||||
@@ -545,7 +558,7 @@ if (invokedDirectly) {
|
||||
}
|
||||
if (complete) {
|
||||
verifyHacsDiscovery();
|
||||
finishIssues(complete.url);
|
||||
finishIssues(complete.url, sha, membershipPath);
|
||||
console.log(`Already published and content-verified: ${complete.url}`);
|
||||
return;
|
||||
}
|
||||
@@ -579,14 +592,15 @@ if (invokedDirectly) {
|
||||
writeFileSync(sumsPath, formatSums({
|
||||
'houseplan-card.js': sha256Path(bundlePath),
|
||||
'houseplan.zip': sha256Path(zipPath),
|
||||
[MEMBERSHIP_FILE]: sha256Path(membershipPath),
|
||||
}));
|
||||
run('gh', [
|
||||
'release', 'upload', tag, bundlePath, zipPath, sumsPath,
|
||||
'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', SUMS_FILE]) {
|
||||
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`);
|
||||
}
|
||||
@@ -600,9 +614,9 @@ if (invokedDirectly) {
|
||||
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);
|
||||
verifyRemoteAssetContents(contract.version, bundleSnapshot, sha);
|
||||
verifyHacsDiscovery();
|
||||
finishIssues(published.url);
|
||||
finishIssues(published.url, sha, membershipPath);
|
||||
console.log(`Published and content-verified: ${published.url}`);
|
||||
} finally {
|
||||
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
||||
|
||||
Reference in New Issue
Block a user