UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

357 lines (337 loc) • 17.3 kB
#!/usr/bin/env node // Hybrid changelog generator, wired into the release flow. // // node scripts/gen-changelog.mjs <version> // // It stamps a dated section for <version> at the top of CHANGELOG.md by: // 1. Promoting whatever you hand-wrote under "## [Unreleased]" VERBATIM // (your highlights — the rich prose we care about), then // 2. Appending anything in the git history since the last tag that your // highlights didn't already mention (deduped by PR number), grouped into // COMPONENT sections (MCP / RunPod image) and Keep-a-Changelog buckets // (Added / Fixed / Changed) from the conventional-commit type. // 3. Resetting "## [Unreleased]" to an empty stub. // // So nothing in the history is ever missed, and your hand-written notes are // never clobbered. Idempotent-ish: safe to re-run before the version is tagged. // // Repo config (COMPONENTS) below decides how a commit maps to a section — this // file is the comfyui-mcp variant (MCP + RunPod); the panel ships its own. import { readFileSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; // The repo this operates on. Derived from the script's own location so it works // from any cwd — with an explicit override so the generator can be exercised // against a scratch repo. Without one it can only be tested by running it on the // real CHANGELOG, which is how a range bug survived three releases: the only way // to check its output was to generate a release and read it. const ROOT = process.env.COMFYUI_MCP_CHANGELOG_ROOT ? process.env.COMFYUI_MCP_CHANGELOG_ROOT : join(dirname(fileURLToPath(import.meta.url)), ".."); const CHANGELOG = join(ROOT, "CHANGELOG.md"); // ── Repo config ───────────────────────────────────────────────────────────── // First matching component wins; the last (match:()=>true) is the fallback. const COMPONENTS = [ { name: "RunPod image", match: (scope) => /^runpod/.test(scope || "") }, { name: "MCP", match: () => true }, ]; // conventional-commit type → Keep-a-Changelog bucket. Types not listed are // dropped from the changelog (chore/ci/test/build/style/docs housekeeping). const TYPE_SECTION = { feat: "Added", fix: "Fixed", perf: "Changed", refactor: "Changed", revert: "Changed", }; const SECTION_ORDER = ["Added", "Fixed", "Changed"]; // ── helpers ────────────────────────────────────────────────────────────────── // stderr ignored: several queries (e.g. describe with no tags) fail by design and are try/caught. const git = (args) => execSync(`git ${args}`, { cwd: ROOT, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); /** The ref to diff against = the previous release. Prefer the most recent * version tag (mcp); fall back to the most recent release commit (the panel * has no per-release tags); else the first commit. */ function prevTag() { try { const t = git("describe --tags --abbrev=0"); if (t) return t; } catch { /* no tags */ } try { const sha = git('log -1 --pretty=format:%H --grep="^\\(chore(release)\\|release\\):"'); if (/^[0-9a-f]{7,40}$/.test(sha)) return sha; } catch { /* no release commit */ } return git("rev-list --max-parents=0 HEAD").split(/\s+/)[0]; // first commit } /** A release commit, in either shape we actually produce: `release: 0.49.6`, or the * squash-merge form GitHub writes from a PR titled with the bare version — `0.49.6 (#849)`. * Only the first was recognised, so the second fell through to the non-conventional * path and was silently dropped along with everything else there. */ // #1309 — three shapes are needed; see scripts/lib/release-subject.mjs. import { isReleaseSubject } from "./lib/release-subject.mjs"; /** Parsed commits since `range`, newest-first, minus noise. * * A NON-CONVENTIONAL subject that carries a PR number is INCLUDED, not skipped. The * old code dropped it on the assumption it was "usually already covered by a PR merge" * — but a squash merge's subject IS the PR title, and this project's PR titles are * descriptive prose ("download_model: large-file timeouts…"), not `fix(scope):`. So the * assumption inverted the outcome: the entries most likely to be dropped were the * substantial ones, because a big PR gets a written title and a small one gets a * conventional prefix. * * That silently omitted the headline entry from three consecutive releases — panel * 0.11.39 lost #621 (a CRITICAL wrong-graph fix), mcp 0.49.6 lost #831 (which closed * eleven issues), and panel 0.11.40 lost three of its four. Each was caught by hand * only because someone read the generated output against the commit list. * * Two rules now, and the second matters as much as the first: * 1. anything with a PR number is emitted (defaulting to "Changed" when the type is * unstated) — a human editing a slightly-noisy line is cheap; a missing headline * fix is not; * 2. NOTHING is dropped silently. Whatever is still skipped is reported to stderr, so * a release always shows what it chose to leave out. A generator whose output looks * complete when it is not is worse than no generator. */ function parseCommits(range) { const raw = git(`log ${range} --no-merges --pretty=format:%s`); if (!raw) return []; const out = []; const skipped = []; for (const subject of raw.split("\n")) { if (isReleaseSubject(subject)) continue; // release commits describe themselves const m = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/); // The LAST `(#N)`, not the first: GitHub appends the PR reference at the end of a // squash subject, so anything earlier is an ISSUE the author cited. Both shapes occur — // `fix(#809): … (#818)` puts the issue in the SCOPE, and // `test: … (#852) (#853)` puts it INLINE — and taking the first match attributes the // entry to the issue. (Reading only the description fixes the first shape and not the // second, which is why this reads positionally rather than by field.) // // Beyond a wrong link, this breaks the dedupe against hand-written highlights, which is // keyed on the PR number — so a hand-written entry would be DUPLICATED by the // auto-generated one instead of suppressed. That is the same class of failure as the // silent drop this file was just fixed for, in the opposite direction. const prIn = (s) => { const all = [...s.matchAll(/\(#(\d+)\)/g)]; return all.length ? all[all.length - 1][1] : null; }; if (!m) { // Non-conventional. Keep it if it names a PR; otherwise it is a local commit // that a squash will have superseded, and dropping it is right. const pr = prIn(subject); if (pr) { out.push({ type: "", scope: "", desc: subject.trim(), section: "Changed", pr }); } else { skipped.push(subject); } continue; } const [, type, scope, , desc] = m; const pr = prIn(desc); // An UNMAPPED type (chore/ci/test/build/style/docs) is housekeeping and stays out — // but only when it has no PR number. A `test(...)` or `docs(...)` PR is a real // shipped change; panel 0.11.40's de-flake landed that way. const section = TYPE_SECTION[type.toLowerCase()] ?? (pr ? "Changed" : null); if (!section) { skipped.push(subject); continue; } out.push({ type, scope: scope || "", desc: desc.trim(), section, pr }); } if (skipped.length) { process.stderr.write( `changelog: left out ${skipped.length} commit(s) with no PR number — check none belong:\n` + skipped.map((s) => ` · ${s}`).join("\n") + "\n", ); } return out; } function componentOf(scope) { return (COMPONENTS.find((c) => c.match(scope)) || COMPONENTS[COMPONENTS.length - 1]).name; } /** Build the auto-generated component/section body for commits not already * covered by the hand-written highlights (deduped by PR number). */ function autoBody(commits, coveredPRs) { const fresh = commits.filter((c) => !(c.pr && coveredPRs.has(c.pr))); if (fresh.length === 0) return ""; // component -> section -> bullets[] const byComp = new Map(); for (const c of fresh) { const comp = componentOf(c.scope); if (!byComp.has(comp)) byComp.set(comp, new Map()); const secs = byComp.get(comp); if (!secs.has(c.section)) secs.set(c.section, []); secs.get(c.section).push(`- ${c.desc}`); } // Single-component repos (e.g. the panel) read cleaner with flat `### Added` // headers; multi-component repos (mcp) nest `### Component` > `#### Added`. const single = COMPONENTS.length === 1; const lines = []; for (const comp of COMPONENTS.map((c) => c.name)) { const secs = byComp.get(comp); if (!secs) continue; if (!single) lines.push(`### ${comp}`, ""); for (const section of SECTION_ORDER) { const bullets = secs.get(section); if (!bullets) continue; lines.push(single ? `### ${section}` : `#### ${section}`, ...bullets, ""); } } return lines.join("\n").trimEnd(); } function today() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } // ── main ───────────────────────────────────────────────────────────────────── const backfill = process.argv.includes("--backfill"); let version = (process.argv.find((a) => /^v?\d+\.\d+\.\d+/.test(a)) || "").replace(/^v/, ""); // --from-pkg: read the version npm just wrote to package.json. The lifecycle // hook previously passed $npm_package_version, an sh-only expansion that is // silently EMPTY under cmd.exe — `npm version` then died mid-release on // Windows (files bumped, commit+tag never made). if (!version && process.argv.includes("--from-pkg")) { version = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")).version; } if (!backfill && !/^\d+\.\d+\.\d+/.test(version)) { console.error(`usage: node scripts/gen-changelog.mjs <version> | --backfill`); process.exit(1); } // Preserve the file's own line-ending convention (mcp is CRLF, panel LF): work // in LF internally, restore on write so we never spuriously rewrite the whole file. const rawMd = readFileSync(CHANGELOG, "utf-8"); const EOL = rawMd.includes("\r\n") ? "\r\n" : "\n"; const writeChangelog = (s) => writeFileSync(CHANGELOG, EOL === "\r\n" ? s.replace(/\n/g, "\r\n") : s); /** * Every PR number the CHANGELOG already documents (#988). * * Read from the file rather than from git, because the file is the record of * what has actually been ANNOUNCED — which is the question being asked. Tags * cannot answer it here: the release flow leaves them off the branch entirely. * * Deliberately scans the WHOLE file, not just the newest section. An * over-reaching range can reach back several releases (v0.50.5's reached to * v0.50.1, four sections back), and each of those releases already said its * piece. */ function alreadyDocumentedPRs() { return [...rawMd.matchAll(/\(#(\d+)\)/g)].map((m) => m[1]); } /** Build a dated entry string for `ver` from commits in `range`, folding in any * hand-written highlights (deduped by PR). */ function buildEntry(ver, range, highlights = "") { // #988 — dedupe against EVERY PR the changelog already documents, not just the // hand-written highlights for this release. // // The range this is given routinely reaches too far back. `describe --tags` // returns the newest tag REACHABLE from HEAD, and the release flow pushes the // tag from a local commit and then squash-merges the version bump onto // protected main — which writes a different sha, so a version tag is never an // ancestor of main. `describe` walks straight past it to the release before. // 0.50.3, 0.50.4 and 0.50.5 each generated a section listing every commit back // to v0.50.1, and each had to be trimmed by hand before the tag was pushed. // // A narrower RANGE was tried and rejected: bounding by the newest release // COMMIT on the branch under-includes instead, silently dropping anything // merged between the tag being cut and the reconcile PR landing (#976 fell in // exactly that window). Dropping an entry is worse than repeating one — a // duplicate is visible, a gap is not. // // Filtering by what is already WRITTEN has neither failure mode: an entry the // changelog already carries is a duplicate by definition, and one it does not // carry survives however wide the range was. The mechanism already existed // for the highlights; it was simply never pointed at the rest of the file. const documented = [...alreadyDocumentedPRs(), ...[...highlights.matchAll(/\(#(\d+)\)/g)].map((m) => m[1])]; const covered = new Set(documented); const commits = parseCommits(range); const auto = autoBody(commits, covered); const parts = [`## [${ver}] - ${today()}`, ""]; if (highlights) parts.push(highlights, ""); if (auto) parts.push(auto, ""); if (!highlights && !auto) parts.push("_No user-facing changes._", ""); return { text: parts.join("\n").trimEnd(), commits }; } const md = rawMd.replace(/\r\n/g, "\n"); // normalized (LF) for matching + building // Accept both "## [Unreleased]" (panel) and "## Unreleased" (mcp); preserve the // exact header text so we don't reformat the file's own convention. const UNREL = /(##[ \t]*(?:\[Unreleased\]|Unreleased))[ \t]*\n([\s\S]*?)(?=\n##[ \t]|\n<!-- end -->|$)/i; const um = md.match(UNREL); if (!um) { console.error("could not find an '## Unreleased' (or '## [Unreleased]') section in CHANGELOG.md"); process.exit(1); } const unrelHeader = um[1]; // e.g. "## Unreleased" or "## [Unreleased]" const highlights = um[2].trim(); if (backfill) { // One-time repair: emit a dated entry for every tag NEWER than the newest one // already in the CHANGELOG, oldest→newest, from the commits between tags. const tags = git("tag --sort=creatordate") .split("\n") .filter((t) => /^v?\d+\.\d+\.\d+$/.test(t)); // Only catch up the changelog from where it left off — the newest version it // already documents. Don't resurrect ancient pre-changelog tags. const cmp = (a, b) => { const pa = a.split("."), pb = b.split("."); for (let i = 0; i < 3; i++) if (+pa[i] !== +pb[i]) return +pa[i] - +pb[i]; return 0; }; const documented = [...md.matchAll(/##\s*\[(\d+\.\d+\.\d+)\]/g)].map((m) => m[1]); const newest = documented.sort(cmp).pop() || "0.0.0"; const missing = tags.filter( (t) => cmp(t.replace(/^v/, ""), newest) > 0 && !md.includes(`## [${t.replace(/^v/, "")}]`), ); if (missing.length === 0) { console.log("backfill: nothing missing."); process.exit(0); } const blocks = []; for (let i = 0; i < missing.length; i++) { const tag = missing[i]; const idx = tags.indexOf(tag); const prev = tags[idx - 1]; const range = prev ? `${prev}..${tag}` : tag; blocks.push(buildEntry(tag.replace(/^v/, ""), range).text); } // newest first under Unreleased const body = blocks.reverse().join("\n\n"); const next = md.replace(UNREL, `${unrelHeader}\n\n${body}\n\n`); writeChangelog(next); console.log(`backfill: added ${missing.length} missing version(s): ${missing.join(", ")}`); process.exit(0); } if (!version) { // REFRESH mode (no version arg): fold commits since the last tag into // [Unreleased] without stamping a version — keeps the changelog warm between // releases (e.g. after a runpod:release). Idempotent: items already present // (by PR number or exact text) are not re-added. const covered = new Set([...highlights.matchAll(/\(#(\d+)\)/g)].map((m) => m[1])); const commits = parseCommits(`${prevTag()}..HEAD`).filter( (c) => !(c.pr && covered.has(c.pr)) && !highlights.includes(c.desc), ); const auto = autoBody(commits, new Set()); if (!auto) { console.log("changelog: [Unreleased] already covers every commit since " + prevTag()); process.exit(0); } const body = [highlights, auto].filter(Boolean).join("\n\n"); writeChangelog(md.replace(UNREL, `${unrelHeader}\n\n${body}\n\n`)); console.log(`changelog: refreshed [Unreleased] with ${commits.length} new commit(s) since ${prevTag()}`); process.exit(0); } if (md.includes(`## [${version}]`)) { console.error(`CHANGELOG already has a [${version}] section — nothing to do.`); process.exit(0); } const { text: entry, commits } = buildEntry(version, `${prevTag()}..HEAD`, highlights); const next = md.replace(UNREL, `${unrelHeader}\n\n${entry}\n\n`); writeChangelog(next); const nComp = new Set(commits.map((c) => componentOf(c.scope))).size; console.log( `changelog: wrote [${version}] — ${highlights ? "kept hand-written highlights + " : ""}${ commits.length } commit(s) across ${nComp} component(s) since ${prevTag()}`, );