feat: changelog.md release notes, v tags, plugin release notes and videos

This commit is contained in:
LukeGus committed 2026-10-06 18:13:39 -05:00
1 parent b9dd3488a5
commit 9bc2be721b
45 files changed
+1462 -816

No files matched your search

+19
View File
@@ -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);
}
+14 -34
View File
@@ -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);
+37 -57
View File
@@ -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);
});
+37 -72
View File
@@ -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 };
+46
View File
@@ -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);
});
});
+1 -1
View File
@@ -254,7 +254,7 @@ const LOCAL_FILES = [
"locales",
"migrations",
"README.md",
"CHANGELOG.json",
"CHANGELOG.md",
];
/**
+24
View File
@@ -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 };
+155 -91
View File
@@ -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: [],
});
});
});
-121
View File
@@ -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);
});
}
-55
View File
@@ -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/);
});
});