Files
phantomptrandClaude Opus 5.5 69b15b56a7 Scripts: name post-images.js with its extension, so the script audit sees it used
The audit counts a script as used when its file name appears elsewhere;
require("./post-images") left the .js off, and CI's repo-scripts job
failed with post-images.js as unreferenced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
2026-10-10 23:44:20 -07:00

373 lines
15 KiB
JavaScript
Executable File

#!/usr/bin/env node
/*
* Release announcement drafts for X, Discord and Reddit, built from the
* version's section of CHANGELOG.md.
*
* Usage
* make release-post # = node scripts/release-posts.js
* node scripts/release-posts.js # version from VERSION
* node scripts/release-posts.js 5.35.0
* node scripts/release-posts.js --only discord # print one platform
* node scripts/release-posts.js --out /tmp/posts # write there instead
* node scripts/release-posts.js --prerelease | --stable # override detection
* node scripts/release-posts.js --url https://… # override the release link
*
* Writes one file per thing you paste to `.release_posts/v<version>/` (git-ignored), shaped
* for each platform:
* X plain text, no markdown; 280 characters with every link counted as 23. A short
* main post with the link (X shows the release as a card), then a reply thread.
* One file per post: x-1-main.txt, x-2-reply.txt, …
* Discord markdown: ## heading, bullets, **bold**, `-#` small print, [named links](<url>)
* (the <> stops Discord from embedding each link). 2000 characters at most.
* Reddit the title and the body are separate boxes: reddit-title.txt, reddit-body.md
* (markdown, ### headings, named links).
* plus README.md: what to paste where, with each post's length.
*
* The changelog section is read as groups: a bold paragraph line
* (`**One install path for everything.**`) starts a group, and each `- `
* bullet under it — including its wrapped continuation lines — is one item.
* An item's bold lead (`- **Stream install.** Longer text…`) is its short form.
*
* Pre-release status comes from `gh release view` when gh is available, so
* the posts say "pre-release" exactly when GitHub does; pass --prerelease or
* --stable to decide it yourself.
*/
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const { captureSite, copyImages } = require("./post-images.js");
const REPO = "phantomptr/ps5upload";
const DISCORD_INVITE = "https://discord.gg/fzK3xddtrM";
const ISSUES_URL = `https://github.com/${REPO}/issues`;
const COFFEE_URL = "https://ko-fi.com/B0B81S0WUA"; // client/src/lib/supportLinks.ts
const SITE_URL = "https://ps5upload.phantomptr.com/"; // site/ on GitHub Pages
const AUTHOR_URL = "https://phantomptr.com/";
const DOCKER_IMAGE = "ghcr.io/phantomptr/ps5upload-engine-webui";
const X_LIMIT = 280;
const X_URL_LEN = 23; // X counts every link as 23 characters
const DISCORD_LIMIT = 2000;
const repoRoot = path.resolve(__dirname, "..");
const args = process.argv.slice(2);
const flag = (name) => args.includes(name);
const option = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : null;
};
const positional = args.filter(
(a, i) => !a.startsWith("--") && !["--url", "--only", "--out"].includes(args[i - 1]),
);
const version =
positional[0] || fs.readFileSync(path.join(repoRoot, "VERSION"), "utf8").trim();
const tag = `v${version}`;
const releaseUrl = option("--url") || `https://github.com/${REPO}/releases/tag/${tag}`;
const only = option("--only");
const outDir = option("--out") || path.join(repoRoot, ".release_posts", tag);
function detectPrerelease() {
if (flag("--prerelease")) return true;
if (flag("--stable")) return false;
try {
const out = execFileSync(
"gh",
["release", "view", tag, "--repo", REPO, "--json", "isPrerelease", "--jq", ".isPrerelease"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
);
return out.trim() === "true";
} catch {
return false; // no gh, no release yet: say nothing about it
}
}
const prerelease = detectPrerelease();
// ── Parse the changelog section ─────────────────────────────────────────────
function readSection() {
const changelog = fs.readFileSync(path.join(repoRoot, "CHANGELOG.md"), "utf8");
const lines = changelog.split("\n");
const start = lines.findIndex((l) => l.trim() === `## ${version}`);
if (start < 0) {
console.error(`No "## ${version}" section in CHANGELOG.md`);
process.exit(1);
}
const body = [];
for (const line of lines.slice(start + 1)) {
if (line.startsWith("## ") || line.trim() === "---") break;
body.push(line);
}
return body;
}
/** [{ heading, items: [{ lead, text }] }] */
function parseGroups(body) {
const groups = [];
let group = null;
let item = null;
const ensureGroup = () => {
if (!group) {
group = { heading: null, items: [] };
groups.push(group);
}
};
for (const raw of body) {
const line = raw.trim();
if (!line) {
item = null;
continue;
}
if (line.startsWith("- ")) {
ensureGroup();
item = { text: line.slice(2) };
group.items.push(item);
} else if (item && raw.startsWith(" ")) {
item.text += ` ${line}`; // wrapped continuation of the bullet
} else if (/^\*\*.+\*\*$/.test(line)) {
group = { heading: line.slice(2, -2), items: [] };
groups.push(group);
item = null;
}
}
for (const g of groups) {
for (const it of g.items) {
const m = it.text.match(/^\*\*(.+?)\*\*\s*(.*)$/);
it.lead = m ? m[1] : it.text;
it.rest = m ? m[2] : "";
}
}
return groups.filter((g) => g.items.length > 0);
}
const stripMd = (s) =>
s
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
/** Shortens `s` to at most `max` characters: at the last comma that leaves at least half of
* it (a list reads fine cut there), else at a word boundary with "…". */
function clip(s, max) {
if (s.length <= max) return s;
const cut = s.slice(0, max - 1);
const comma = cut.lastIndexOf(", ");
if (comma >= max / 2) return cut.slice(0, comma);
return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max / 2)).replace(/[,;:\s]+$/, "")}…`;
}
/** One line for short formats. A bold lead that is a sentence ("**Faster installs.** …")
* stands alone; a label ("**Convert:** AMPR LZ4 packs, …") keeps the first clause after it
* so the line still says something. Otherwise the first sentence. */
function shortItem(it, max = 64) {
const lead = stripMd(it.lead).trim();
const rest = stripMd(it.rest || "").trim();
if (lead !== stripMd(it.text).trim()) {
// A label that is already a phrase ("Collection scans show their progress:") stands alone;
// a short one ("Convert:") needs the clause after it to say anything.
if (/:$/.test(lead) && rest && lead.split(/\s+/).length < 4) {
const clause = rest.split(/[;(]|,\s(?=and |or )|\.\s/)[0].trim().replace(/[.,;:]$/, "");
return clip(`${lead} ${clause}`, max);
}
return clip(lead.replace(/[.,:;]$/, ""), max);
}
return clip(lead.split(/(?<=[.!?])\s/)[0].replace(/[.,:;]$/, ""), max);
}
const sectionLines = readSection();
const groups = parseGroups(sectionLines);
/** The section's opening paragraph (the line before the bullets), if there is one. */
const summary = (() => {
const para = [];
for (const l of sectionLines) {
if (l.trim().startsWith("- ") || /^\*\*.+\*\*$/.test(l.trim()) || l.startsWith("#")) break;
if (l.trim()) para.push(l.trim());
else if (para.length) break;
}
return para.length ? stripMd(para.join(" ")) : null;
})();
if (groups.length === 0) {
console.error(`The ${version} section has no bullet points to announce.`);
process.exit(1);
}
const headline = groups[0].heading ? stripMd(groups[0].heading).replace(/\.$/, "") : null;
const channel = prerelease ? " (pre-release)" : "";
// ── X ───────────────────────────────────────────────────────────────────────
/** Length as X counts it: every URL is 23 characters. */
const xLength = (s) => s.replace(/https?:\/\/\S+/g, "x".repeat(X_URL_LEN)).length;
function buildX() {
const lead = `PS5Upload ${tag}${channel} is out 🎮`;
// The changelog's opening line is the best one-sentence summary there is.
const intro = summary ? clip(summary, 160) : headline;
// Without a summary to open with, the post points at the thread below it instead.
const main = [lead, intro, `Download: ${SITE_URL}`, "#PS5 #PS5Homebrew", intro ? null : "What's new 👇"]
.filter(Boolean)
.join("\n\n");
// Pack every group heading and item into as few replies as fit, without
// splitting a group heading from its first item.
const lines = [];
for (const g of groups) {
const items = g.items.map((it) => {
const line = `• ${shortItem(it)}`;
return xLength(line) > X_LIMIT ? `${line.slice(0, X_LIMIT - 1)}…` : line;
});
// The first heading is already the main post's headline.
if (g.heading && g !== groups[0]) lines.push({ text: stripMd(g.heading), heading: true });
for (const text of items) lines.push({ text, heading: false });
}
lines.push({ text: `Full notes: ${releaseUrl}\nHelp & ideas: ${DISCORD_INVITE}\nBugs: ${ISSUES_URL}\n☕ ${COFFEE_URL}`, heading: false });
const replies = [];
let current = [];
const fits = (extra) => xLength([...current, ...extra].join("\n")) <= X_LIMIT;
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
// A heading only goes where its first item also fits.
const block = l.heading && lines[i + 1] ? [l.text, lines[i + 1].text] : [l.text];
if (current.length > 0 && !fits(block)) {
replies.push(current.join("\n"));
current = [];
}
current.push(...block);
if (block.length === 2) i++;
}
if (current.length > 0) replies.push(current.join("\n"));
return { main, replies };
}
// ── Discord ─────────────────────────────────────────────────────────────────
function buildDiscord() {
const links = [
`**[Download ${tag}](<${SITE_URL}>)**`,
`[Release notes](<${releaseUrl}>)`,
`[Report a bug](<${ISSUES_URL}>)`,
`Docker web UI: \`${DOCKER_IMAGE}:${version}\``,
`☕ [Buy me a coffee](<${COFFEE_URL}>)`,
].join(" · ");
const render = (full, ping) => {
const out = [];
if (ping) out.push("@everyone");
out.push(`## 🎮 PS5Upload ${tag}${channel} is out`);
if (summary) out.push(summary);
for (const g of groups) {
out.push("");
if (g.heading) out.push(`### ${stripMd(g.heading)}`);
for (const it of g.items) {
if (full) {
out.push(`- ${it.text}`);
continue;
}
// Bold only the label: "**Convert:** AMPR LZ4 asset packs…".
const line = shortItem(it);
const lead = stripMd(it.lead).trim();
const head = /:$/.test(lead) ? lead : null;
out.push(head && line.startsWith(head) ? `- **${head}**${line.slice(head.length)}` : `- **${line}**`);
}
}
out.push("", links);
if (prerelease) {
out.push("-# Pre-release: in Settings → Updates, turn on “Get pre-release versions” to be offered it.");
}
out.push("-# Questions and ideas: ask here.");
return out.join("\n");
};
// Full bullets when they fit Discord's 2000-character limit, short ones when not.
const full = render(true, true);
return full.length <= DISCORD_LIMIT ? full : render(false, true);
}
// ── Reddit ──────────────────────────────────────────────────────────────────
function buildReddit() {
const firstChange = groups[0]?.items[0] ? shortItem(groups[0].items[0]).replace(/:.*$/, "") : null;
const lede = headline || (summary ? clip(summary.replace(/\.$/, ""), 120) : firstChange);
const title = `PS5Upload ${tag}${channel} released${lede ? ` — ${lede}` : ""}`;
const body = [`**PS5Upload ${tag}** is out${prerelease ? " as a pre-release" : ""}.${summary ? ` ${summary}` : ""}`, "", "### What's new", ""];
for (const g of groups) {
if (g.heading) body.push("", `#### ${stripMd(g.heading)}`, "");
for (const it of g.items) body.push(`- ${it.text}`);
}
body.push(
"",
"### Links",
"",
`- **Download:** [ps5upload.phantomptr.com](${SITE_URL}) (picks the right file for your system)`,
`- **Release notes:** [${tag} on GitHub](${releaseUrl})`,
`- **Docker (web UI):** \`${DOCKER_IMAGE}:${version}\``,
`- **Bugs:** [open an issue](${ISSUES_URL})`,
`- **Help and ideas:** [Discord](${DISCORD_INVITE})`,
"",
`PS5Upload is free. If it saves you time, [a coffee](${COFFEE_URL}) keeps it going ☕`,
);
return { title: title.slice(0, 300), body: body.join("\n") };
}
// ── Output ──────────────────────────────────────────────────────────────────
const x = buildX();
const discord = buildDiscord();
const reddit = buildReddit();
const sections = {
x: () => {
const lines = [`=== X — main post (${xLength(x.main)}/${X_LIMIT}) ===`, x.main];
x.replies.forEach((r, i) =>
lines.push("", `--- reply ${i + 1} (${xLength(r)}/${X_LIMIT}) ---`, r),
);
return lines.join("\n");
},
discord: () => `=== Discord (${discord.length}/${DISCORD_LIMIT}) ===\n${discord}`,
reddit: () => `=== Reddit ===\nTitle: ${reddit.title}\n\n${reddit.body}`,
};
if (only && !sections[only]) {
console.error(`--only must be one of: ${Object.keys(sections).join(", ")}`);
process.exit(2);
}
const chosen = only ? [only] : Object.keys(sections);
console.log(chosen.map((k) => sections[k]()).join("\n\n"));
fs.mkdirSync(outDir, { recursive: true });
for (const f of fs.readdirSync(outDir)) fs.rmSync(path.join(outDir, f), { recursive: true }); // only this version's files
const files = [];
const write = (name, text) => {
fs.writeFileSync(path.join(outDir, name), `${text}\n`);
files.push(name);
};
const xPosts = [x.main, ...x.replies];
xPosts.forEach((t, i) => write(`x-${i + 1}-${i === 0 ? "main" : "reply"}.txt`, t));
write("discord.md", discord);
write("reddit-title.txt", reddit.title);
write("reddit-body.md", reddit.body);
// The website shows this release: its hero carries the version and the download, its News
// section these notes. Taken fresh when Playwright is available.
captureSite();
const images = copyImages(path.join(outDir, "images"), ["site-hero-dark", "site-news-dark", "site-hero-light"]);
write(
"README.md",
[
`# PS5Upload ${tag} release posts`,
"",
"Copy each file's contents into the place named. Nothing here is committed (.release_posts is git-ignored).",
"",
"## X",
...xPosts.map((t, i) =>
`- \`x-${i + 1}-${i === 0 ? "main" : "reply"}.txt\` — ${i === 0 ? `the post${images.length ? " (attach images/site-hero-dark.png)" : ""}` : `reply ${i} (reply to the previous post)`}, ${xLength(t)}/${X_LIMIT} characters`,
),
"",
"## Discord",
`- \`discord.md\` — the announcement, ${discord.length}/${DISCORD_LIMIT} characters. Remove the @everyone line to post without a ping.${images.length ? " Attach images/site-news-dark.png." : ""}`,
"",
"## Reddit",
"- `reddit-title.txt` — the Title box",
"- `reddit-body.md` — the body (switch the editor to Markdown first)",
"",
...(images.length
? ["## Images (from the website)", ...images.map((n) => `- \`images/${n}\``), ""]
: []),
].join("\n"),
);
console.error(`\nWrote ${files.length} files to ${path.relative(repoRoot, outDir) || outDir}`);