361 lines
11 KiB
JavaScript
361 lines
11 KiB
JavaScript
import { gt, major, valid } from "semver";
|
|
import { z } from "zod";
|
|
|
|
const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
|
|
const versionSchema = z.object({ version: z.string() });
|
|
const manifestSchema = z.object({ ".": z.string() });
|
|
const sectionsSchema = z.object({
|
|
versioning: z.string().optional(),
|
|
"changelog-sections": z.array(
|
|
z.object({ type: z.string(), hidden: z.boolean().optional() }),
|
|
),
|
|
});
|
|
const GITHUB_MERGE_SUBJECT = /^Merge pull request #(\d+) from \S+/u;
|
|
|
|
function conventionalTitle(subject, body) {
|
|
if (!GITHUB_MERGE_SUBJECT.test(subject)) return subject;
|
|
// GitHub's default merge message puts the PR title after its subject.
|
|
// Inspect that first nonempty line, not arbitrary examples in commit prose.
|
|
return (
|
|
body
|
|
.split(/\r?\n/u)
|
|
.slice(1)
|
|
.find((line) => line.trim() !== "")
|
|
?.trim() ?? subject
|
|
);
|
|
}
|
|
|
|
function releaseVersion(value, location) {
|
|
if (valid(value) !== value || value.includes("+")) {
|
|
throw new Error(
|
|
`${location} must contain an exact SemVer without build metadata: ${value}`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function readJson(git, sha, path, schema) {
|
|
const text = await git(["show", `${sha}:${path}`]);
|
|
let value;
|
|
try {
|
|
value = JSON.parse(text);
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) {
|
|
throw new Error(`${path}@${sha} contains invalid JSON: ${error.message}`);
|
|
}
|
|
throw error;
|
|
}
|
|
const parsed = schema.safeParse(value);
|
|
if (!parsed.success) {
|
|
throw new Error(`${path}@${sha} is malformed: ${parsed.error.message}`);
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
function readCommits(text) {
|
|
if (text.length === 0) return [];
|
|
const fields = text.split("\0");
|
|
if (fields.pop() !== "" || fields.length % 3 !== 0) {
|
|
throw new Error("Git returned malformed checkpoint history records.");
|
|
}
|
|
const commits = [];
|
|
for (let index = 0; index < fields.length; index += 3) {
|
|
const sha = shaSchema.parse(fields[index]);
|
|
const subject = z.string().parse(fields[index + 1]);
|
|
const body = z.string().parse(fields[index + 2]);
|
|
const title = conventionalTitle(subject, body);
|
|
const type = /^([a-z]+)(?:\([^)]*\))?!?: /u.exec(title)?.[1];
|
|
commits.push({
|
|
sha,
|
|
subject,
|
|
...(title !== subject ? { conventionalTitle: title } : {}),
|
|
type,
|
|
breaking:
|
|
/^[a-z]+(?:\([^)]*\))?!: /u.test(title) ||
|
|
/^BREAKING(?: CHANGE|-CHANGE):\s*\S/mu.test(body),
|
|
breakingNotes: Array.from(
|
|
body
|
|
.replace(/\r\n/gu, "\n")
|
|
.matchAll(
|
|
/^BREAKING(?: CHANGE|-CHANGE):[ \t]*(\S[^\n]*(?:\n(?!\n|[A-Za-z-]+: )[^\n]+)*)/gmu,
|
|
),
|
|
(match) => match[1].replace(/\s+/gu, " ").trim(),
|
|
),
|
|
});
|
|
}
|
|
return commits;
|
|
}
|
|
|
|
function referencesCommit(text, commit) {
|
|
if (text.includes(commit.sha.slice(0, 7))) return true;
|
|
const pullRequest =
|
|
GITHUB_MERGE_SUBJECT.exec(commit.subject)?.[1] ??
|
|
/\(#(\d+)\)$/u.exec(commit.subject)?.[1];
|
|
return (
|
|
pullRequest !== undefined &&
|
|
new RegExp(`#${pullRequest}(?!\\d)`, "u").test(text)
|
|
);
|
|
}
|
|
|
|
function releaseNotes(text, expectedVersion) {
|
|
const headings = Array.from(text.matchAll(/^## \[([^\]]+)\].*$/gmu));
|
|
const first = headings[0];
|
|
if (first?.[1] !== expectedVersion || first.index === undefined) {
|
|
throw new Error(`CHANGELOG.md must start with release ${expectedVersion}.`);
|
|
}
|
|
return text.slice(first.index, headings[1]?.index ?? text.length);
|
|
}
|
|
|
|
function breakingSection(notes) {
|
|
const heading = /^### [^\n]*BREAKING CHANGES[^\n]*\n/imu.exec(notes);
|
|
if (heading === null) return "";
|
|
const body = notes.slice(heading.index + heading[0].length);
|
|
const end = body.search(/^### /mu);
|
|
return end < 0 ? body : body.slice(0, end);
|
|
}
|
|
|
|
async function requireAncestor(git, ancestor, descendant, description) {
|
|
try {
|
|
await git(["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
} catch (error) {
|
|
const failure = z.object({ code: z.literal(1) }).safeParse(error);
|
|
if (!failure.success) throw error;
|
|
throw new Error(
|
|
`${description}: ${ancestor} is not an ancestor of ${descendant}. Sync the released tag into main before cutting a checkpoint, and prepare candidates from that frozen source.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function resolveBaseline(git, tag) {
|
|
try {
|
|
return shaSchema.parse(
|
|
(await git(["rev-parse", "--verify", `${tag}^{commit}`])).trim(),
|
|
);
|
|
} catch (error) {
|
|
const failure = z.object({ code: z.literal(128) }).safeParse(error);
|
|
if (!failure.success) throw error;
|
|
throw new Error(
|
|
`Published baseline ${tag} is unavailable. Fetch release tags and finish the previous release sync before preparing this checkpoint. ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function validateCandidate(git, sha, version, commits) {
|
|
const versions = [
|
|
[
|
|
"package.json",
|
|
(await readJson(git, sha, "package.json", versionSchema)).version,
|
|
],
|
|
[
|
|
".release-please-manifest.json",
|
|
(
|
|
await readJson(
|
|
git,
|
|
sha,
|
|
".release-please-manifest.json",
|
|
manifestSchema,
|
|
)
|
|
)["."],
|
|
],
|
|
];
|
|
const lock = await readJson(
|
|
git,
|
|
sha,
|
|
"package-lock.json",
|
|
versionSchema.extend({
|
|
packages: z.object({ "": versionSchema }),
|
|
}),
|
|
);
|
|
versions.push(
|
|
["package-lock.json", lock.version],
|
|
["package-lock.json packages.root", lock.packages[""].version],
|
|
);
|
|
const server = await readJson(
|
|
git,
|
|
sha,
|
|
"server.json",
|
|
versionSchema.extend({
|
|
packages: z.array(
|
|
z.object({ identifier: z.string(), version: z.string() }),
|
|
),
|
|
}),
|
|
);
|
|
const npmPackage = server.packages.find(
|
|
(entry) => entry.identifier === "rea-agents",
|
|
);
|
|
if (npmPackage === undefined)
|
|
throw new Error("server.json is missing the rea-agents package.");
|
|
versions.push(
|
|
["server.json", server.version],
|
|
["server.json rea-agents package", npmPackage.version],
|
|
);
|
|
// Catalogs are generated and checked from this candidate by the release job;
|
|
// they are not version authority stored in the immutable source tree.
|
|
const generated = await git([
|
|
"show",
|
|
`${sha}:src/generatedPackageMetadata.ts`,
|
|
]);
|
|
const generatedVersions = Array.from(
|
|
generated.matchAll(/^\s+version: "([^"]+)"/gmu),
|
|
);
|
|
if (generatedVersions.length !== 1)
|
|
throw new Error(
|
|
"Generated package metadata must declare one literal version.",
|
|
);
|
|
versions.push(["src/generatedPackageMetadata.ts", generatedVersions[0]?.[1]]);
|
|
for (const [location, actual] of versions) {
|
|
if (actual !== version) {
|
|
throw new Error(
|
|
`${location}@${sha} declares ${actual}; release branch requires ${version}.`,
|
|
);
|
|
}
|
|
}
|
|
const notes = releaseNotes(
|
|
await git(["show", `${sha}:CHANGELOG.md`]),
|
|
version,
|
|
);
|
|
const breakingNotes = breakingSection(notes);
|
|
const normalizedBreakingNotes = breakingNotes.replace(/\s+/gu, " ");
|
|
const missingBreaking = commits.filter(
|
|
(commit) =>
|
|
commit.breaking &&
|
|
!referencesCommit(breakingNotes, commit) &&
|
|
(commit.breakingNotes.length === 0 ||
|
|
commit.breakingNotes.some(
|
|
(note) => !normalizedBreakingNotes.includes(note),
|
|
)),
|
|
);
|
|
if (missingBreaking.length > 0) {
|
|
throw new Error(
|
|
`Release ${version} migration notes omit unreleased breaking changes: ${missingBreaking.map((commit) => `${commit.sha} ${commit.subject}`).join("; ")}`,
|
|
);
|
|
}
|
|
return commits.filter((commit) => !referencesCommit(notes, commit));
|
|
}
|
|
|
|
/** Validate a recorded checkpoint and optional candidate using exact Git ancestry. */
|
|
export async function inspectReleaseCheckpoint(git, options) {
|
|
const { phase, stage, releaseBranch, sourceSha, candidateSha } = z
|
|
.object({
|
|
phase: z.enum(["prepare", "publish"]),
|
|
stage: z.enum(["source", "candidate"]),
|
|
releaseBranch: z.union([
|
|
z.literal("main"),
|
|
z.string().startsWith("release/"),
|
|
]),
|
|
sourceSha: shaSchema,
|
|
candidateSha: shaSchema.optional(),
|
|
})
|
|
.parse(options);
|
|
if (releaseBranch === "main" && (phase !== "publish" || stage !== "source")) {
|
|
throw new Error(
|
|
"Main publication validates its reviewed merged source SHA.",
|
|
);
|
|
}
|
|
const expectedVersion = releaseVersion(
|
|
releaseBranch === "main"
|
|
? (await readJson(git, sourceSha, "package.json", versionSchema)).version
|
|
: releaseBranch.slice("release/".length),
|
|
releaseBranch === "main" ? "reviewed main release" : "release/VERSION",
|
|
);
|
|
if (phase === "publish" && stage === "candidate") {
|
|
throw new Error(
|
|
"Publish validates its merged source SHA; candidate stage applies only to prepare.",
|
|
);
|
|
}
|
|
const applicationSha =
|
|
phase === "publish"
|
|
? shaSchema.parse((await git(["rev-parse", `${sourceSha}^1`])).trim())
|
|
: sourceSha;
|
|
const manifest = await readJson(
|
|
git,
|
|
applicationSha,
|
|
".release-please-manifest.json",
|
|
manifestSchema,
|
|
);
|
|
const baselineVersion = releaseVersion(
|
|
manifest["."],
|
|
"published baseline manifest",
|
|
);
|
|
const sourcePackage = await readJson(
|
|
git,
|
|
applicationSha,
|
|
"package.json",
|
|
versionSchema,
|
|
);
|
|
if (sourcePackage.version !== baselineVersion) {
|
|
throw new Error(
|
|
`Source package ${sourcePackage.version} and release baseline ${baselineVersion} disagree.`,
|
|
);
|
|
}
|
|
if (!gt(expectedVersion, baselineVersion)) {
|
|
throw new Error(
|
|
`Expected release ${expectedVersion} must advance published baseline ${baselineVersion}.`,
|
|
);
|
|
}
|
|
const baselineTag = `refs/tags/rea-agents-${baselineVersion}`;
|
|
const baselineSha = await resolveBaseline(git, baselineTag);
|
|
await requireAncestor(
|
|
git,
|
|
baselineSha,
|
|
applicationSha,
|
|
"Published baseline is outside the checkpoint ancestry",
|
|
);
|
|
const configuration = await readJson(
|
|
git,
|
|
applicationSha,
|
|
"release-please-config.json",
|
|
sectionsSchema,
|
|
);
|
|
const visibleTypes = new Set(
|
|
configuration["changelog-sections"]
|
|
.filter((section) => !section.hidden)
|
|
.map((section) => section.type),
|
|
);
|
|
const commits = readCommits(
|
|
await git([
|
|
"log",
|
|
"--first-parent",
|
|
"-z",
|
|
"--format=%H%x00%s%x00%B",
|
|
`${baselineSha}..${applicationSha}`,
|
|
]),
|
|
).filter((commit) => commit.breaking || visibleTypes.has(commit.type));
|
|
const breaking = commits.filter((commit) => commit.breaking);
|
|
if (
|
|
configuration.versioning !== "always-bump-minor" &&
|
|
breaking.length > 0 &&
|
|
major(baselineVersion) > 0 &&
|
|
major(expectedVersion) <= major(baselineVersion)
|
|
) {
|
|
throw new Error(
|
|
`Expected release ${expectedVersion} contains unreleased breaking markers since ${baselineVersion}: ${breaking.map((commit) => `${commit.sha} ${commit.subject}`).join("; ")}. Review compatibility or select a major version.`,
|
|
);
|
|
}
|
|
let snapshot = phase === "publish" ? sourceSha : candidateSha;
|
|
if (stage === "candidate" && snapshot === undefined) {
|
|
snapshot = shaSchema.parse((await git(["rev-parse", "HEAD"])).trim());
|
|
}
|
|
if (phase === "publish" || stage === "candidate") {
|
|
await requireAncestor(
|
|
git,
|
|
applicationSha,
|
|
snapshot,
|
|
"Candidate is outside the selected checkpoint ancestry",
|
|
);
|
|
}
|
|
const missingNotes =
|
|
phase === "publish" || stage === "candidate"
|
|
? await validateCandidate(git, snapshot, expectedVersion, commits)
|
|
: [];
|
|
return {
|
|
expectedVersion,
|
|
baselineVersion,
|
|
baselineSha,
|
|
sourceSha,
|
|
applicationSha,
|
|
commits,
|
|
missingNotes,
|
|
};
|
|
}
|