135 lines
5.2 KiB
JavaScript
135 lines
5.2 KiB
JavaScript
import { execFileSync } from "node:child_process";
|
|
import { readdirSync, readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
const [tag, previousTag] = process.argv.slice(2);
|
|
|
|
function fail(message) {
|
|
console.error(`Changelog output check failed: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!tag || !previousTag) {
|
|
fail("usage: node scripts/check-changelog-output.mjs <tag> <prev>");
|
|
}
|
|
|
|
const cwd = process.cwd();
|
|
const docsPath = "packages/docs/changelog.mdx";
|
|
const trackerPattern = /^changelog\/release-tracker-[0-9]{4}-[0-9]{2}-[0-9]{2}\.md$/;
|
|
const status = execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8" }).trimEnd();
|
|
|
|
if (!status) {
|
|
fail("no changes found; the changelog agent did not modify any files");
|
|
}
|
|
|
|
const changedPaths = status.split("\n").map((line) => line.slice(3));
|
|
const unexpectedPaths = changedPaths.filter((path) => path !== docsPath && !trackerPattern.test(path));
|
|
if (unexpectedPaths.length > 0) {
|
|
fail(`unexpected changed path(s): ${unexpectedPaths.join(", ")}`);
|
|
}
|
|
if (!changedPaths.includes(docsPath)) {
|
|
fail(`${docsPath} was not changed`);
|
|
}
|
|
if (!changedPaths.some((path) => trackerPattern.test(path))) {
|
|
fail("no release tracker file was changed");
|
|
}
|
|
console.log("Changed paths are limited to the changelog docs and release tracker files.");
|
|
|
|
const docs = readFileSync(resolve(cwd, docsPath), "utf8");
|
|
const openingUpdates = docs.match(/<Update /g)?.length ?? 0;
|
|
const closingUpdates = docs.match(/<\/Update>/g)?.length ?? 0;
|
|
if (openingUpdates !== closingUpdates) {
|
|
fail(`unbalanced Update tags: found ${openingUpdates} opening and ${closingUpdates} closing tags`);
|
|
}
|
|
|
|
const compareLink = `[${tag}](https://github.com/different-ai/openwork/compare/${previousTag}...${tag})`;
|
|
const compareLinkCount = docs.split(compareLink).length - 1;
|
|
if (compareLinkCount !== 1) {
|
|
fail(`expected the exact compare link ${compareLink} once, found ${compareLinkCount}`);
|
|
}
|
|
|
|
const versions = [...docs.matchAll(/^\s*## \[(v[0-9]+\.[0-9]+\.[0-9]+)\]\(/gm)].map((match) => match[1]);
|
|
function compareVersions(left, right) {
|
|
const leftParts = left.slice(1).split(".").map(Number);
|
|
const rightParts = right.slice(1).split(".").map(Number);
|
|
for (let index = 0; index < 3; index += 1) {
|
|
if (leftParts[index] !== rightParts[index]) {
|
|
return leftParts[index] - rightParts[index];
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
for (let index = 1; index < versions.length; index += 1) {
|
|
if (compareVersions(versions[index - 1], versions[index]) <= 0) {
|
|
fail(`docs versions are not strictly descending at ${versions[index - 1]} then ${versions[index]}`);
|
|
}
|
|
}
|
|
console.log("Changelog Update tags, compare link, and version ordering are valid.");
|
|
|
|
const trackerDirectory = resolve(cwd, "changelog");
|
|
const trackerFiles = readdirSync(trackerDirectory)
|
|
.filter((name) => /^release-tracker-[0-9]{4}-[0-9]{2}-[0-9]{2}\.md$/.test(name))
|
|
.sort();
|
|
const tagHeading = `## ${tag}`;
|
|
const containingTrackers = [];
|
|
|
|
for (const trackerFile of trackerFiles) {
|
|
const path = resolve(trackerDirectory, trackerFile);
|
|
const contents = readFileSync(path, "utf8");
|
|
const headingCount = contents.split("\n").filter((line) => line === tagHeading).length;
|
|
for (let count = 0; count < headingCount; count += 1) {
|
|
containingTrackers.push({ path, contents });
|
|
}
|
|
}
|
|
|
|
if (containingTrackers.length !== 1) {
|
|
fail(`expected ${tagHeading} exactly once across release trackers, found ${containingTrackers.length}`);
|
|
}
|
|
|
|
const shortstat = execFileSync("git", ["diff", "--shortstat", `${previousTag}..${tag}`], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
});
|
|
const insertions = Number(shortstat.match(/([0-9]+) insertion/)?.[1] ?? 0);
|
|
const deletions = Number(shortstat.match(/([0-9]+) deletion/)?.[1] ?? 0);
|
|
const locLine = `${insertions + deletions} lines changed since \`${previousTag}\` (${insertions} insertions, ${deletions} deletions).`;
|
|
const trackerContents = containingTrackers[0].contents;
|
|
if (!trackerContents.includes(locLine)) {
|
|
fail(`tracker section is missing the exact LOC line: ${locLine}`);
|
|
}
|
|
|
|
const sectionStart = trackerContents.split("\n").findIndex((line) => line === tagHeading);
|
|
const sectionLines = trackerContents.split("\n").slice(sectionStart + 1);
|
|
const nextSection = sectionLines.findIndex((line) => line.startsWith("## "));
|
|
const section = (nextSection === -1 ? sectionLines : sectionLines.slice(0, nextSection)).join("\n");
|
|
const requiredHeadings = [
|
|
"#### Commit",
|
|
"#### Released at",
|
|
"#### Title",
|
|
"#### One-line summary",
|
|
"#### Main changes",
|
|
"#### Lines of code changed since previous release",
|
|
"#### Release importance",
|
|
"#### Major improvements",
|
|
"#### Number of major improvements",
|
|
"#### Major improvement details",
|
|
"#### Major bugs resolved",
|
|
"#### Number of major bugs resolved",
|
|
"#### Major bug fix details",
|
|
"#### Deprecated features",
|
|
"#### Number of deprecated features",
|
|
"#### Deprecated details",
|
|
];
|
|
let previousHeadingIndex = -1;
|
|
for (const heading of requiredHeadings) {
|
|
const headingIndex = section.indexOf(heading);
|
|
if (headingIndex === -1) {
|
|
fail(`${tagHeading} section is missing ${heading}`);
|
|
}
|
|
if (headingIndex <= previousHeadingIndex) {
|
|
fail(`${tagHeading} section has ${heading} out of order`);
|
|
}
|
|
previousHeadingIndex = headingIndex;
|
|
}
|
|
console.log(`${tagHeading} appears once with the exact LOC line and all required headings in order.`);
|