mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
release-posts.js - Reads whole changelog groups (bold heading + wrapped bullets), not just the first line of each bullet. - X: link counted as 23 chars, replies packed to fit 280, markdown stripped. - Discord: full bullets when they fit 2000 chars, short ones otherwise; Reddit: real markdown sections instead of a code block. - Says "pre-release" when GitHub does (gh), --prerelease/--stable to override; --only and --out to pick a platform and write files. - make release-post calls it directly; the .sh wrapper is gone. Removed (outdated) - ps5-install-trace.py, ps5-install-watch.py, fpkg-mount-test.py: drove /api/pkg/install/start and the dpi-* routes, which no longer exist. - gather-fakelibs.py: superseded by the app's Backport scan. - pull_ps5_debug.py: pulled a debug dir the helper no longer writes to. hw-test-loop.sh ran cargo from the repo root (no workspace there); it now points at engine/Cargo.toml. README: a Highlights section up top (unified install, Convert to FPKG, sleep and wake), Android/web in the platforms badge, tested firmware 5.10 & 9.60. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
10 KiB
JavaScript
Executable File
273 lines
10 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
|
|
* node scripts/release-posts.js # version from VERSION
|
|
* node scripts/release-posts.js 5.35.0
|
|
* node scripts/release-posts.js --only discord # one platform
|
|
* node scripts/release-posts.js --out /tmp/posts # also write x.txt, discord.md, reddit.md
|
|
* node scripts/release-posts.js --prerelease | --stable # override detection
|
|
* node scripts/release-posts.js --url https://… # override the release link
|
|
*
|
|
* 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 REPO = "phantomptr/ps5upload";
|
|
const DISCORD_INVITE = "https://discord.gg/fzK3xddtrM";
|
|
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");
|
|
|
|
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");
|
|
|
|
/** One line for short formats: the bold lead, or the first sentence. */
|
|
function shortItem(it) {
|
|
const s = stripMd(it.lead).trim();
|
|
const trim = (t) => t.replace(/[.,:;]$/, "");
|
|
if (s !== stripMd(it.text).trim()) return trim(s);
|
|
return trim(s.split(/(?<=[.!?])\s/)[0]);
|
|
}
|
|
|
|
const groups = parseGroups(readSection());
|
|
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!`;
|
|
const main = [lead, headline, releaseUrl].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: `Bugs, questions, ideas: ${DISCORD_INVITE}`, 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 footer = [
|
|
"",
|
|
`**Download:** ${releaseUrl}`,
|
|
`**Docker (web UI):** \`${DOCKER_IMAGE}:${version}\``,
|
|
`**Help & bug reports:** ${DISCORD_INVITE}`,
|
|
];
|
|
if (prerelease) {
|
|
footer.unshift("", "_Pre-release: in Settings → Updates, turn on “Get pre-release versions” to be offered it._");
|
|
}
|
|
const render = (full) => {
|
|
const out = [`@everyone`, `## PS5Upload ${tag}${channel} is out`];
|
|
for (const g of groups) {
|
|
out.push("");
|
|
if (g.heading) out.push(`**${stripMd(g.heading)}**`);
|
|
for (const it of g.items) {
|
|
out.push(full ? `- ${it.text}` : `- ${shortItem(it)}`);
|
|
}
|
|
}
|
|
return [...out, ...footer].join("\n");
|
|
};
|
|
// Full bullets when they fit Discord's 2000-character limit, short ones when not.
|
|
const full = render(true);
|
|
return full.length <= DISCORD_LIMIT ? full : render(false);
|
|
}
|
|
|
|
// ── Reddit ──────────────────────────────────────────────────────────────────
|
|
|
|
function buildReddit() {
|
|
const title = `PS5Upload ${tag}${channel} released${headline ? ` — ${headline}` : ""}`;
|
|
const body = [`**PS5Upload ${tag}** is out${prerelease ? " as a pre-release" : ""}.`];
|
|
for (const g of groups) {
|
|
body.push("");
|
|
if (g.heading) body.push(`### ${stripMd(g.heading)}`, "");
|
|
for (const it of g.items) body.push(`- ${it.text}`);
|
|
}
|
|
body.push(
|
|
"",
|
|
`**Download:** ${releaseUrl}`,
|
|
"",
|
|
`**Docker (web UI):** \`${DOCKER_IMAGE}:${version}\``,
|
|
"",
|
|
`**Discord (help, bugs, feature requests):** ${DISCORD_INVITE}`,
|
|
);
|
|
return { title, 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"));
|
|
|
|
if (outDir) {
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
const files = {
|
|
x: ["x.txt", [x.main, ...x.replies].join("\n\n---\n\n")],
|
|
discord: ["discord.md", discord],
|
|
reddit: ["reddit.md", `${reddit.title}\n\n${reddit.body}`],
|
|
};
|
|
for (const k of chosen) {
|
|
const [name, text] = files[k];
|
|
fs.writeFileSync(path.join(outDir, name), `${text}\n`);
|
|
}
|
|
console.error(`\nWrote ${chosen.map((k) => files[k][0]).join(", ")} to ${outDir}`);
|
|
}
|