mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
Every release entry in CHANGELOG.md now leads with highlights: an unheaded list above the first `###`. The app's What's new sheet, the website changelog and the GitHub release body show the highlights and fold the sections, the full record, behind one control. An entry without highlights keeps its sections in view. docs/release.md carries the writing rules. Installed apps fetch CHANGELOG.md from main and parse it with the parser they shipped with, so the two heading rules are a compatibility surface: `##` starts a release, `###` starts a section, and headings inside fenced code are text. The app, website and release-script parsers now split an entry the same way and each runs the shared cases in scripts/test-support/changelog-structure.json. The release-script tests, which no CI job ran before, are part of "Validate CI contracts". Adds the 0.16.0-beta.1 entry (en and zh-CN) ahead of the release.
229 lines
6.7 KiB
JavaScript
229 lines
6.7 KiB
JavaScript
import { execFileSync as nodeExecFileSync } from "node:child_process";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { parseChangelogEntries, splitChangelogHighlights } from "./changelog-utils.mjs";
|
|
import { getGitHubRelease } from "./github-release.mjs";
|
|
import {
|
|
getReleaseInfoFromSourceTag,
|
|
normalizeReleaseTag,
|
|
parseReleaseVersion,
|
|
} from "./release-version-utils.mjs";
|
|
import { isMainModule } from "./is-main-module.mjs";
|
|
|
|
function usageAndExit(code = 1) {
|
|
const usage = `
|
|
Usage: node scripts/sync-release-notes-from-changelog.mjs [options]
|
|
|
|
Options:
|
|
--repo <owner/repo> Repository slug. Defaults to $GITHUB_REPOSITORY.
|
|
--tag <tag> Release tag (e.g. v0.1.14). Defaults to latest changelog entry.
|
|
--create-if-missing Create a draft release if it does not already exist.
|
|
`;
|
|
process.stderr.write(usage.trimStart());
|
|
process.stderr.write("\n");
|
|
process.exit(code);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
repo: process.env.GITHUB_REPOSITORY || "",
|
|
tag: "",
|
|
createIfMissing: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--repo") {
|
|
const value = argv[index + 1];
|
|
if (!value) {
|
|
usageAndExit();
|
|
}
|
|
args.repo = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--tag") {
|
|
const value = argv[index + 1];
|
|
if (!value) {
|
|
usageAndExit();
|
|
}
|
|
args.tag = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === "--create-if-missing") {
|
|
args.createIfMissing = true;
|
|
continue;
|
|
}
|
|
usageAndExit();
|
|
}
|
|
|
|
if (!args.repo) {
|
|
throw new Error("Missing repository. Pass --repo or set GITHUB_REPOSITORY.");
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function parseChangelog(changelogText) {
|
|
return parseChangelogEntries(changelogText).map((entry) =>
|
|
Object.assign({}, entry, {
|
|
tag: `v${entry.version}`,
|
|
notes: formatReleaseNotes(entry),
|
|
}),
|
|
);
|
|
}
|
|
|
|
// A release body leads with the entry's highlights and folds its sections, the
|
|
// full record, into one collapsed block. GitHub renders `<details>` in release
|
|
// bodies; CHANGELOG.md itself stays plain Markdown because the app and the
|
|
// website render it without HTML. An entry without highlights keeps its sections
|
|
// in view, since nothing stands in for them.
|
|
function formatReleaseNotes(entry) {
|
|
const { highlightLines, sectionLines } = splitChangelogHighlights(entry.bodyLines);
|
|
const notesParts = [`## ${entry.version} - ${entry.date}`];
|
|
if (highlightLines.length > 0 && sectionLines.length > 0) {
|
|
notesParts.push(
|
|
"",
|
|
...highlightLines,
|
|
"",
|
|
"<details>",
|
|
"<summary>All changes</summary>",
|
|
"",
|
|
...sectionLines,
|
|
"",
|
|
"</details>",
|
|
);
|
|
} else if (entry.bodyLines.length > 0) {
|
|
notesParts.push("", ...entry.bodyLines);
|
|
}
|
|
return `${notesParts.join("\n").trim()}\n`;
|
|
}
|
|
|
|
function runGh(args, execFileSync = nodeExecFileSync) {
|
|
execFileSync("gh", args, { stdio: "inherit" });
|
|
}
|
|
|
|
function updateReleaseNotes({ releaseId, repo, notesPath }, execFileSync = nodeExecFileSync) {
|
|
runGh(
|
|
["api", "-X", "PATCH", `repos/${repo}/releases/${releaseId}`, "-F", `body=@${notesPath}`],
|
|
execFileSync,
|
|
);
|
|
}
|
|
|
|
function exposeGitHubContributorMentions(notes) {
|
|
return notes.replace(
|
|
/\[@([A-Za-z0-9-]+)\]\(https:\/\/github\.com\/([A-Za-z0-9-]+)\/?\)/g,
|
|
(match, labelLogin, profileLogin) => {
|
|
if (labelLogin.toLowerCase() !== profileLogin.toLowerCase()) {
|
|
return match;
|
|
}
|
|
|
|
return `@${profileLogin}`;
|
|
},
|
|
);
|
|
}
|
|
|
|
export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
|
|
const execFileSync = deps.execFileSync ?? nodeExecFileSync;
|
|
const sleepSync =
|
|
deps.sleepSync ??
|
|
((milliseconds) => {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
});
|
|
const args = parseArgs(argv);
|
|
const changelogPath = path.resolve("CHANGELOG.md");
|
|
const changelogText = readFileSync(changelogPath, "utf8");
|
|
const entries = parseChangelog(changelogText);
|
|
|
|
const targetTag = args.tag ? normalizeReleaseTag(args.tag) : entries[0].tag;
|
|
const releaseInfo = getReleaseInfoFromSourceTag(targetTag);
|
|
const targetEntry = entries.find((entry) => entry.tag === targetTag);
|
|
|
|
let notes = targetEntry?.notes ?? null;
|
|
|
|
if (!notes) {
|
|
console.log(`No matching changelog section found for ${targetTag}. Skipping.`);
|
|
return;
|
|
}
|
|
|
|
notes = exposeGitHubContributorMentions(notes);
|
|
|
|
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-"));
|
|
const notesPath = path.join(tempDir, `${targetTag}-notes.md`);
|
|
writeFileSync(notesPath, notes);
|
|
|
|
const createArgs = [
|
|
"release",
|
|
"create",
|
|
targetTag,
|
|
"--repo",
|
|
args.repo,
|
|
"--title",
|
|
`Paseo ${targetTag}`,
|
|
"--notes-file",
|
|
notesPath,
|
|
"--verify-tag",
|
|
"--draft",
|
|
...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []),
|
|
];
|
|
|
|
try {
|
|
const release = getGitHubRelease(args.repo, targetTag, execFileSync);
|
|
if (release) {
|
|
updateReleaseNotes({ releaseId: release.id, repo: args.repo, notesPath }, execFileSync);
|
|
console.log(`Updated release notes for ${targetTag}.`);
|
|
return;
|
|
}
|
|
|
|
if (!args.createIfMissing) {
|
|
console.log(
|
|
`Release ${targetTag} not found. Skipping because --create-if-missing was not provided.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
runGh(createArgs, execFileSync);
|
|
} catch (createError) {
|
|
console.warn(
|
|
`Release creation failed for ${targetTag}; attempting edit in case another workflow created it concurrently.`,
|
|
);
|
|
const raceRelease = getGitHubRelease(args.repo, targetTag, execFileSync);
|
|
if (!raceRelease) {
|
|
throw createError;
|
|
}
|
|
updateReleaseNotes({ releaseId: raceRelease.id, repo: args.repo, notesPath }, execFileSync);
|
|
console.log(`Updated release notes for ${targetTag} after create race.`);
|
|
|
|
if (createError instanceof Error) {
|
|
console.warn(createError.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const createdRelease = getGitHubRelease(args.repo, targetTag, execFileSync);
|
|
if (createdRelease) {
|
|
updateReleaseNotes(
|
|
{ releaseId: createdRelease.id, repo: args.repo, notesPath },
|
|
execFileSync,
|
|
);
|
|
console.log(`Created release ${targetTag} with changelog notes.`);
|
|
return;
|
|
}
|
|
sleepSync(1_000);
|
|
}
|
|
throw new Error(`Created release ${targetTag} could not be resolved.`);
|
|
} finally {
|
|
rmSync(tempDir, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
syncReleaseNotes();
|
|
}
|