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),
686 lines (643 loc) • 30.5 kB
JavaScript
/**
* Verify the release section that is about to ship — or that just did.
*
* node scripts/check-changelog.mjs [version] [--ref <git-ref>]
*
* WHY THIS EXISTS (#2407). gen-changelog.mjs is a generator; nothing verified its
* output. On one evening three cuts went wrong the same way, and every one of them
* was invisible:
*
* 0.52.133 shipped #2378 and did not list it (hand-corrected by #2384)
* 0.52.134 cut to carry a fix that had ALREADY shipped — no code changes at all
* 0.52.138 shipped #2400 and did not list it (hand-corrected by #2406)
*
* All three are one race: a PR merges between the release branch being cut and the
* release PR merging. Ancestry then resolves itself — the branch merges into main,
* so the tag gets the code — which is exactly why nobody notices. The CHANGELOG is
* the only thing that gaps, and it gaps silently. 0.52.134 is the instructive one:
* reading a notes gap as "the fix has not shipped yet" produced a whole no-op
* version, the opposite error from the same root cause.
*
* TWO DIRECTIONS, and the second is the one that catches the above:
*
* A. Everything CITED must be REACHABLE. A `## [X]` entry naming PR #N must have
* a commit carrying #N that is an ancestor of vX. Ported from the panel
* (comfyui-mcp-panel#1894); catches an entry credited to a release that does
* not contain it.
* B. Everything REACHABLE must be CITED. Every commit in `v<prev>..vX` that names
* a PR must be mentioned in [X]. The panel does not implement this half. A
* MISSING entry is a different failure from a wrong one, and it is the one
* that shipped three times.
*
* Plus the structural checks a hand-edited section needs: no version with two
* sections, no heading repeated inside one, no PR credited twice.
*
* The audit is a pure function with `isAncestor` and both commit lists injected, so
* it is tested against synthetic histories rather than against this repo's own
* CHANGELOG — a guard whose only test subject is the file it guards can only be
* exercised by breaking a release.
*/
import { existsSync, readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { isReleaseSubject } from "./lib/release-subject.mjs";
import {
ambiguousReferences,
canonicalReference,
commitReferences,
isReleaseMerge,
mentionedNumbers,
mergeReferences,
referenceAliases,
referenceNumbers,
} from "./lib/changelog-refs.mjs";
const SCRIPT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
// COMFYUI_MCP_CHANGELOG_ROOT is the override gen-changelog.mjs already takes, for
// the same reason it takes one: without it the only way to exercise this is to cut
// a release and read the result.
const ROOT = resolve(
process.env.COMFYUI_MCP_CHANGELOG_ROOT || process.env.CHANGELOG_ROOT || SCRIPT_ROOT,
);
const CHANGELOG = join(ROOT, "CHANGELOG.md");
const gitAt = (root, ...gitArgs) =>
execFileSync("git", ["-C", root, ...gitArgs], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const git = (...gitArgs) => gitAt(ROOT, ...gitArgs);
// ── parsing ──────────────────────────────────────────────────────────────────
/** `## [X] - date` sections, with the body lines that follow each. */
export function parseReleaseSections(markdown) {
// mcp's CHANGELOG.md is CRLF and the panel's is LF. Normalise, or a heading
// regex anchored with $ silently matches nothing in one of the two repos.
const lines = String(markdown ?? "").replace(/\r\n/g, "\n").split("\n");
const releases = [];
let current = null;
for (let index = 0; index < lines.length; index += 1) {
const match = /^##\s+\[([^\]]+)\](?:\s*-\s*(\S+))?/.exec(lines[index]);
if (!match) continue;
if (current) current.lines = lines.slice(current.start + 1, index);
current = { version: match[1].trim(), date: match[2] ?? null, start: index, lines: [] };
releases.push(current);
}
if (current) current.lines = lines.slice(current.start + 1);
return releases;
}
/** Headings and bullet entries inside one release body. */
export function parseReleaseBody(lines) {
const headings = [];
const entries = [];
let section = null;
let entry = null;
// A heading's identity is its PATH, not its text. gen-changelog nests
// `### <component>` > `#### <bucket>` for this repo's two components, so a
// release touching both emits `#### Fixed` twice — once under `### MCP`, once
// under `### RunPod image`. That is correct generated output, and keying
// uniqueness on level+text alone rejected it: [0.50.37] is a real shipped
// section that fails that way. Refusing a valid release is the direction that
// blocks a good publish, so siblings are told apart by their parents.
const openHeadings = [];
const flush = () => {
if (!entry) return;
entries.push({ ...entry, text: entry.lines.join("\n").trim() });
entry = null;
};
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const heading = /^(#{3,6})\s+(.+?)\s*$/.exec(line);
if (heading) {
flush();
const level = heading[1].length;
while (openHeadings.length && openHeadings.at(-1).level >= level) openHeadings.pop();
const path = [...openHeadings, { level, text: heading[2] }]
.map((item) => `${item.level}:${item.text.toLowerCase()}`)
.join(" > ");
section = { level, text: heading[2], line: index + 1, path };
openHeadings.push(section);
headings.push(section);
continue;
}
if (/^[-*]\s+/.test(line)) {
flush();
entry = { section, line: index + 1, lines: [line.replace(/^[-*]\s+/, "")] };
continue;
}
// A continuation line is blank or indented; anything else ends the entry.
if (entry && (line.trim() === "" || /^\s+\S/.test(line))) entry.lines.push(line.trim());
else if (entry) flush();
}
flush();
return { headings, entries };
}
/** `<sha>\x1f<subject>\x1e…` from git log, which cannot be split on newlines. */
export function parseCommitSubjects(output) {
return String(output ?? "")
.split("\x1e")
.map((record) => record.trim())
.filter(Boolean)
.map((record) => {
const separator = record.indexOf("\x1f");
const sha = separator >= 0 ? record.slice(0, separator) : "";
const subject = separator >= 0 ? record.slice(separator + 1) : record;
return { sha, subject, refs: commitReferences(subject) };
});
}
const semverIdentifier = "(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)";
const strictSemver = new RegExp(
`^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-${semverIdentifier}(?:\\.${semverIdentifier})*)?$`,
);
export const releaseVersion = (version) => String(version ?? "").replace(/^v/, "");
export const isStrictSemver = (version) => strictSemver.test(releaseVersion(version));
// ── the audit ────────────────────────────────────────────────────────────────
/**
* @param markdown CHANGELOG.md contents to audit
* @param version which release section to audit
* @param commits every commit reachable from targetRef (alias + ancestry pool)
* @param rangeCommits commits in previousRef..targetRef with no merges (coverage
* pool), or null when the range could not be resolved
* @param targetRef the ref this section claims to describe
* @param previousRef the ref the release is measured from, for the message
* @param historyComplete false when `commits` is truncated or unreadable, which
* makes reachability unanswerable rather than false
* @param isAncestor (sha, ref) => boolean
*/
export function auditReleaseSection({
markdown,
version,
commits = [],
rangeCommits = null,
targetRef,
previousRef,
historyComplete = true,
isAncestor = () => true,
}) {
const normalizedVersion = releaseVersion(version);
const sections = parseReleaseSections(markdown);
const violations = [];
// One version, one section. A hand-edited reconcile has produced two before.
const seenReleaseSections = new Map();
for (const item of sections) {
const itemVersion = releaseVersion(item.version);
if (!isStrictSemver(itemVersion)) continue;
const line = item.start + 1;
if (seenReleaseSections.has(itemVersion)) {
violations.push(
`CHANGELOG.md repeats the [${itemVersion}] release section at lines ` +
`${seenReleaseSections.get(itemVersion)} and ${line}. Keep one top-level section.`,
);
} else {
seenReleaseSections.set(itemVersion, line);
}
}
const section = sections.find((item) => releaseVersion(item.version) === normalizedVersion);
if (!section) {
return [...violations, `CHANGELOG.md has no [${normalizedVersion}] release section.`];
}
if (!section.lines.some((line) => line.trim())) {
return [...violations, `CHANGELOG.md [${normalizedVersion}] release section is empty.`];
}
const { headings, entries } = parseReleaseBody(section.lines);
const aliases = referenceAliases(commits);
const canonical = (ref) => canonicalReference(ref, aliases);
const seenHeadings = new Map();
for (const heading of headings) {
const key = heading.path;
const line = section.start + heading.line;
if (seenHeadings.has(key)) {
violations.push(
`[${normalizedVersion}] repeats the heading "${heading.text}" at lines ` +
`${seenHeadings.get(key)} and ${line}. Merge the two blocks.`,
);
} else {
seenHeadings.set(key, line);
}
}
const seenReferences = new Map();
for (const item of entries) {
const keys = [...new Set(referenceNumbers(item.text).map(canonical))];
const line = section.start + item.line;
for (const key of keys) {
if (seenReferences.has(key)) {
violations.push(
`[${normalizedVersion}] credits issue/PR identity #${key} twice, at lines ` +
`${seenReferences.get(key)} and ${line}.`,
);
} else {
seenReferences.set(key, line);
}
}
}
// A. Everything CITED must be REACHABLE from the ref this section describes.
//
// Gated on a COMPLETE history, because a truncated one cannot distinguish "this
// entry is credited to the wrong release" from "that commit is simply not in my
// pool". A depth-1 clone holds one commit, so every entry in the section looks
// unreachable: the guard reported #2398, #2399 and #2400 as bogus on a shallow
// checkout of this very branch, which fails a CORRECT release. Skipping coverage
// alone was not enough — and worse, saying so implied reachability had been
// checked when it had been checked against nothing.
const byRef = new Map();
for (const commit of commits) {
for (const ref of commit.refs) {
if (!byRef.has(ref)) byRef.set(ref, []);
byRef.get(ref).push(commit);
}
}
const shippedHere = rangeCommits && new Set(rangeCommits.map((commit) => commit.sha));
for (const item of historyComplete ? entries : []) {
// EVERY parenthesised citation, not only the trailing one. `(#2382, #2387)`
// names two shipped changes, and checking just the last let an unreachable
// first citation hide behind a reachable second — a wrong credit surviving the
// half built to catch wrong credits. Measured across sixteen releases: this
// adds ZERO violations, so the extra strictness costs no noise. Bare `#N` in
// prose is deliberately NOT ancestry-checked; that is commentary rather than a
// citation, and 0.52.135's "credit #2378 to 0.52.133, where it shipped" is a
// correct note about a ref that is legitimately unreachable from that tag.
const refs = [...new Set(referenceNumbers(item.text))];
const line = section.start + item.line;
for (const pr of refs) {
const candidates = byRef.get(pr) ?? [];
if (!candidates.length) {
violations.push(
`[${normalizedVersion}] entry at line ${line} names PR #${pr}, but no commit reachable ` +
`from ${targetRef} carries that reference.`,
);
continue;
}
if (!candidates.some((candidate) => isAncestor(candidate.sha, targetRef))) {
violations.push(
`[${normalizedVersion}] entry at line ${line} names PR #${pr}, but no commit carrying it ` +
`is an ancestor of ${targetRef} — it did not ship in this release.`,
);
continue;
}
// Reachable is not the same as SHIPPED HERE. Every earlier release is
// reachable from this tag, so an entry crediting this version with a change
// that landed two releases ago passes the ancestry test — which is the
// 0.52.134 mistake exactly. [0.52.133] credits itself with #2196, whose
// commit is not in v0.52.132..v0.52.133 at all; its real work was #2376.
if (shippedHere && !candidates.some((candidate) => shippedHere.has(candidate.sha))) {
violations.push(
`[${normalizedVersion}] entry at line ${line} names PR #${pr}, but the commit carrying ` +
`it is not in ${previousRef}..${targetRef} — it shipped in an earlier release.`,
);
}
}
}
// B. Everything REACHABLE must be CITED. The half that catches a silent gap.
if (rangeCommits) {
// An umbrella issue vouches for NOTHING. It has several PRs in this history, so
// one entry citing it would satisfy coverage for every one of them and hide all
// but the first — the shape where a fix on one exit leaves its siblings behind.
// referenceAliases already refuses to ALIAS these; refusing to alias is not
// refusing to vouch, which is the hole this closes.
const ambiguous = ambiguousReferences(commits);
const mentioned = new Set(mentionedNumbers(section.lines.join("\n")).map(canonical));
for (const commit of rangeCommits) {
// A release describes itself, not something it contains. `v<prev>..<target>`
// includes the target, so X's own release commit is always in this range — and
// in the merge-commit flow, so is the merge that landed the release branch.
if (isReleaseSubject(commit.subject) || isReleaseMerge(commit.subject)) continue;
// A squash carries its PR as `(#N)`; a REAL merge carries it as a bare `#N` on
// `Merge pull request #N from …`, which commitReferences resolves and the
// parenthesised form never sees. Four PRs shipped that way across sixteen
// releases (#2294, #2307, #2326, #2340) and none of them is in the changelog.
// Anything with no reference at all is a local commit a squash superseded —
// gen-changelog drops those too, and the two must agree or the guard fights
// its own generator.
// The fallback is for MERGE subjects specifically, not for anything whose
// refs happen to be non-empty. Reading commit.refs here also swallowed a
// numeric conventional SCOPE: `fix(2333): a UUID redacts its whole token`
// carries no PR at all — 2333 is the issue — and the guard demanded the
// changelog cite #2333 as though it were one. That commit is a local step
// inside merge #2294, and 0.52.124 was flagged for it.
const cited = referenceNumbers(commit.subject);
const shipped = cited.length ? cited : mergeReferences(commit.subject);
if (!shipped.length) continue;
// What this commit IS, as opposed to what it merely references.
const identity = shipped.at(-1);
const covered = commit.refs.some((ref) => {
if (!mentioned.has(canonical(ref))) return false;
// An umbrella issue vouches only for ITSELF. Citing #2393 documents the
// commits whose sole identity is #2393, but it must not stand in for
// #2400 and #2409 — two separate fixes, one of them then invisible.
// Excluding ambiguous references outright was the first attempt and it
// over-fired: six commits in 0.52.125 carry #2313 as their ONLY reference,
// shipped under merge #2340, and citing #2313 is the only way they are
// documentable at all. That release is correctly written and must stay green.
return !ambiguous.has(ref) || ref === identity;
});
if (covered) continue;
violations.push(
`[${normalizedVersion}] does not mention PR #${shipped.at(-1)}, which shipped in this ` +
`release (reachable from ${targetRef}, not from ${previousRef}): "${commit.subject}". ` +
`Add the entry, or add #${shipped.at(-1)} to the entry that already covers it.`,
);
}
}
return violations;
}
/** Name parity with the panel's guard, which the port started from. */
export const checkChangelog = auditReleaseSection;
// ── CLI ──────────────────────────────────────────────────────────────────────
function readCommits(ref, runGit = git) {
const commit = runGit("rev-parse", "--verify", ref + "^{commit}");
return parseCommitSubjects(runGit("log", "--format=%H%x1f%s%x1e", commit));
}
function gitErrorMessage(error) {
const detail = error?.stderr || error?.message || error;
return String(detail).trim().split(/\r?\n/, 1)[0];
}
function classifiedGitError(phase, error) {
const classified = new Error(phase + ": " + gitErrorMessage(error), { cause: error });
classified.phase = phase;
return classified;
}
function isMissingPreviousReleaseTag(error) {
return (
error?.status === 128 &&
/No names found, cannot describe anything|No tags can describe/i.test(gitErrorMessage(error))
);
}
function resolveTargetRef({ version, explicitRef = null, runGit = git }) {
if (explicitRef) return explicitRef;
const tag = "v" + version;
try {
runGit("show-ref", "--verify", "--quiet", "refs/tags/" + tag);
} catch (error) {
if (error?.status === 1) return "HEAD";
throw classifiedGitError("target tag " + tag + " lookup failed", error);
}
try {
runGit("rev-parse", "--verify", tag + "^{commit}");
} catch (error) {
throw classifiedGitError("target tag " + tag + " lookup failed", error);
}
return tag;
}
function probeShallowRepository({ runGit = git } = {}) {
try {
return {
status: "ok",
shallow: runGit("rev-parse", "--is-shallow-repository") === "true",
};
} catch (error) {
return { status: "error", error: classifiedGitError("git shallow-history probe failed", error) };
}
}
export function main(argv, { runGit = git, root = ROOT, changelog = CHANGELOG } = {}) {
const args = argv.slice(2);
const refIndex = args.indexOf("--ref");
const refValueIndex = refIndex >= 0 ? refIndex + 1 : -1;
const explicitRef = refIndex >= 0 ? args[refValueIndex] : null;
// Option-shaped leftovers stay in the version candidates so `--bad-version` is
// REJECTED rather than ignored — ignoring it would silently audit a different
// section than the caller named.
const versionArgs = args.filter((_, index) => index !== refIndex && index !== refValueIndex);
if (refIndex >= 0 && !explicitRef) {
console.error("changelog: --ref requires a Git commit ref");
return 2;
}
if (versionArgs.length > 1) {
console.error("changelog: expected at most one release version argument");
return 2;
}
let markdown;
if (explicitRef) {
try {
runGit("rev-parse", "--verify", `${explicitRef}^{commit}`);
markdown = runGit("show", `${explicitRef}:CHANGELOG.md`);
} catch (error) {
console.error(
`changelog: could not read CHANGELOG.md at ${explicitRef}: ${String(error.message).split("\n")[0]}`,
);
return 1;
}
} else {
markdown = readFileSync(changelog, "utf8");
}
let version;
if (versionArgs.length === 1) {
version = releaseVersion(versionArgs[0]);
if (!isStrictSemver(version)) {
console.error(`changelog: invalid release version "${versionArgs[0]}"; expected strict SemVer`);
return 2;
}
} else {
// What is BEING RELEASED, not whichever section happens to be newest.
//
// Taking the newest section meant a cut that wrote no section at all was
// audited as its PREDECESSOR — and passed, because the predecessor is fine.
// The one release with no notes whatsoever is exactly the one that must fail.
// package.json is the authority: `npm version` bumps it and gen-changelog
// stamps the matching section, so the two agree on every healthy release, and
// a missing section becomes "has no [X] release section" instead of silence.
//
// With --ref the working tree's package.json describes a different commit, so
// the tag name is the authority there instead.
const fromRef = explicitRef && /^v?\d+\.\d+\.\d+/.test(explicitRef) ? explicitRef : null;
let fromPackage = null;
if (!fromRef) {
try {
fromPackage = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
} catch {
fromPackage = null;
}
}
version = releaseVersion(
fromRef ??
fromPackage ??
parseReleaseSections(markdown).find((item) => isStrictSemver(item.version))?.version,
);
}
if (!version) {
console.error("usage: node scripts/check-changelog.mjs [version] [--ref <git-ref>]");
return 2;
}
let commits = [];
let rangeCommits = null;
let previousRef = null;
// "There is no repository here" and "the repository is there and git FAILED" are
// different answers and must not share an exit code. A source tarball or a zip
// download legitimately has no .git and nothing to verify against, so it skips.
// An EPERM, a corrupt object store or a missing git binary is an ERROR — and
// letting that exit 0 turns run-checks' green tick into a gate that passed
// because it could not look, which is the whole failure this guard exists to
// stop, aimed at itself.
// Asked of the FILESYSTEM, not of git. Asking git is circular: if git cannot be
// executed at all (`spawnSync git EPERM` — the case that prompted this), then
// `rev-parse --is-inside-work-tree` fails too and the broken state reads as "no
// repository here", which is the exact pass this is meant to prevent. Deleting
// .git/objects reproduces the same trap: git then reports "not a git repository"
// even though the checkout plainly is one. `.git` is a directory in a normal
// clone and a FILE in a git worktree; existsSync answers both.
const repositoryPresent = existsSync(join(root, ".git"));
// Only a proven missing target tag may use HEAD. A failed lookup means the
// target commit was not reliably identified and must fail closed.
let targetRef = explicitRef;
if (!targetRef && repositoryPresent) {
try {
targetRef = resolveTargetRef({ version, runGit });
} catch (error) {
console.error(
"changelog: " + error.message + ". Refusing to report a pass on a check " +
"that could not identify its target commit.",
);
return 1;
}
} else if (!targetRef) {
targetRef = "HEAD";
}
if (repositoryPresent) {
try {
commits = readCommits(targetRef, runGit);
} catch (error) {
if (repositoryPresent) {
console.error(
`changelog: git failed inside a repository at ${targetRef} ` +
`(${String(error.message).split("\n")[0]}). Refusing to report a pass on a ` +
`check that could not run.`,
);
return 1;
}
// Loud, and never confusable with a pass: name the halves that did not run.
console.error(
`changelog: could not read history at ${targetRef} ` +
`(${String(error.message).split("\n")[0]}) — structure was checked, ` +
`reachability and coverage were NOT.`,
);
}
}
// A shallow clone cannot answer "what shipped since the last release". It can
// only answer it WRONG, by reporting every PR as missing. Probe failure is not
// evidence of a full clone, so it fails closed instead of claiming verification.
const shallowResult = repositoryPresent
? probeShallowRepository({ runGit })
: { status: "ok", shallow: false };
if (shallowResult.status === "error") {
console.error(
"changelog: " + shallowResult.error.message + ". Refusing to report a pass on a " +
"history check that could not run.",
);
return 1;
}
const shallow = shallowResult.shallow;
// A truncated history cannot answer EITHER question — not just coverage. Both
// ci.yml and release.yml check out at fetch-depth: 0 precisely so this branch is
// never taken where it matters; a developer's shallow clone gets the honest
// "checked nothing" instead of three invented failures.
const historyComplete = commits.length > 0 && !shallow;
if (shallow) {
console.error(
"changelog: shallow clone — reachability AND coverage were NOT checked; only " +
"the section's structure was. Re-run in a full clone (CI uses fetch-depth: 0).",
);
} else if (commits.length) {
try {
// The base is "the previous release REACHABLE from this one". That is only
// the immediate predecessor while version tags stay on the branch — which is
// true of the current flow (every tag from v0.52.13 on is an ancestor of
// main) and was NOT true of the 0.50/0.51 flow, where the tag was pushed
// from a local commit and the bump squash-merged onto protected main as a
// different sha. 130 of 311 tags are still unreachable for that reason, and
// #988 is the same root cause hitting the generator.
//
// Left as-is deliberately rather than second-guessed from the changelog's
// own ordering: `describe` only considers REACHABLE tags, so a wrong answer
// is always too OLD, never too new. That over-reports — it cannot hide a
// missing entry — and every message names the base it used, so the mistake
// reads off the failure instead of having to be inferred. Guessing a newer
// base would trade a loud, wrong-looking failure for a silent gap, which is
// the exact direction this guard exists to close.
// --match v[0-9]*: `--tags` alone matches ANY tag, and this repo carries a
// non-release one (`backup/570-prerebase`). Selecting a stray tag as the
// previous release narrows the range, and everything before it then passes
// uncited — a silent gap, which is the failure this whole guard exists for.
previousRef = runGit("describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", `${targetRef}^`);
} catch (error) {
if (!isMissingPreviousReleaseTag(error)) {
console.error(
"changelog: " + classifiedGitError("git describe failed", error).message +
". Refusing to report a pass on a coverage check that could not run.",
);
return 1;
}
// `describe` fails for two very different reasons, and the round-2 fix to the
// history read left this sibling exit behind: no tag is REACHABLE (the first
// release — legitimate, skip), or the tag refs cannot be read at all (broken —
// and silently skipping coverage there is a gate passing because it could not
// look). Listing tags separates them: it succeeds in the first case and throws
// in the second.
try {
runGit("tag", "--list");
} catch (error) {
console.error(
`changelog: git could not read tags (${String(error.message).split("\n")[0]}). ` +
`Refusing to report a pass on a coverage check that could not run.`,
);
return 1;
}
console.error(
`changelog: no release tag before ${targetRef} — coverage ` +
`(every shipped PR is listed) was NOT checked.`,
);
rangeCommits = null;
}
// The range read is its OWN failure, not a missing tag. Sharing a catch with
// `describe` meant a `git log` error landed in the no-previous-tag handler and
// skipped coverage at exit 0 — the third sibling of this family, after the
// history read and `describe` itself. Here the tag has already resolved, so
// git demonstrably works and any failure is real.
if (previousRef) {
try {
rangeCommits = parseCommitSubjects(
runGit("log", `${previousRef}..${targetRef}`, "--format=%H%x1f%s%x1e"),
);
} catch (error) {
console.error(
`changelog: could not read ${previousRef}..${targetRef} ` +
`(${String(error.message).split("\n")[0]}). Refusing to report a pass on a ` +
`coverage check that could not run.`,
);
return 1;
}
}
}
const violations = auditReleaseSection({
markdown,
version,
commits,
rangeCommits,
targetRef,
previousRef,
historyComplete,
isAncestor: (sha, ref) => {
try {
runGit("merge-base", "--is-ancestor", sha, ref);
return true;
} catch {
return false;
}
},
});
if (violations.length) {
for (const violation of violations) console.error(`changelog: ERROR — ${violation}`);
return 1;
}
// Three outcomes, named apart. A run that verified nothing must never print the
// sentence a fully verified run prints.
if (!historyComplete) {
console.log(`changelog: [${version}] is structurally sound; history was unavailable, so NOTHING about what shipped was verified`);
} else if (rangeCommits) {
console.log(`changelog: [${version}] lists every PR since ${previousRef}, and every entry it names is reachable from ${targetRef}`);
} else {
console.log(`changelog: [${version}] names only entries reachable from ${targetRef}; coverage was not checked`);
}
return 0;
}
const invokedDirectly =
process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) process.exit(main(process.argv));