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.
291 lines
8.6 KiB
JavaScript
291 lines
8.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { getGitHubRelease, getReleaseLookupTag } from "./github-release.mjs";
|
|
import { syncReleaseNotes } from "./sync-release-notes-from-changelog.mjs";
|
|
|
|
function withTempChangelog(fn, changelogText = "## 0.1.60-beta.1 - 2026-04-20\n\n- Beta notes.\n") {
|
|
const previousCwd = process.cwd();
|
|
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-test-"));
|
|
process.chdir(tempDir);
|
|
writeFileSync("CHANGELOG.md", changelogText);
|
|
|
|
try {
|
|
fn();
|
|
} finally {
|
|
process.chdir(previousCwd);
|
|
rmSync(tempDir, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function notFoundError() {
|
|
return Object.assign(new Error("release not found"), { stderr: "gh: Not Found (HTTP 404)" });
|
|
}
|
|
|
|
test("uses the untagged URL slug for draft release CLI operations", () => {
|
|
assert.equal(
|
|
getReleaseLookupTag({
|
|
draft: true,
|
|
html_url: "https://github.com/getpaseo/paseo/releases/tag/untagged-draft",
|
|
tag_name: "v0.1.60-beta.1",
|
|
}),
|
|
"untagged-draft",
|
|
);
|
|
});
|
|
|
|
test("does not treat GitHub authentication failures as missing releases", () => {
|
|
const calls = [];
|
|
const authError = Object.assign(new Error("authentication failed"), {
|
|
stderr: "gh: HTTP 401: Bad credentials",
|
|
});
|
|
assert.throws(
|
|
() =>
|
|
getGitHubRelease("getpaseo/paseo", "v0.1.60-beta.1", (command, args) => {
|
|
calls.push({ args, command });
|
|
throw authError;
|
|
}),
|
|
authError,
|
|
);
|
|
assert.equal(calls.length, 1, "authentication failures must not fall back to draft lookup");
|
|
});
|
|
|
|
test("updates an existing release body through the release id API", () => {
|
|
withTempChangelog(() => {
|
|
const calls = [];
|
|
|
|
const execFileSync = (command, args, options) => {
|
|
calls.push({ args, command, options });
|
|
|
|
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
|
|
return JSON.stringify({ id: 311163621, draft: false });
|
|
}
|
|
|
|
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
|
|
const notesArg = args.find((arg) => arg.startsWith("body=@"));
|
|
assert.ok(notesArg, "PATCH should send the notes body from a file");
|
|
const notesPath = notesArg.slice("body=@".length);
|
|
assert.match(notesPath, /v0\.1\.60-beta\.1-notes\.md$/);
|
|
return "";
|
|
}
|
|
|
|
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
|
|
};
|
|
|
|
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1"], {
|
|
execFileSync,
|
|
});
|
|
|
|
assert.equal(
|
|
calls.some((call) => call.args[0] === "release" && call.args[1] === "edit"),
|
|
false,
|
|
"retagged releases should not use gh release edit because it can resend tag_name",
|
|
);
|
|
assert.equal(
|
|
calls.some(
|
|
(call) =>
|
|
call.args[0] === "api" &&
|
|
call.args[1] === "-X" &&
|
|
call.args[2] === "PATCH" &&
|
|
call.args[3] === "repos/getpaseo/paseo/releases/311163621",
|
|
),
|
|
true,
|
|
"existing releases should be patched by release id",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("updates a draft release body without publishing it", () => {
|
|
withTempChangelog(() => {
|
|
const calls = [];
|
|
|
|
const execFileSync = (command, args, options) => {
|
|
calls.push({ args, command, options });
|
|
|
|
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
|
|
throw notFoundError();
|
|
}
|
|
|
|
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases?per_page=100") {
|
|
return JSON.stringify([
|
|
{
|
|
id: 311163621,
|
|
draft: true,
|
|
name: "Paseo v0.1.60-beta.1",
|
|
tag_name: "untagged-draft",
|
|
html_url: "https://github.com/getpaseo/paseo/releases/tag/untagged-draft",
|
|
},
|
|
]);
|
|
}
|
|
|
|
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
|
|
return "[]";
|
|
}
|
|
|
|
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
|
|
};
|
|
|
|
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1"], {
|
|
execFileSync,
|
|
});
|
|
|
|
const updateCall = calls.find(
|
|
(call) => call.args[0] === "api" && call.args[1] === "-X" && call.args[2] === "PATCH",
|
|
);
|
|
assert.ok(updateCall, "the draft release should be updated");
|
|
assert.deepEqual(
|
|
updateCall.args.filter((arg) => arg.startsWith("draft=")),
|
|
[],
|
|
"updating notes must not change draft visibility",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("creates missing beta releases as drafts", () => {
|
|
withTempChangelog(() => {
|
|
const calls = [];
|
|
let created = false;
|
|
|
|
const execFileSync = (command, args, options) => {
|
|
calls.push({ args, command, options });
|
|
|
|
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
|
|
throw notFoundError();
|
|
}
|
|
|
|
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases?per_page=100") {
|
|
return created
|
|
? JSON.stringify([
|
|
{
|
|
id: 311163621,
|
|
draft: true,
|
|
name: "Paseo v0.1.60-beta.1",
|
|
tag_name: "v0.1.60-beta.1",
|
|
},
|
|
])
|
|
: "[]";
|
|
}
|
|
|
|
if (args[0] === "release" && args[1] === "create") {
|
|
created = true;
|
|
return "";
|
|
}
|
|
|
|
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
|
|
return "";
|
|
}
|
|
|
|
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
|
|
};
|
|
|
|
syncReleaseNotes(
|
|
["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1", "--create-if-missing"],
|
|
{ execFileSync },
|
|
);
|
|
|
|
const createCall = calls.find(
|
|
(call) => call.args[0] === "release" && call.args[1] === "create",
|
|
);
|
|
assert.ok(createCall, "the missing release should be created");
|
|
assert.equal(createCall.args.includes("--draft"), true);
|
|
assert.equal(createCall.args.includes("--prerelease"), true);
|
|
});
|
|
});
|
|
|
|
// Syncs the v0.1.60-beta.1 entry of `changelogText` and returns the body GitHub would receive.
|
|
function syncedReleaseBody(changelogText) {
|
|
let syncedNotes = "";
|
|
withTempChangelog(() => {
|
|
const execFileSync = (command, args) => {
|
|
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
|
|
return JSON.stringify({ id: 311163621, draft: false });
|
|
}
|
|
|
|
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
|
|
const notesArg = args.find((arg) => arg.startsWith("body=@"));
|
|
assert.ok(notesArg, "PATCH should send the notes body from a file");
|
|
syncedNotes = readFileSync(notesArg.slice("body=@".length), "utf8");
|
|
return "";
|
|
}
|
|
|
|
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
|
|
};
|
|
|
|
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1"], {
|
|
execFileSync,
|
|
});
|
|
}, changelogText);
|
|
return syncedNotes;
|
|
}
|
|
|
|
test("converts contributor profile links to mentions in synced release notes", () => {
|
|
const syncedNotes = syncedReleaseBody(
|
|
[
|
|
"## 0.1.60-beta.1 - 2026-04-20",
|
|
"",
|
|
"- Beta notes. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
assert.match(syncedNotes, /by @therainisme\)/);
|
|
assert.doesNotMatch(syncedNotes, /\[@therainisme\]\(https:\/\/github\.com\/therainisme\)/);
|
|
});
|
|
|
|
test("leads with the highlights and folds the sections into one collapsed block", () => {
|
|
const syncedNotes = syncedReleaseBody(
|
|
[
|
|
"## 0.1.60-beta.1 - 2026-04-20",
|
|
"",
|
|
"- **Faster diffs:** Diffs load sooner.",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Added a picker",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Fixed a crash",
|
|
"",
|
|
"## 0.1.59 - 2026-04-10",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Fixed an older thing",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
assert.equal(
|
|
syncedNotes,
|
|
[
|
|
"## 0.1.60-beta.1 - 2026-04-20",
|
|
"",
|
|
"- **Faster diffs:** Diffs load sooner.",
|
|
"",
|
|
"<details>",
|
|
"<summary>All changes</summary>",
|
|
"",
|
|
"### Added",
|
|
"",
|
|
"- Added a picker",
|
|
"",
|
|
"### Fixed",
|
|
"",
|
|
"- Fixed a crash",
|
|
"",
|
|
"</details>",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
test("keeps the sections in view when an entry has no highlights", () => {
|
|
const syncedNotes = syncedReleaseBody(
|
|
"## 0.1.60-beta.1 - 2026-04-20\n\n### Fixed\n\n- Fixed a crash\n",
|
|
);
|
|
|
|
assert.equal(syncedNotes, "## 0.1.60-beta.1 - 2026-04-20\n\n### Fixed\n\n- Fixed a crash\n");
|
|
});
|