mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
632 lines
22 KiB
TypeScript
632 lines
22 KiB
TypeScript
#!/usr/bin/env node
|
|
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { pathToFileURL } from "node:url";
|
|
import { changelogFormat, findReleaseChangelog } from "./lib/release-changelog.mjs";
|
|
import {
|
|
compactReleaseNotes,
|
|
OPENCLAW_RELEASE_TAG_PATTERN,
|
|
validateReleaseNotesRepository as validateRepository,
|
|
validateReleaseNotesTag as validateTag,
|
|
} from "./lib/release-notes-compaction.mjs";
|
|
import { classifyReleaseTrain, parseReleaseVersion } from "./lib/release-version.mjs";
|
|
import { validateActiveExtendedStableLine } from "./openclaw-npm-extended-stable-release.mjs";
|
|
|
|
type ShippedBaselineExclusion = {
|
|
ref: string;
|
|
count: number;
|
|
pullRequests: number[];
|
|
};
|
|
|
|
type ContributionRecordProvenance = {
|
|
base: string;
|
|
target: string;
|
|
inRangePullRequests?: number;
|
|
retainedSeedOnlyPullRequests?: number;
|
|
uniquePullRequests: number;
|
|
};
|
|
|
|
export const GITHUB_RELEASE_BODY_MAX_CHARACTERS = 125_000;
|
|
export const GITHUB_RELEASE_BODY_MAX_BYTES = 125_000;
|
|
|
|
type ReleaseNotesTarget = {
|
|
changelog: unknown;
|
|
version: unknown;
|
|
tag: unknown;
|
|
repository: unknown;
|
|
regularStableVersion?: unknown;
|
|
contributionRecordPath?: string;
|
|
};
|
|
|
|
const RELEASE_VERIFICATION_HEADING = "### Release verification";
|
|
const SHIPPED_BASELINE_EXCLUSIONS_PREFIX = "Shipped baseline exclusions:";
|
|
const MONTH_NAMES = [
|
|
"January",
|
|
"February",
|
|
"March",
|
|
"April",
|
|
"May",
|
|
"June",
|
|
"July",
|
|
"August",
|
|
"September",
|
|
"October",
|
|
"November",
|
|
"December",
|
|
] as const;
|
|
const RELEASE_HEADING_PATTERN =
|
|
/^## (?<version>Unreleased|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*))?)\r?$/u;
|
|
|
|
function fail(message: string): never {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function assertString(value: unknown, label: string): asserts value is string {
|
|
if (typeof value !== "string") {
|
|
fail(`${label} must be a string`);
|
|
}
|
|
}
|
|
|
|
function normalizeTail(value: string | undefined) {
|
|
return value?.trim() ?? "";
|
|
}
|
|
|
|
function joinBody(notes: string, tail: string | undefined) {
|
|
const normalizedNotes = notes.trimEnd();
|
|
const normalizedTail = normalizeTail(tail);
|
|
if (!normalizedNotes) {
|
|
return normalizedTail;
|
|
}
|
|
return normalizedTail ? `${normalizedNotes}\n\n${normalizedTail}` : normalizedNotes;
|
|
}
|
|
|
|
function extendedStableReleaseNotice({
|
|
version,
|
|
repository,
|
|
regularStableVersion,
|
|
}: {
|
|
version: string;
|
|
repository: string;
|
|
regularStableVersion: unknown;
|
|
}) {
|
|
const release = parseReleaseVersion(version);
|
|
if (!release || classifyReleaseTrain(release) !== "extended-stable") {
|
|
if (regularStableVersion !== undefined) {
|
|
fail("regular stable version is only valid for extended-stable release notes");
|
|
}
|
|
return "";
|
|
}
|
|
assertString(regularStableVersion, "regular stable version");
|
|
validateActiveExtendedStableLine(version, regularStableVersion);
|
|
const month = MONTH_NAMES[release.month - 1];
|
|
if (!month) {
|
|
fail(`unsupported extended-stable release month: ${release.month}`);
|
|
}
|
|
return `This is a gateway-only \`extended-stable\` release, which is our current equivalent to LTS. This release is OpenClaw from the end of ${month} ${release.year}, plus critical security updates, reliability and performance fixes, and features like new model support. The current latest version of OpenClaw is [${regularStableVersion}](https://github.com/${repository}/releases#release-v${regularStableVersion})`;
|
|
}
|
|
|
|
export function formatContributionRecordProvenance(provenance: ContributionRecordProvenance) {
|
|
const { base, target, inRangePullRequests, retainedSeedOnlyPullRequests } = provenance;
|
|
if (inRangePullRequests === undefined || retainedSeedOnlyPullRequests === undefined) {
|
|
fail("canonical contribution record provenance requires split PR counts");
|
|
}
|
|
const uniquePullRequests = inRangePullRequests + retainedSeedOnlyPullRequests;
|
|
const count = (value: number) => value.toLocaleString("en-US");
|
|
const prs = (value: number) => `PR${value === 1 ? "" : "s"}`;
|
|
return `This audited record covers the complete ${base}..${target} history: ${count(inRangePullRequests)} in-range ${prs(inRangePullRequests)} + ${count(retainedSeedOnlyPullRequests)} retained seed-only ${prs(retainedSeedOnlyPullRequests)} = ${count(uniquePullRequests)} unique ${prs(uniquePullRequests)}.`;
|
|
}
|
|
export function parseContributionRecordProvenance(section: string) {
|
|
const rows = Array.from(section.matchAll(/^- \*\*PR #(\d+)\*\*/gmu), (match) => Number(match[1]));
|
|
const duplicate = rows.find((value, index) => rows.indexOf(value) !== index);
|
|
if (duplicate !== undefined) {
|
|
fail(`duplicate contribution record PR #${duplicate}`);
|
|
}
|
|
const line = section.match(/^This audited record covers the complete .+$/mu)?.[0];
|
|
if (!line) {
|
|
return undefined;
|
|
}
|
|
const canonical = line.match(
|
|
/^This audited record covers the complete (?<base>\S+)\.\.(?<target>[0-9a-f]{40}) history: (?<inRange>0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) in-range PRs? \+ (?<seedOnly>0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) retained seed-only PRs? = (?<unique>0|[1-9][0-9]{0,2}(?:,[0-9]{3})*) unique PRs?\./u,
|
|
);
|
|
const legacy =
|
|
canonical ??
|
|
line.match(
|
|
/^This audited record covers the complete (?<base>\S+)\.\.(?<target>\S+) history: (?<unique>[0-9]+) merged PRs?\./u,
|
|
);
|
|
const groups = legacy?.groups;
|
|
if (!groups?.base || !groups.target || groups.unique === undefined) {
|
|
fail("release contribution record provenance is malformed");
|
|
}
|
|
const number = (value: string) => Number(value.replaceAll(",", ""));
|
|
const provenance: ContributionRecordProvenance = {
|
|
base: groups.base,
|
|
target: groups.target,
|
|
uniquePullRequests: number(groups.unique),
|
|
};
|
|
const canonicalGroups = canonical?.groups;
|
|
if (canonicalGroups?.inRange !== undefined && canonicalGroups.seedOnly !== undefined) {
|
|
provenance.inRangePullRequests = number(canonicalGroups.inRange);
|
|
provenance.retainedSeedOnlyPullRequests = number(canonicalGroups.seedOnly);
|
|
if (
|
|
provenance.inRangePullRequests + provenance.retainedSeedOnlyPullRequests !==
|
|
provenance.uniquePullRequests
|
|
) {
|
|
fail("release contribution record provenance arithmetic is invalid");
|
|
}
|
|
}
|
|
if (provenance.uniquePullRequests > 0 && !/^#### Pull requests\r?$/mu.test(section)) {
|
|
fail("positive contribution record requires a Pull requests section");
|
|
}
|
|
if (rows.length !== provenance.uniquePullRequests) {
|
|
fail(`contribution record row count ${rows.length} != ${provenance.uniquePullRequests}`);
|
|
}
|
|
return provenance;
|
|
}
|
|
|
|
function githubReleaseBodySize(body: string) {
|
|
return {
|
|
characters: Array.from(body).length,
|
|
bytes: Buffer.byteLength(body, "utf8"),
|
|
};
|
|
}
|
|
|
|
function fitsGithubReleaseBody(body: string) {
|
|
const size = githubReleaseBodySize(body);
|
|
return (
|
|
size.characters <= GITHUB_RELEASE_BODY_MAX_CHARACTERS &&
|
|
size.bytes <= GITHUB_RELEASE_BODY_MAX_BYTES
|
|
);
|
|
}
|
|
|
|
function releaseSections(changelog: string) {
|
|
const headings: Array<{ version: string | undefined; start: number }> = [];
|
|
let offset = 0;
|
|
let fence: string | undefined;
|
|
for (const segment of changelog.split(/(?<=\n)/u)) {
|
|
const line = segment.replace(/\n$/u, "");
|
|
const fenceMatch = line.match(/^\s*(?<marker>`{3,}|~{3,})/u);
|
|
if (fenceMatch?.groups?.marker) {
|
|
const marker = fenceMatch.groups.marker;
|
|
if (!fence) {
|
|
fence = marker;
|
|
} else if (marker.charAt(0) === fence.charAt(0) && marker.length >= fence.length) {
|
|
fence = undefined;
|
|
}
|
|
offset += segment.length;
|
|
continue;
|
|
}
|
|
if (!fence) {
|
|
if (line.startsWith("## ")) {
|
|
const releaseHeading = line.match(RELEASE_HEADING_PATTERN);
|
|
headings.push({ version: releaseHeading?.groups?.version, start: offset });
|
|
}
|
|
}
|
|
offset += segment.length;
|
|
}
|
|
return headings.flatMap(({ version, start }, index) =>
|
|
version ? [{ version, start, end: headings[index + 1]?.start ?? changelog.length }] : [],
|
|
);
|
|
}
|
|
|
|
export function extractChangelogReleaseSections(changelog: string) {
|
|
return releaseSections(changelog).map(({ version, start, end }) => ({
|
|
version,
|
|
source: changelog.slice(start, end).trimEnd(),
|
|
}));
|
|
}
|
|
|
|
export function extractChangelogSection(changelog: unknown, version: unknown) {
|
|
assertString(changelog, "changelog");
|
|
assertString(version, "version");
|
|
const section = releaseSections(changelog).find((candidate) => candidate.version === version);
|
|
if (!section) {
|
|
fail(`CHANGELOG.md does not contain ## ${version}`);
|
|
}
|
|
return changelog.slice(section.start, section.end).trimEnd();
|
|
}
|
|
|
|
export function releaseNotesVersionForTag(tag: unknown) {
|
|
assertString(tag, "tag");
|
|
validateTag(tag);
|
|
return tag.replace(/^v/u, "").replace(/-(?:(?:alpha|beta)\.[1-9][0-9]*|[1-9][0-9]*)$/u, "");
|
|
}
|
|
|
|
function validateShippedBaselineRef(ref: string) {
|
|
if (!OPENCLAW_RELEASE_TAG_PATTERN.test(ref)) {
|
|
fail(`invalid shipped release tag: ${ref}`);
|
|
}
|
|
}
|
|
|
|
export function formatShippedBaselineExclusions(baselines: ShippedBaselineExclusion[]) {
|
|
if (baselines.length === 0) {
|
|
return "";
|
|
}
|
|
const normalized = baselines
|
|
.map(({ ref, count, pullRequests }) => {
|
|
validateShippedBaselineRef(ref);
|
|
if (!Array.isArray(pullRequests)) {
|
|
fail(`missing shipped baseline PR inventory for ${ref}`);
|
|
}
|
|
const normalizedPullRequests = pullRequests.toSorted((a, b) => a - b);
|
|
if (
|
|
normalizedPullRequests.some((number) => !Number.isSafeInteger(number) || number < 1) ||
|
|
new Set(normalizedPullRequests).size !== normalizedPullRequests.length
|
|
) {
|
|
fail(`invalid shipped baseline PR inventory for ${ref}`);
|
|
}
|
|
if (!Number.isSafeInteger(count) || count < 0 || count !== normalizedPullRequests.length) {
|
|
fail(`invalid shipped baseline exclusion count for ${ref}: ${count}`);
|
|
}
|
|
return { ref, count, pullRequests: normalizedPullRequests };
|
|
})
|
|
.toSorted((a, b) => (a.ref === b.ref ? 0 : a.ref < b.ref ? -1 : 1));
|
|
const seen = new Set<string>();
|
|
for (const baseline of normalized) {
|
|
if (seen.has(baseline.ref)) {
|
|
fail(`duplicate shipped baseline exclusion: ${baseline.ref}`);
|
|
}
|
|
seen.add(baseline.ref);
|
|
}
|
|
return `${SHIPPED_BASELINE_EXCLUSIONS_PREFIX} ${normalized
|
|
.map(({ ref, count, pullRequests }) =>
|
|
count === 0
|
|
? `${ref} (0 PRs)`
|
|
: `${ref} (${count} PRs: ${pullRequests.map((number) => `#${number}`).join(", ")})`,
|
|
)
|
|
.join("; ")}.`;
|
|
}
|
|
|
|
export function parseShippedBaselineExclusions(section: string) {
|
|
const lines = section.split(/\r?\n/u).filter((line) => line.startsWith("Shipped baseline"));
|
|
if (lines.length === 0) {
|
|
return [];
|
|
}
|
|
if (lines.length > 1) {
|
|
fail("release contribution record contains multiple shipped baseline exclusion lines");
|
|
}
|
|
const [line] = lines;
|
|
if (!line) {
|
|
fail("release contribution record is missing shipped baseline exclusions");
|
|
}
|
|
const entries = line.match(/^Shipped baseline exclusions: (?<entries>.+)\.$/u)?.groups?.entries;
|
|
if (!entries) {
|
|
fail("release contribution record contains malformed shipped baseline exclusions");
|
|
}
|
|
const baselines = entries.split("; ").map((entry) => {
|
|
const item = entry.match(
|
|
/^(?<ref>\S+) \((?<count>0|[1-9][0-9]*) PRs(?:: (?<pullRequests>#[1-9][0-9]*(?:, #[1-9][0-9]*)*))?\)$/u,
|
|
);
|
|
const groups = item?.groups;
|
|
if (!groups?.ref || groups.count === undefined) {
|
|
fail(`release contribution record contains malformed shipped baseline exclusion: ${entry}`);
|
|
}
|
|
const count = Number(groups.count);
|
|
const pullRequests = groups.pullRequests
|
|
? groups.pullRequests.split(", ").map((number) => Number(number.slice(1)))
|
|
: [];
|
|
return { ref: groups.ref, count, pullRequests };
|
|
});
|
|
if (formatShippedBaselineExclusions(baselines) !== line) {
|
|
fail("release contribution record shipped baseline exclusions are not canonical");
|
|
}
|
|
return baselines;
|
|
}
|
|
|
|
export function dedicatedSectionVersionForTag(tag: unknown) {
|
|
// Correction (vX-N) and alpha tags may carry their own exact changelog
|
|
// heading; beta and stable bodies must come from the stable base section.
|
|
assertString(tag, "tag");
|
|
const taggedVersion = tag.replace(/^v/u, "");
|
|
if (/-beta\.[1-9][0-9]*$/u.test(taggedVersion)) {
|
|
return undefined;
|
|
}
|
|
return /-(?:alpha\.)?[1-9][0-9]*$/u.test(taggedVersion) ? taggedVersion : undefined;
|
|
}
|
|
|
|
export function releaseNotesSectionForTag(changelog: unknown, version: unknown, tag: unknown) {
|
|
// Alpha and correction tags prefer their own exact heading when the
|
|
// changelog carries one; otherwise they fall back to the base version.
|
|
assertString(tag, "tag");
|
|
assertString(version, "version");
|
|
const dedicatedVersion = dedicatedSectionVersionForTag(tag);
|
|
if (dedicatedVersion && dedicatedVersion !== version) {
|
|
try {
|
|
return extractChangelogSection(changelog, dedicatedVersion);
|
|
} catch {
|
|
// No dedicated section; use the base version below.
|
|
}
|
|
}
|
|
try {
|
|
return extractChangelogSection(changelog, version);
|
|
} catch (error) {
|
|
if (!/-alpha\.[1-9][0-9]*$/u.test(tag)) {
|
|
throw error;
|
|
}
|
|
return extractChangelogSection(changelog, "Unreleased").replace(
|
|
/^## Unreleased\r?$/mu,
|
|
`## ${version}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function loadReleaseNotesForTag({
|
|
rootDir,
|
|
ref,
|
|
tag,
|
|
version = releaseNotesVersionForTag(tag),
|
|
}: {
|
|
rootDir: string;
|
|
ref?: string;
|
|
tag: string;
|
|
version?: string;
|
|
}) {
|
|
validateTag(tag);
|
|
const dedicatedVersion = dedicatedSectionVersionForTag(tag);
|
|
const versions = [
|
|
...(dedicatedVersion ? [dedicatedVersion] : []),
|
|
version,
|
|
...(/-alpha\.[1-9][0-9]*$/u.test(tag) ? ["Unreleased"] : []),
|
|
];
|
|
for (const selectedVersion of new Set(versions)) {
|
|
const source = findReleaseChangelog({ rootDir, ref, version: selectedVersion });
|
|
if (source) {
|
|
if (source.format !== "initial") {
|
|
fail("docs-mirrored release notes require the docs-publication renderer");
|
|
}
|
|
return source;
|
|
}
|
|
}
|
|
throw new Error(`changelog does not contain release notes for ${tag}`);
|
|
}
|
|
|
|
export function renderGithubReleaseNotes({
|
|
changelog,
|
|
version,
|
|
tag,
|
|
repository,
|
|
regularStableVersion,
|
|
contributionRecordPath,
|
|
verification = "",
|
|
}: ReleaseNotesTarget & { verification?: string }) {
|
|
assertString(repository, "repository");
|
|
assertString(tag, "tag");
|
|
assertString(version, "version");
|
|
validateRepository(repository);
|
|
validateTag(tag);
|
|
const tagVersion = releaseNotesVersionForTag(tag);
|
|
if (tagVersion !== version) {
|
|
fail(`release tag ${tag} requires CHANGELOG.md version ${tagVersion}, got ${version}`);
|
|
}
|
|
assertString(changelog, "changelog");
|
|
if (changelogFormat(changelog) !== "initial") {
|
|
fail("docs-mirrored release notes require the docs-publication renderer");
|
|
}
|
|
const section = releaseNotesSectionForTag(changelog, version, tag);
|
|
const notice = extendedStableReleaseNotice({ version, repository, regularStableVersion });
|
|
const fullBody = joinBody(notice, section);
|
|
const mode = fitsGithubReleaseBody(fullBody) ? "full" : "compact";
|
|
const compactBody =
|
|
mode === "full"
|
|
? undefined
|
|
: compactReleaseNotes(section, repository, tag, contributionRecordPath)?.body;
|
|
const baseBody = mode === "full" ? fullBody : compactBody && joinBody(notice, compactBody);
|
|
if (baseBody === undefined) {
|
|
fail(
|
|
"release notes exceed GitHub's body limit and cannot be compacted without a complete contribution record",
|
|
);
|
|
}
|
|
if (!fitsGithubReleaseBody(baseBody)) {
|
|
const size = githubReleaseBodySize(baseBody);
|
|
fail(
|
|
`compacted release notes are still too large for GitHub: ${size.characters} characters, ${size.bytes} bytes`,
|
|
);
|
|
}
|
|
const normalizedVerification = normalizeTail(verification);
|
|
const bodyWithVerification = joinBody(baseBody, normalizedVerification);
|
|
const verificationIncluded =
|
|
normalizedVerification !== "" && fitsGithubReleaseBody(bodyWithVerification);
|
|
const body = verificationIncluded ? bodyWithVerification : baseBody;
|
|
return {
|
|
body,
|
|
mode,
|
|
size: githubReleaseBodySize(body),
|
|
verificationIncluded,
|
|
verificationOmitted: normalizedVerification !== "" && !verificationIncluded,
|
|
};
|
|
}
|
|
|
|
export function verifyGithubReleaseNotes({
|
|
body,
|
|
changelog,
|
|
version,
|
|
tag,
|
|
repository,
|
|
regularStableVersion,
|
|
contributionRecordPath,
|
|
}: ReleaseNotesTarget & { body: unknown }) {
|
|
assertString(body, "release body");
|
|
const normalizedBody = body.trimEnd();
|
|
const base = renderGithubReleaseNotes({
|
|
changelog,
|
|
version,
|
|
tag,
|
|
repository,
|
|
regularStableVersion,
|
|
contributionRecordPath,
|
|
});
|
|
if (normalizedBody === base.body) {
|
|
return {
|
|
...base,
|
|
matches: true,
|
|
actualSize: githubReleaseBodySize(normalizedBody),
|
|
};
|
|
}
|
|
const verificationPrefix = `${base.body}\n\n${RELEASE_VERIFICATION_HEADING}`;
|
|
const verification = normalizedBody.startsWith(verificationPrefix)
|
|
? normalizedBody.slice(base.body.length + 2)
|
|
: "";
|
|
const expected = verification
|
|
? renderGithubReleaseNotes({
|
|
changelog,
|
|
version,
|
|
tag,
|
|
repository,
|
|
regularStableVersion,
|
|
contributionRecordPath,
|
|
verification,
|
|
})
|
|
: base;
|
|
return {
|
|
...expected,
|
|
matches: normalizedBody === expected.body,
|
|
actualSize: githubReleaseBodySize(normalizedBody),
|
|
};
|
|
}
|
|
|
|
function usage() {
|
|
return `Usage:
|
|
node --import tsx scripts/render-github-release-notes.mts \\
|
|
(--root <repository-path> [--ref <ref>] | --changelog <legacy-file>) \\
|
|
--tag <tag> --repository <owner/repo> \\
|
|
[--version <version>] [--regular-stable-version <version>] \\
|
|
[--verification-file <path>] [--output <path>] \\
|
|
[--metadata-output <path>]
|
|
Verification uses the same target arguments with --verify-body <path>.
|
|
`;
|
|
}
|
|
|
|
function parseArgs(argv: string[]) {
|
|
const valueOptions = [
|
|
["--changelog", "changelog"],
|
|
["--root", "rootDir"],
|
|
["--ref", "ref"],
|
|
["--version", "version"],
|
|
["--tag", "tag"],
|
|
["--repository", "repository"],
|
|
["--regular-stable-version", "regularStableVersion"],
|
|
["--verification-file", "verificationFile"],
|
|
["--output", "output"],
|
|
["--metadata-output", "metadataOutput"],
|
|
["--verify-body", "verifyBody"],
|
|
] as const satisfies ReadonlyArray<readonly [string, string]>;
|
|
type ValueOption = (typeof valueOptions)[number][1];
|
|
const options: Partial<Record<ValueOption, string>> & { help?: true } = {};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--help" || arg === "-h") {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
const option = valueOptions.find(([flag]) => flag === arg);
|
|
if (option) {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
fail(`${arg} requires a value`);
|
|
}
|
|
options[option[1]] = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
fail(`unknown argument: ${arg}`);
|
|
}
|
|
if (!options.help) {
|
|
for (const name of ["tag", "repository"] as const) {
|
|
if (!options[name]) {
|
|
fail(`--${name} is required`);
|
|
}
|
|
}
|
|
if (Boolean(options.changelog) === Boolean(options.rootDir)) {
|
|
fail("exactly one of --root or --changelog is required");
|
|
}
|
|
if (options.ref && !options.rootDir) {
|
|
fail("--ref requires --root");
|
|
}
|
|
if (options.metadataOutput && !options.output) {
|
|
fail("--metadata-output requires --output");
|
|
}
|
|
if (
|
|
options.verifyBody &&
|
|
(options.output || options.metadataOutput || options.verificationFile)
|
|
) {
|
|
fail("--verify-body cannot be combined with rendering output or verification-file options");
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
process.stdout.write(usage());
|
|
return;
|
|
}
|
|
const { changelog: changelogPath, repository, tag } = options;
|
|
if (!repository || !tag) {
|
|
fail("release notes arguments were not validated");
|
|
}
|
|
const version = options.version ?? releaseNotesVersionForTag(tag);
|
|
const source = options.rootDir
|
|
? loadReleaseNotesForTag({ rootDir: options.rootDir, ref: options.ref, tag, version })
|
|
: undefined;
|
|
const changelog = source
|
|
? source.section
|
|
: changelogPath
|
|
? readFileSync(changelogPath, "utf8")
|
|
: fail("release notes source was not validated");
|
|
if (options.verifyBody) {
|
|
const result = verifyGithubReleaseNotes({
|
|
body: readFileSync(options.verifyBody, "utf8"),
|
|
changelog,
|
|
version,
|
|
tag,
|
|
repository,
|
|
regularStableVersion: options.regularStableVersion,
|
|
contributionRecordPath: source?.recordPath ?? undefined,
|
|
});
|
|
if (!result.matches) {
|
|
fail("Release body does not match canonical release notes.");
|
|
}
|
|
return;
|
|
}
|
|
const verification = options.verificationFile
|
|
? readFileSync(options.verificationFile, "utf8")
|
|
: "";
|
|
const rendered = renderGithubReleaseNotes({
|
|
changelog,
|
|
version,
|
|
tag,
|
|
repository,
|
|
regularStableVersion: options.regularStableVersion,
|
|
verification,
|
|
contributionRecordPath: source?.recordPath ?? undefined,
|
|
});
|
|
if (options.output) {
|
|
writeFileSync(options.output, rendered.body);
|
|
if (options.metadataOutput) {
|
|
const metadata = {
|
|
mode: rendered.mode,
|
|
size: rendered.size,
|
|
verificationIncluded: rendered.verificationIncluded,
|
|
verificationOmitted: rendered.verificationOmitted,
|
|
};
|
|
writeFileSync(options.metadataOutput, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
}
|
|
process.stderr.write(
|
|
`release-notes: ${rendered.mode} body, ${rendered.size.characters} characters, ${rendered.size.bytes} bytes${
|
|
rendered.verificationOmitted ? ", verification omitted at GitHub limit" : ""
|
|
}\n`,
|
|
);
|
|
return;
|
|
}
|
|
process.stdout.write(rendered.body);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
}
|
|
}
|