Files
nexu-io__open-design/tools/release/tests/release-note.test.ts
lefarcen 5a7bbe5b10 chore(release): stop blocking stable releases on missing release notes (#7886)
A stable release with no docs/CHANGELOG/v<version>/ directory hard-failed at
the very first job. Fixing it mid-release is not "write two markdown files":
the stable promotion gate pins github.commit to the promoted prerelease, so the
changelog commit invalidates that artifact and forces a fresh ~27-minute
prerelease round trip.

Missing notes are now a loud warning instead of a failure. reviewReleaseNotePlanPolicy
returns warnings for a stable release with no notes, or one missing zh-CN, and
prepare-release-note surfaces them as a ::warning:: annotation plus a job
summary entry. Notes that exist but cannot be consumed still throw: a channel
mismatch, or a plan that omits the default locale every consumer falls back to.

publish-metadata carried a second stable gate on the same condition. Removing
only the first would have moved the hard failure past the GitHub draft release
and asset upload, which is strictly worse, so both are gone. The rest of the
chain already treated "absent" as a complete outcome — it is the beta and
prerelease path — so publication, verification, and metadata assembly are
unchanged.

Non-stable channels behave exactly as before.
2026-09-08 08:23:56 +00:00

334 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createReleaseNotePublication,
parseReleaseNotePublication,
releaseNoteMetadataFromPublication,
verifyReleaseNotePublication,
} from "../src/release-note/publication.js";
import { reportReleaseNotePolicyWarnings, reviewReleaseNotePlanPolicy } from "../src/release-note/policy.js";
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", () => {
// 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 () => {
const root = await temporaryRoot();
await writeNote(root, "1.2.3", "en");
const plan = discoverReleaseNotePlan({
channel: "stable",
releaseVersion: "1.2.3",
sourceRoot: root,
});
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([]);
});
it("reports nothing when a stable release carries every recommended locale", 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,
});
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("");
});
});
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",
},
},
},
});
});
// 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();
});
});