2026-07-13 16:21:31 +08:00
|
|
|
import { createHash } from "node:crypto";
|
2026-09-08 08:23:56 +00:00
|
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
2026-07-13 16:21:31 +08:00
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
|
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
createReleaseNotePublication,
|
2026-09-08 08:23:56 +00:00
|
|
|
parseReleaseNotePublication,
|
2026-07-13 16:21:31 +08:00
|
|
|
releaseNoteMetadataFromPublication,
|
|
|
|
|
verifyReleaseNotePublication,
|
|
|
|
|
} from "../src/release-note/publication.js";
|
2026-09-08 08:23:56 +00:00
|
|
|
import { reportReleaseNotePolicyWarnings, reviewReleaseNotePlanPolicy } from "../src/release-note/policy.js";
|
2026-07-13 16:21:31 +08:00
|
|
|
import { discoverReleaseNotePlan } from "../src/release-note/source.js";
|
|
|
|
|
|
|
|
|
|
const roots: string[] = [];
|
|
|
|
|
|
|
|
|
|
async function temporaryRoot(): Promise<string> {
|
|
|
|
|
const root = await mkdtemp(join(tmpdir(), "od-release-note-"));
|
|
|
|
|
roots.push(root);
|
|
|
|
|
return root;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeNote(
|
|
|
|
|
root: string,
|
|
|
|
|
releaseVersion: string,
|
|
|
|
|
locale: string,
|
|
|
|
|
options: { body?: string; description?: string; title?: string } = {},
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const directory = join(root, `v${releaseVersion}`);
|
|
|
|
|
await mkdir(directory, { recursive: true });
|
|
|
|
|
const value = [
|
|
|
|
|
"---",
|
|
|
|
|
`title: ${options.title ?? `Open Design ${releaseVersion}`}`,
|
|
|
|
|
`description: ${options.description ?? `Release notes for ${releaseVersion}.`}`,
|
|
|
|
|
"---",
|
|
|
|
|
"",
|
|
|
|
|
options.body ?? "## Improvements\n\nA deterministic release note body.",
|
|
|
|
|
"",
|
|
|
|
|
].join("\n");
|
|
|
|
|
const path = join(directory, `${locale}.md`);
|
|
|
|
|
await writeFile(path, value, "utf8");
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("release note source discovery", () => {
|
|
|
|
|
it("uses the exact full release version and parses self-describing Markdown", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
const raw = await writeNote(root, "1.2.3-beta.4", "en", {
|
|
|
|
|
description: "Beta release details.",
|
|
|
|
|
title: "Beta 4",
|
|
|
|
|
});
|
|
|
|
|
await writeNote(root, "1.2.3-beta.4", "zh-CN");
|
|
|
|
|
|
|
|
|
|
const plan = discoverReleaseNotePlan({
|
|
|
|
|
channel: "beta",
|
|
|
|
|
releaseVersion: "1.2.3-beta.4",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(plan.state).toBe("ready");
|
|
|
|
|
expect(plan.entries.map((entry) => entry.locale)).toEqual(["en", "zh-CN"]);
|
|
|
|
|
expect(plan.entries[0]).toMatchObject({
|
|
|
|
|
description: "Beta release details.",
|
|
|
|
|
mediaType: "text/markdown; charset=utf-8",
|
|
|
|
|
name: "en.md",
|
|
|
|
|
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
|
|
|
title: "Beta 4",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
}).state).toBe("absent");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects supplied Markdown without valid front matter or body", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
const directory = join(root, "v1.2.3-preview.2");
|
|
|
|
|
await mkdir(directory, { recursive: true });
|
|
|
|
|
await writeFile(join(directory, "en.md"), "# No front matter\n", "utf8");
|
|
|
|
|
|
|
|
|
|
expect(() => discoverReleaseNotePlan({
|
|
|
|
|
channel: "preview",
|
|
|
|
|
releaseVersion: "1.2.3-preview.2",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
})).toThrow(/front matter/i);
|
|
|
|
|
|
|
|
|
|
await writeFile(
|
|
|
|
|
join(directory, "en.md"),
|
|
|
|
|
"---\ntitle: Preview\ndescription: Preview details.\n---\n\n",
|
|
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
expect(() => discoverReleaseNotePlan({
|
|
|
|
|
channel: "preview",
|
|
|
|
|
releaseVersion: "1.2.3-preview.2",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
})).toThrow(/body/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects an explicitly supplied empty release-note directory", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
await mkdir(join(root, "v1.2.3-beta.5"), { recursive: true });
|
|
|
|
|
|
|
|
|
|
expect(() => discoverReleaseNotePlan({
|
|
|
|
|
channel: "beta",
|
|
|
|
|
releaseVersion: "1.2.3-beta.5",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
})).toThrow(/present but empty/i);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("release note channel policy", () => {
|
2026-09-08 08:23:56 +00:00
|
|
|
// The invariant: missing release notes never block a release. A note that
|
|
|
|
|
// exists but cannot be consumed (wrong channel, no default locale) still
|
|
|
|
|
// fails, because that is a broken artifact rather than an absent one.
|
|
|
|
|
it("keeps discovery channel-neutral and warns instead of failing when stable notes are partial", async () => {
|
2026-07-13 16:21:31 +08:00
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
await writeNote(root, "1.2.3", "en");
|
|
|
|
|
const plan = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 08:23:56 +00:00
|
|
|
const warnings = reviewReleaseNotePlanPolicy(plan, "stable");
|
|
|
|
|
expect(warnings.map((warning) => warning.code)).toEqual(["stable-release-note-locale-missing"]);
|
|
|
|
|
expect(warnings[0]?.message).toMatch(/zh-CN/);
|
|
|
|
|
expect(reviewReleaseNotePlanPolicy({ ...plan, channel: "beta" }, "beta")).toEqual([]);
|
2026-07-13 16:21:31 +08:00
|
|
|
});
|
|
|
|
|
|
2026-09-08 08:23:56 +00:00
|
|
|
it("reports nothing when a stable release carries every recommended locale", async () => {
|
2026-07-13 16:21:31 +08:00
|
|
|
const root = await temporaryRoot();
|
2026-09-08 08:23:56 +00:00
|
|
|
await writeNote(root, "1.2.3", "en");
|
|
|
|
|
await writeNote(root, "1.2.3", "zh-CN");
|
|
|
|
|
const plan = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
2026-07-13 16:21:31 +08:00
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-08 08:23:56 +00:00
|
|
|
expect(reviewReleaseNotePlanPolicy(plan, "stable")).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("lets a stable release ship with no notes at all and reports the gap", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
const absent = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(absent.state).toBe("absent");
|
|
|
|
|
const warnings = reviewReleaseNotePlanPolicy(absent, "stable");
|
|
|
|
|
expect(warnings.map((warning) => warning.code)).toEqual(["stable-release-note-absent"]);
|
|
|
|
|
expect(warnings[0]?.message).toContain("1.2.3");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves non-stable channels silent when notes are absent", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
for (const [channel, releaseVersion] of [
|
|
|
|
|
["beta", "1.2.3-beta.1"],
|
|
|
|
|
["prerelease", "1.2.3-prerelease.1"],
|
|
|
|
|
["preview", "1.2.3-preview.1"],
|
|
|
|
|
] as const) {
|
|
|
|
|
const absent = discoverReleaseNotePlan({ channel, releaseVersion, sourceRoot: root });
|
|
|
|
|
expect(absent.state).toBe("absent");
|
|
|
|
|
expect(reviewReleaseNotePlanPolicy(absent, channel)).toEqual([]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still rejects a plan that has notes but omits the default locale", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
await writeNote(root, "1.2.3", "zh-CN");
|
|
|
|
|
const plan = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(plan.state).toBe("ready");
|
|
|
|
|
expect(() => reviewReleaseNotePlanPolicy(plan, "stable")).toThrow(/default locale/i);
|
|
|
|
|
expect(() => reviewReleaseNotePlanPolicy({ ...plan, channel: "beta" }, "beta")).toThrow(/default locale/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still rejects a plan whose channel disagrees with the caller", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
await writeNote(root, "1.2.3", "en");
|
|
|
|
|
const plan = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(() => reviewReleaseNotePlanPolicy(plan, "beta")).toThrow(/channel mismatch/i);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("release note policy reporting", () => {
|
|
|
|
|
// Not blocking only works if the gap is loud. A skipped stable note must land
|
|
|
|
|
// on the run page as an annotation and in the job summary.
|
|
|
|
|
it("annotates the run and appends to the job summary", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
const summaryPath = join(root, "step-summary.md");
|
|
|
|
|
await writeFile(summaryPath, "", "utf8");
|
|
|
|
|
const absent = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
const warned: string[] = [];
|
|
|
|
|
const restore = console.warn;
|
|
|
|
|
console.warn = (message: string) => void warned.push(message);
|
|
|
|
|
try {
|
|
|
|
|
reportReleaseNotePolicyWarnings(reviewReleaseNotePlanPolicy(absent, "stable"), summaryPath);
|
|
|
|
|
} finally {
|
|
|
|
|
console.warn = restore;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(warned).toHaveLength(1);
|
|
|
|
|
expect(warned[0]).toMatch(/^::warning title=Release notes missing::/);
|
|
|
|
|
expect(warned[0]).not.toContain("\n");
|
|
|
|
|
expect(await readFile(summaryPath, "utf8")).toContain("### :warning: Release notes");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("stays quiet and leaves the summary untouched when there is nothing to report", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
const summaryPath = join(root, "step-summary.md");
|
|
|
|
|
await writeFile(summaryPath, "", "utf8");
|
|
|
|
|
const warned: string[] = [];
|
|
|
|
|
const restore = console.warn;
|
|
|
|
|
console.warn = (message: string) => void warned.push(message);
|
|
|
|
|
try {
|
|
|
|
|
reportReleaseNotePolicyWarnings([], summaryPath);
|
|
|
|
|
} finally {
|
|
|
|
|
console.warn = restore;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(warned).toEqual([]);
|
|
|
|
|
expect(await readFile(summaryPath, "utf8")).toBe("");
|
2026-07-13 16:21:31 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("release note publication contract", () => {
|
|
|
|
|
it("projects a planned publication into storage-neutral public metadata", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
await writeNote(root, "1.2.3", "en");
|
|
|
|
|
await writeNote(root, "1.2.3", "zh-CN");
|
|
|
|
|
const plan = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
const publication = createReleaseNotePublication(plan, {
|
|
|
|
|
publicOrigin: "https://releases.example.test",
|
|
|
|
|
published: false,
|
|
|
|
|
versionPrefix: "stable/versions/1.2.3",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(publication.state).toBe("planned");
|
|
|
|
|
expect(() => verifyReleaseNotePublication(plan, publication, { requirePublished: false })).not.toThrow();
|
|
|
|
|
expect(() => verifyReleaseNotePublication(plan, publication, { requirePublished: true })).toThrow(/published/i);
|
|
|
|
|
expect(releaseNoteMetadataFromPublication(publication)).toEqual({
|
|
|
|
|
content: {
|
|
|
|
|
defaultLocale: "en",
|
|
|
|
|
locales: {
|
|
|
|
|
en: {
|
|
|
|
|
mediaType: "text/markdown; charset=utf-8",
|
|
|
|
|
sha256: plan.entries[0]?.sha256,
|
|
|
|
|
size: plan.entries[0]?.size,
|
|
|
|
|
url: "https://releases.example.test/stable/versions/1.2.3/release-notes/en.md",
|
|
|
|
|
},
|
|
|
|
|
"zh-CN": {
|
|
|
|
|
mediaType: "text/markdown; charset=utf-8",
|
|
|
|
|
sha256: plan.entries[1]?.sha256,
|
|
|
|
|
size: plan.entries[1]?.size,
|
|
|
|
|
url: "https://releases.example.test/stable/versions/1.2.3/release-notes/zh-CN.md",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-09-08 08:23:56 +00:00
|
|
|
|
|
|
|
|
// A stable release that skipped its notes must produce a whole artifact, not
|
|
|
|
|
// half of one: an absent publication, no releaseNote metadata block, and a
|
|
|
|
|
// verification pass that agrees with both.
|
|
|
|
|
it("projects an absent stable plan into an absent publication carrying no metadata", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
const plan = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
const publication = createReleaseNotePublication(plan, {
|
|
|
|
|
publicOrigin: "https://releases.example.test",
|
|
|
|
|
published: true,
|
|
|
|
|
versionPrefix: "stable/versions/1.2.3",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(publication.state).toBe("absent");
|
|
|
|
|
expect(publication.entries).toEqual([]);
|
|
|
|
|
expect(releaseNoteMetadataFromPublication(publication)).toBeNull();
|
|
|
|
|
expect(() => verifyReleaseNotePublication(plan, publication, { requirePublished: true })).not.toThrow();
|
|
|
|
|
expect(() => parseReleaseNotePublication(JSON.parse(JSON.stringify(publication)) as unknown)).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("projects a partial stable plan into a publication carrying only the locales that exist", async () => {
|
|
|
|
|
const root = await temporaryRoot();
|
|
|
|
|
await writeNote(root, "1.2.3", "en");
|
|
|
|
|
const plan = discoverReleaseNotePlan({
|
|
|
|
|
channel: "stable",
|
|
|
|
|
releaseVersion: "1.2.3",
|
|
|
|
|
sourceRoot: root,
|
|
|
|
|
});
|
|
|
|
|
const publication = createReleaseNotePublication(plan, {
|
|
|
|
|
publicOrigin: "https://releases.example.test",
|
|
|
|
|
published: true,
|
|
|
|
|
versionPrefix: "stable/versions/1.2.3",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(publication.state).toBe("published");
|
|
|
|
|
expect(publication.entries.map((entry) => entry.locale)).toEqual(["en"]);
|
|
|
|
|
expect(Object.keys(releaseNoteMetadataFromPublication(publication)?.content.locales ?? {})).toEqual(["en"]);
|
|
|
|
|
expect(() => verifyReleaseNotePublication(plan, publication, { requirePublished: true })).not.toThrow();
|
|
|
|
|
expect(() => parseReleaseNotePublication(JSON.parse(JSON.stringify(publication)) as unknown)).not.toThrow();
|
|
|
|
|
});
|
2026-07-13 16:21:31 +08:00
|
|
|
});
|