mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: changelog.md release notes, v tags, plugin release notes and videos
This commit is contained in:
1 parent
b9dd3488a5
commit
9bc2be721b
45 files changed
+1462
-816
No files matched your search
@@ -0,0 +1,19 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const sdk = path.join(root, "packages/plugin-sdk/dist/changelog.js");
|
||||
|
||||
if (!fs.existsSync(sdk)) {
|
||||
console.error("check-changelog: build the SDK first (npm run build:sdk)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { parseChangelog } = require(sdk);
|
||||
const { problems } = parseChangelog(
|
||||
fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8"),
|
||||
);
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` CHANGELOG.md: ${problem}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { changelogSection } = require("./lib/changelog-section.cjs");
|
||||
|
||||
// Apple caps the "What's New in This Version" field at 4000 characters.
|
||||
const MAX_LENGTH = 4000;
|
||||
@@ -26,18 +27,6 @@ function fail(message) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function extractSection(notes, name, { required = true } = {}) {
|
||||
const pattern = new RegExp(
|
||||
`<!--\\s*${name}\\s*-->([\\s\\S]*?)<!--\\s*/${name}\\s*-->`,
|
||||
);
|
||||
const match = notes.match(pattern);
|
||||
if (!match) {
|
||||
if (required) fail(`missing <!-- ${name} --> section in release notes`);
|
||||
return "";
|
||||
}
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
// Strip markdown that reads badly as plain text in App Store Connect.
|
||||
function toPlainText(markdown) {
|
||||
return markdown
|
||||
@@ -50,7 +39,7 @@ function toPlainText(markdown) {
|
||||
text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
||||
text = text.replace(/`([^`]+)`/g, "$1");
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
|
||||
text = text.replace(/^#+\s*/, "");
|
||||
if (/^#+\s/.test(text)) text = `${text.replace(/^#+\s*/, "")}:`;
|
||||
return text;
|
||||
})
|
||||
.join("\n")
|
||||
@@ -78,28 +67,21 @@ function truncate(text, limit) {
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function buildNotes(notesFile) {
|
||||
const summary = extractSection(notesFile, "SUMMARY");
|
||||
const updateLog = extractSection(notesFile, "UPDATE_LOG", {
|
||||
required: false,
|
||||
});
|
||||
const bugFixes = extractSection(notesFile, "BUG_FIXES", { required: false });
|
||||
|
||||
const parts = [toPlainText(summary)];
|
||||
if (updateLog) parts.push("", "Update Log:", toPlainText(updateLog));
|
||||
if (bugFixes) parts.push("", "Bug Fixes:", toPlainText(bugFixes));
|
||||
|
||||
const body = parts
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
function buildNotes(changelog, version) {
|
||||
const notes = changelogSection(changelog, version);
|
||||
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||
const body = toPlainText(notes);
|
||||
if (!body) fail("release notes produced no text");
|
||||
return truncate(body, MAX_LENGTH);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const notesPath = args.notes || "RELEASE_NOTES.md";
|
||||
const changelogPath = args.changelog || "CHANGELOG.md";
|
||||
const version =
|
||||
typeof args.version === "string"
|
||||
? args.version
|
||||
: require(path.resolve("package.json")).version;
|
||||
const outDir = args["out-dir"];
|
||||
const locales = String(args.locales || "en-US")
|
||||
.split(",")
|
||||
@@ -109,12 +91,10 @@ function main() {
|
||||
if (!outDir || outDir === true) fail("--out-dir is required");
|
||||
if (locales.length === 0) fail("--locales resolved to no locales");
|
||||
|
||||
const resolvedNotes = path.resolve(notesPath);
|
||||
if (!fs.existsSync(resolvedNotes)) {
|
||||
fail(`release notes file not found: ${resolvedNotes}`);
|
||||
}
|
||||
const resolved = path.resolve(changelogPath);
|
||||
if (!fs.existsSync(resolved)) fail(`changelog not found: ${resolved}`);
|
||||
|
||||
const notes = buildNotes(fs.readFileSync(resolvedNotes, "utf8"));
|
||||
const notes = buildNotes(fs.readFileSync(resolved, "utf8"), version);
|
||||
|
||||
for (const locale of locales) {
|
||||
const localeDir = path.join(path.resolve(outDir), locale);
|
||||
|
||||
@@ -9,10 +9,20 @@ const {
|
||||
MAX_LENGTH,
|
||||
} = require("./generate-appstore-notes.cjs");
|
||||
|
||||
function notesFile(sections: Record<string, string>) {
|
||||
return Object.entries(sections)
|
||||
.map(([name, body]) => `<!-- ${name} -->\n${body}\n<!-- /${name} -->`)
|
||||
.join("\n\n");
|
||||
function changelog(body: string, version = "26.10.0") {
|
||||
return [
|
||||
"# Changelog",
|
||||
"",
|
||||
`## ${version}`,
|
||||
"",
|
||||
body,
|
||||
"",
|
||||
"## 26.9.0",
|
||||
"",
|
||||
"### Added",
|
||||
"- Older",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
describe("toPlainText", () => {
|
||||
@@ -36,6 +46,10 @@ describe("toPlainText", () => {
|
||||
expect(toPlainText("* One\n* Two")).toBe("- One\n- Two");
|
||||
});
|
||||
|
||||
it("turns headings into labels", () => {
|
||||
expect(toPlainText("### Added\n- One")).toBe("Added:\n- One");
|
||||
});
|
||||
|
||||
it("collapses runs of blank lines", () => {
|
||||
expect(toPlainText("- One\n\n\n\n- Two")).toBe("- One\n\n- Two");
|
||||
});
|
||||
@@ -52,74 +66,40 @@ describe("truncate", () => {
|
||||
});
|
||||
|
||||
it("drops a section header left with no bullets under it", () => {
|
||||
const result = truncate("- One\n\nBug Fixes:\n- Two", 18);
|
||||
const result = truncate("- One\n\nFixed:\n- Two", 14);
|
||||
expect(result).toBe("- One");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNotes", () => {
|
||||
it("includes the summary, update log, and bug fixes", () => {
|
||||
it("uses only the version's section", () => {
|
||||
const notes = buildNotes(
|
||||
notesFile({
|
||||
SUMMARY: "A big release.",
|
||||
UPDATE_LOG: "- Added a thing",
|
||||
BUG_FIXES: "- Fixed a thing",
|
||||
}),
|
||||
changelog("A summary.\n\n### Added\n- A thing\n\n### Fixed\n- A bug"),
|
||||
"26.10.0",
|
||||
);
|
||||
|
||||
expect(notes).toContain("A big release.");
|
||||
expect(notes).toContain("Update Log:");
|
||||
expect(notes).toContain("- Added a thing");
|
||||
expect(notes).toContain("Bug Fixes:");
|
||||
expect(notes).toContain("- Fixed a thing");
|
||||
expect(notes).toBe("A summary.\n\nAdded:\n- A thing\n\nFixed:\n- A bug");
|
||||
expect(notes).not.toContain("Older");
|
||||
});
|
||||
|
||||
it("omits optional sections that are absent", () => {
|
||||
const notes = buildNotes(notesFile({ SUMMARY: "Small release." }));
|
||||
|
||||
expect(notes).toBe("Small release.");
|
||||
expect(notes).not.toContain("Update Log:");
|
||||
expect(notes).not.toContain("Bug Fixes:");
|
||||
});
|
||||
|
||||
it("stays within the App Store character limit", () => {
|
||||
it("stays within the App Store limit and never ends mid-bullet", () => {
|
||||
const bullets = Array.from(
|
||||
{ length: 400 },
|
||||
(_, i) => `- Added feature number ${i}`,
|
||||
);
|
||||
const notes = buildNotes(
|
||||
notesFile({
|
||||
SUMMARY: "Big release.",
|
||||
UPDATE_LOG: Array.from(
|
||||
{ length: 400 },
|
||||
(_, i) => `- Added feature number ${i}`,
|
||||
).join("\n"),
|
||||
BUG_FIXES: Array.from(
|
||||
{ length: 400 },
|
||||
(_, i) => `- Fixed bug number ${i}`,
|
||||
).join("\n"),
|
||||
}),
|
||||
changelog(`### Added\n${bullets.join("\n")}`),
|
||||
"26.10.0",
|
||||
);
|
||||
|
||||
expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH);
|
||||
expect(notes.length).toBeGreaterThan(0);
|
||||
expect(notes.split("\n").at(-1)).toMatch(/^- Added feature number \d+$/);
|
||||
});
|
||||
|
||||
it("never ends mid-bullet when truncating", () => {
|
||||
const notes = buildNotes(
|
||||
notesFile({
|
||||
SUMMARY: "Big release.",
|
||||
UPDATE_LOG: Array.from(
|
||||
{ length: 400 },
|
||||
(_, i) => `- Added feature number ${i}`,
|
||||
).join("\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
const lines = notes.split("\n");
|
||||
expect(lines[lines.length - 1]).toMatch(/^- Added feature number \d+$/);
|
||||
});
|
||||
|
||||
it("produces non-empty notes for the real release notes file", () => {
|
||||
it("produces notes for the real changelog", () => {
|
||||
const fs = require("fs");
|
||||
const notes = buildNotes(fs.readFileSync("RELEASE_NOTES.md", "utf8"));
|
||||
|
||||
const notes = buildNotes(
|
||||
fs.readFileSync("CHANGELOG.md", "utf8"),
|
||||
require("../package.json").version,
|
||||
);
|
||||
expect(notes.length).toBeGreaterThan(0);
|
||||
expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { changelogSection } = require("./lib/changelog-section.cjs");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
@@ -24,37 +25,10 @@ function fail(message) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function extractSection(notes, name) {
|
||||
const pattern = new RegExp(
|
||||
`<!--\\s*${name}\\s*-->([\\s\\S]*?)<!--\\s*/${name}\\s*-->`,
|
||||
);
|
||||
const match = notes.match(pattern);
|
||||
if (!match) {
|
||||
fail(`missing <!-- ${name} --> section in release notes`);
|
||||
}
|
||||
const value = match[1].trim();
|
||||
if (!value) {
|
||||
fail(`empty <!-- ${name} --> section in release notes`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function youtubeId(raw) {
|
||||
const value = raw.trim();
|
||||
let match = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
|
||||
if (match) return match[1];
|
||||
match = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
|
||||
if (match) return match[1];
|
||||
match = value.match(/embed\/([A-Za-z0-9_-]+)/);
|
||||
if (match) return match[1];
|
||||
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
|
||||
fail(`could not parse a YouTube video id from "${value}"`);
|
||||
}
|
||||
|
||||
function buildTable(version, mobileVersion) {
|
||||
const tag = `release-${version}-tag`;
|
||||
function buildTable(version, mobileVersion, mobileTag) {
|
||||
const tag = `v${version}`;
|
||||
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
|
||||
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/release-${mobileVersion}-tag`;
|
||||
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `release-${mobileVersion}-tag`}`;
|
||||
|
||||
const win = (file) => `${base}/${file}`;
|
||||
const linux = (file) => `${base}/${file}`;
|
||||
@@ -71,58 +45,49 @@ function buildTable(version, mobileVersion) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const version = args.version;
|
||||
const mobileVersion = args["mobile-version"];
|
||||
const notesPath = args.notes || "RELEASE_NOTES.md";
|
||||
|
||||
if (!version || version === true) fail("--version is required");
|
||||
if (!mobileVersion || mobileVersion === true)
|
||||
fail("--mobile-version is required");
|
||||
|
||||
const resolvedNotes = path.resolve(notesPath);
|
||||
if (!fs.existsSync(resolvedNotes)) {
|
||||
fail(`release notes file not found: ${resolvedNotes}`);
|
||||
}
|
||||
|
||||
const notes = fs.readFileSync(resolvedNotes, "utf8");
|
||||
const summary = extractSection(notes, "SUMMARY");
|
||||
const youtube = extractSection(notes, "YOUTUBE");
|
||||
const updateLog = extractSection(notes, "UPDATE_LOG");
|
||||
const bugFixes = extractSection(notes, "BUG_FIXES");
|
||||
|
||||
const videoId = youtubeId(youtube);
|
||||
const embed = [
|
||||
`<a href="https://youtu.be/${videoId}">`,
|
||||
` <img src="./docs/repo-images/YouTube.png" alt="YouTube" width="500">`,
|
||||
`</a>`,
|
||||
].join("\n");
|
||||
|
||||
const table = buildTable(version, mobileVersion);
|
||||
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
||||
const notes = changelogSection(changelog, version);
|
||||
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||
|
||||
const donateAlert = [
|
||||
"> [!TIP]",
|
||||
"> Termix is free and always will be. If it's useful to you, consider [donating](https://donate.termix.site/donate/) to support development.",
|
||||
].join("\n");
|
||||
|
||||
const body = [
|
||||
return [
|
||||
donateAlert,
|
||||
"",
|
||||
summary,
|
||||
notes,
|
||||
"",
|
||||
embed,
|
||||
"### Downloads",
|
||||
"",
|
||||
table,
|
||||
"",
|
||||
"Update Log:",
|
||||
updateLog,
|
||||
"",
|
||||
"Bug Fixes:",
|
||||
bugFixes,
|
||||
buildTable(version, mobileVersion, mobileTag),
|
||||
].join("\n");
|
||||
|
||||
process.stdout.write(body + "\n");
|
||||
}
|
||||
|
||||
main();
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const version = args.version;
|
||||
const mobileVersion = args["mobile-version"];
|
||||
const mobileTag =
|
||||
typeof args["mobile-tag"] === "string" ? args["mobile-tag"] : undefined;
|
||||
const changelogPath = path.resolve(args.changelog || "CHANGELOG.md");
|
||||
|
||||
if (!version || version === true) fail("--version is required");
|
||||
if (!mobileVersion || mobileVersion === true)
|
||||
fail("--mobile-version is required");
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
fail(`changelog not found: ${changelogPath}`);
|
||||
}
|
||||
|
||||
const changelog = fs.readFileSync(changelogPath, "utf8");
|
||||
process.stdout.write(
|
||||
buildBody({ version, mobileVersion, mobileTag, changelog }) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { buildBody };
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildBody } = require("./generate-release-body.cjs");
|
||||
|
||||
const CHANGELOG = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 26.11.0",
|
||||
"",
|
||||
"### Fixed",
|
||||
"- A bug",
|
||||
"",
|
||||
"## 26.10.0",
|
||||
"",
|
||||
"### Added",
|
||||
"- Plugins",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("buildBody", () => {
|
||||
it("uses the version's changelog section and v tags for downloads", () => {
|
||||
const body = buildBody({
|
||||
version: "26.10.0",
|
||||
mobileVersion: "1.2.0",
|
||||
changelog: CHANGELOG,
|
||||
});
|
||||
expect(body).toContain("### Added\n- Plugins");
|
||||
expect(body).not.toContain("A bug");
|
||||
expect(body).toContain("/releases/download/v26.10.0/termix_");
|
||||
expect(body).toContain(
|
||||
"Mobile/releases/download/release-1.2.0-tag/termix_android.apk",
|
||||
);
|
||||
});
|
||||
|
||||
it("works for the real changelog", () => {
|
||||
const fs = require("fs");
|
||||
const body = buildBody({
|
||||
version: require("../package.json").version,
|
||||
mobileVersion: "1.0.0",
|
||||
changelog: fs.readFileSync("CHANGELOG.md", "utf8"),
|
||||
});
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -254,7 +254,7 @@ const LOCAL_FILES = [
|
||||
"locales",
|
||||
"migrations",
|
||||
"README.md",
|
||||
"CHANGELOG.json",
|
||||
"CHANGELOG.md",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The Markdown under one version's "## x.y.z" heading in CHANGELOG.md.
|
||||
* The release scripts run before the SDK is built, so this is a small copy
|
||||
* of changelogSection from packages/plugin-sdk/src/changelog.ts; lint checks
|
||||
* the whole file with the real parser.
|
||||
*/
|
||||
function changelogSection(markdown, version) {
|
||||
const lines = markdown.replace(/\r/g, "").split("\n");
|
||||
const heading =
|
||||
/^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+.+?)?\s*$/;
|
||||
let start = -1;
|
||||
let fence = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim().startsWith("```")) fence = !fence;
|
||||
if (fence || !/^##\s/.test(line)) continue;
|
||||
if (start >= 0) return lines.slice(start, i).join("\n").trim();
|
||||
const match = heading.exec(line.trim());
|
||||
if (match && match[1] === version) start = i + 1;
|
||||
}
|
||||
return start >= 0 ? lines.slice(start).join("\n").trim() : null;
|
||||
}
|
||||
|
||||
module.exports = { changelogSection };
|
||||
@@ -1,113 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateChangelog } from "../packages/plugin-sdk/cli/lib/changelog.mjs";
|
||||
import {
|
||||
changelogSection,
|
||||
parseChangelog,
|
||||
parseReleaseNotes,
|
||||
validateChangelog,
|
||||
} from "../packages/plugin-sdk/src/changelog.ts";
|
||||
|
||||
function release(version: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
version,
|
||||
date: "2026-10-01",
|
||||
changes: [{ type: "added", text: "Something new" }],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
const FILE = [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## Unreleased",
|
||||
"",
|
||||
"### Fixed",
|
||||
"- A bug not shipped yet",
|
||||
"",
|
||||
"## 1.1.0 - 2026-10-06",
|
||||
"",
|
||||
"Second release.",
|
||||
"",
|
||||
"### Added",
|
||||
"- Something new",
|
||||
" that wraps",
|
||||
"* Another thing",
|
||||
"",
|
||||
"### Fixed",
|
||||
"- Something broken",
|
||||
"",
|
||||
"## 1.0.0",
|
||||
"",
|
||||
"### Added",
|
||||
"- The first version",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("validateChangelog", () => {
|
||||
it("accepts a changelog whose newest release matches the manifest", () => {
|
||||
const changelog = {
|
||||
$schema: "https://example.com/changelog.schema.json",
|
||||
unreleased: [{ type: "fixed", text: "A bug" }],
|
||||
releases: [
|
||||
release("1.1.0", { notes: "Second release." }),
|
||||
release("1.0.0"),
|
||||
],
|
||||
};
|
||||
expect(validateChangelog(changelog, "1.1.0")).toEqual([]);
|
||||
describe("parseChangelog", () => {
|
||||
it("reads releases, summaries, sections and unreleased changes", () => {
|
||||
const { changelog, problems } = parseChangelog(FILE);
|
||||
expect(problems).toEqual([]);
|
||||
expect(changelog.unreleased).toEqual([
|
||||
{ type: "fixed", text: "A bug not shipped yet" },
|
||||
]);
|
||||
expect(changelog.releases).toEqual([
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2026-10-06",
|
||||
summary: "Second release.",
|
||||
changes: [
|
||||
{ type: "added", text: "Something new\nthat wraps" },
|
||||
{ type: "added", text: "Another thing" },
|
||||
{ type: "fixed", text: "Something broken" },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "1.0.0",
|
||||
changes: [{ type: "added", text: "The first version" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a release that has no date yet", () => {
|
||||
const { date: _date, ...undated } = release("1.0.0");
|
||||
expect(validateChangelog({ releases: [undated] }, "1.0.0")).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a manifest version with no release notes", () => {
|
||||
const problems = validateChangelog(
|
||||
{ releases: [release("1.0.0")] },
|
||||
"1.1.0",
|
||||
it("accepts Keep a Changelog brackets, CRLF and calendar versions", () => {
|
||||
const { changelog, problems } = parseChangelog(
|
||||
"## [26.10.0] - 2026-10-06\r\n\r\n### Changed\r\n- A thing\r\n",
|
||||
);
|
||||
expect(problems).toContain(
|
||||
"the newest release is 1.0.0, but manifest version is 1.1.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires releases newest first and without duplicates", () => {
|
||||
expect(
|
||||
validateChangelog(
|
||||
{ releases: [release("1.0.0"), release("1.2.0")] },
|
||||
"1.0.0",
|
||||
),
|
||||
).toContain("releases must be listed newest first");
|
||||
expect(
|
||||
validateChangelog(
|
||||
{ releases: [release("1.0.0"), release("1.0.0")] },
|
||||
"1.0.0",
|
||||
),
|
||||
).toContain("releases[1].version 1.0.0 is listed twice");
|
||||
expect(problems).toEqual([]);
|
||||
expect(changelog.releases[0]).toMatchObject({
|
||||
version: "26.10.0",
|
||||
date: "2026-10-06",
|
||||
});
|
||||
});
|
||||
|
||||
it("orders a prerelease below its release", () => {
|
||||
expect(
|
||||
validateChangelog(
|
||||
{ releases: [release("2.0.0"), release("2.0.0-beta.1")] },
|
||||
"2.0.0",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateChangelog(
|
||||
{ releases: [release("2.0.0-beta.1"), release("2.0.0")] },
|
||||
"2.0.0-beta.1",
|
||||
),
|
||||
).toContain("releases must be listed newest first");
|
||||
const md = (a: string, b: string) =>
|
||||
`## ${a}\n### Added\n- x\n## ${b}\n### Added\n- y\n`;
|
||||
expect(parseChangelog(md("2.0.0", "2.0.0-beta.1")).problems).toEqual([]);
|
||||
expect(parseChangelog(md("2.0.0-beta.1", "2.0.0")).problems).toContain(
|
||||
"releases must be listed newest first",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects bad versions, dates, change types and empty text", () => {
|
||||
const problems = validateChangelog(
|
||||
{
|
||||
releases: [
|
||||
{
|
||||
version: "v1",
|
||||
date: "October 1",
|
||||
changes: [{ type: "improved", text: " " }],
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
it("flags bad headings, dates, sections, stray text and duplicates", () => {
|
||||
const { problems } = parseChangelog(
|
||||
[
|
||||
"## v1",
|
||||
"## 1.0.0 - October 1",
|
||||
"### Improved",
|
||||
"- x",
|
||||
"### Added",
|
||||
"loose text",
|
||||
"- y",
|
||||
"## 1.0.0",
|
||||
"### Added",
|
||||
"- z",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(problems).toEqual(
|
||||
expect.arrayContaining([
|
||||
"releases[0].version must be a version like 1.2.3",
|
||||
"releases[0].date must be a date like 2026-10-01",
|
||||
expect.stringContaining("releases[0].changes[0].type must be one of"),
|
||||
"releases[0].changes[0].text must be a non-empty string",
|
||||
'line 1: "## v1" should look like "## 1.2.0 - 2026-10-06"',
|
||||
'1.0.0 has a date "October 1", use YYYY-MM-DD',
|
||||
expect.stringContaining('unknown section "Improved"'),
|
||||
expect.stringContaining("that is not a list item: loose text"),
|
||||
"1.0.0 is listed twice",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown keys, empty change lists and missing releases", () => {
|
||||
expect(validateChangelog({ releases: [] }, "1.0.0")).toEqual([
|
||||
"releases must be a non-empty array",
|
||||
]);
|
||||
expect(
|
||||
validateChangelog(
|
||||
{ extra: true, releases: [release("1.0.0", { changes: [] })] },
|
||||
"1.0.0",
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'CHANGELOG.json has unknown key "extra"',
|
||||
"releases[0].changes must list at least one change",
|
||||
]),
|
||||
it("flags a release with no changes and Unreleased out of place", () => {
|
||||
const { problems } = parseChangelog(
|
||||
"## 1.0.0\n\nJust a summary.\n\n## Unreleased\n### Added\n- x\n",
|
||||
);
|
||||
expect(validateChangelog([], "1.0.0")).toEqual([
|
||||
"CHANGELOG.json must be an object",
|
||||
expect(problems).toContain("1.0.0 has no changes listed");
|
||||
expect(problems).toContain('"## Unreleased" has to come first');
|
||||
});
|
||||
|
||||
it("ignores headings inside code fences", () => {
|
||||
const { changelog, problems } = parseChangelog(
|
||||
"## 1.0.0\n\n```\n## not a heading\n```\n\n### Added\n- x\n",
|
||||
);
|
||||
expect(problems).toEqual([]);
|
||||
expect(changelog.releases).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateChangelog", () => {
|
||||
it("passes when the newest release is the manifest version", () => {
|
||||
expect(validateChangelog(FILE, "1.1.0")).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a manifest version with no notes", () => {
|
||||
expect(validateChangelog(FILE, "1.2.0")).toContain(
|
||||
"the newest release is 1.1.0, but the manifest version is 1.2.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a file with no releases", () => {
|
||||
expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([
|
||||
'no releases found, add a "## 1.0.0" heading',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changelogSection", () => {
|
||||
it("returns the Markdown under a version's heading", () => {
|
||||
expect(changelogSection(FILE, "1.1.0")).toBe(
|
||||
[
|
||||
"Second release.",
|
||||
"",
|
||||
"### Added",
|
||||
"- Something new",
|
||||
" that wraps",
|
||||
"* Another thing",
|
||||
"",
|
||||
"### Fixed",
|
||||
"- Something broken",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(changelogSection(FILE, "1.0.0")).toBe(
|
||||
"### Added\n- The first version",
|
||||
);
|
||||
});
|
||||
|
||||
it("is null for a version that is not there", () => {
|
||||
expect(changelogSection(FILE, "9.9.9")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReleaseNotes", () => {
|
||||
it("parses a section body on its own", () => {
|
||||
expect(
|
||||
parseReleaseNotes("Summary.\n\n### Security\n- Patched a hole"),
|
||||
).toEqual({
|
||||
summary: "Summary.",
|
||||
changes: [{ type: "security", text: "Patched a hole" }],
|
||||
problems: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function extractYoutubeId(notes) {
|
||||
const match = notes.match(
|
||||
/<!--\s*YOUTUBE\s*-->([\s\S]*?)<!--\s*\/YOUTUBE\s*-->/,
|
||||
);
|
||||
if (!match || !match[1].trim()) {
|
||||
throw new Error(
|
||||
"missing or empty <!-- YOUTUBE --> section in release notes",
|
||||
);
|
||||
}
|
||||
return parseYoutubeId(match[1].trim());
|
||||
}
|
||||
|
||||
function parseYoutubeId(raw) {
|
||||
const value = raw.trim();
|
||||
let m = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
m = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
m = value.match(/embed\/([A-Za-z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
|
||||
throw new Error(`could not parse a YouTube video id from "${value}"`);
|
||||
}
|
||||
|
||||
async function getAccessToken({ clientId, clientSecret, refreshToken }) {
|
||||
const res = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`token exchange failed (${res.status}): ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.access_token;
|
||||
}
|
||||
|
||||
async function getVideoStatus(accessToken, videoId) {
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
if (!res.ok)
|
||||
throw new Error(`videos.list failed (${res.status}): ${await res.text()}`);
|
||||
const json = await res.json();
|
||||
return json.items?.[0]?.status?.privacyStatus ?? null;
|
||||
}
|
||||
|
||||
async function setVideoPublic(accessToken, videoId) {
|
||||
const status = await getVideoStatus(accessToken, videoId);
|
||||
if (status === "public") {
|
||||
console.log(`Video ${videoId} is already public, skipping.`);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(
|
||||
"https://www.googleapis.com/youtube/v3/videos?part=status",
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: videoId,
|
||||
status: { privacyStatus: "public" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`videos.update failed (${res.status}): ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const clientId = process.env.YOUTUBE_CLIENT_ID;
|
||||
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET;
|
||||
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN;
|
||||
|
||||
if (!clientId || !clientSecret || !refreshToken) {
|
||||
throw new Error(
|
||||
"YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, and YOUTUBE_REFRESH_TOKEN are required",
|
||||
);
|
||||
}
|
||||
|
||||
const notesPath = path.resolve(
|
||||
process.env.RELEASE_NOTES || "RELEASE_NOTES.md",
|
||||
);
|
||||
const notes = fs.readFileSync(notesPath, "utf8");
|
||||
const videoId = extractYoutubeId(notes);
|
||||
|
||||
const accessToken = await getAccessToken({
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
});
|
||||
await setVideoPublic(accessToken, videoId);
|
||||
|
||||
console.log(`Set YouTube video ${videoId} to public.`);
|
||||
}
|
||||
|
||||
module.exports = { extractYoutubeId, parseYoutubeId };
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error(`publish-youtube: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { extractYoutubeId, parseYoutubeId } = require("./publish-youtube.cjs");
|
||||
|
||||
describe("parseYoutubeId", () => {
|
||||
it("parses a youtu.be short link", () => {
|
||||
expect(parseYoutubeId("https://youtu.be/At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
|
||||
});
|
||||
|
||||
it("parses a watch?v= link", () => {
|
||||
expect(parseYoutubeId("https://www.youtube.com/watch?v=At8iDk6-Q_s")).toBe(
|
||||
"At8iDk6-Q_s",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses an embed link", () => {
|
||||
expect(parseYoutubeId("https://www.youtube.com/embed/At8iDk6-Q_s")).toBe(
|
||||
"At8iDk6-Q_s",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a bare id", () => {
|
||||
expect(parseYoutubeId("At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
|
||||
});
|
||||
|
||||
it("throws on garbage", () => {
|
||||
expect(() => parseYoutubeId("https://example.com/")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractYoutubeId", () => {
|
||||
it("pulls the id from a release notes YOUTUBE section", () => {
|
||||
const notes = [
|
||||
"<!-- SUMMARY -->",
|
||||
"stuff",
|
||||
"<!-- /SUMMARY -->",
|
||||
"<!-- YOUTUBE -->",
|
||||
"https://youtu.be/At8iDk6-Q_s",
|
||||
"<!-- /YOUTUBE -->",
|
||||
].join("\n");
|
||||
expect(extractYoutubeId(notes)).toBe("At8iDk6-Q_s");
|
||||
});
|
||||
|
||||
it("throws when the YOUTUBE section is missing", () => {
|
||||
expect(() => extractYoutubeId("no section here")).toThrow(/YOUTUBE/);
|
||||
});
|
||||
|
||||
it("throws when the YOUTUBE section is empty", () => {
|
||||
expect(() =>
|
||||
extractYoutubeId("<!-- YOUTUBE -->\n\n<!-- /YOUTUBE -->"),
|
||||
).toThrow(/empty/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user