ci(release-tooling): update release note models and prompts (#11936)

This commit is contained in:
Taesu
2026-10-20 19:54:42 +09:00
committed by GitHub
parent f5ea731e6d
commit 94436b3ec6
12 changed files with 168 additions and 64 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"@actions/core": "3.0.1",
"@octokit/request-error": "7.1.1",
"@octokit/rest": "22.0.1",
"ai": "7.0.112",
"ai": "7.0.143",
"conventional-changelog-conventionalcommits": "10.4.0",
"conventional-commits-parser": "7.1.2",
"mdast-util-from-markdown": "^2.0.3",
@@ -30,6 +30,13 @@ function containsUnsupportedNode(
);
}
function parseMarkdown(value: string) {
return fromMarkdown(value, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
});
}
export function containsUnsupportedGeneratedMarkdown(
value: string,
policy: GeneratedMarkdownPolicy,
@@ -39,11 +46,17 @@ export function containsUnsupportedGeneratedMarkdown(
}
const allowedNodes =
policy === "description" ? descriptionNodes : inlineNodes;
const markdown = fromMarkdown(value, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
});
return containsUnsupportedNode(markdown, allowedNodes);
return containsUnsupportedNode(parseMarkdown(value), allowedNodes);
}
export function inlineCodeValues(value: string): string[] {
return parseMarkdown(value).children.flatMap((node) =>
node.type === "paragraph"
? node.children.flatMap((child) =>
child.type === "inlineCode" ? [child.value] : [],
)
: [],
);
}
export function formatUntrustedInlineMarkdown(value: string): string {
+3 -3
View File
@@ -8,7 +8,7 @@ interface ReleaseModels {
}
export const models: ReleaseModels = {
changeset: gateway("openai/gpt-6-luna"),
releaseNotes: gateway("openai/gpt-6-sol"),
releaseNotesReviewer: gateway("anthropic/claude-sonnet-5"),
changeset: gateway("anthropic/claude-haiku-5.5"),
releaseNotes: gateway("anthropic/claude-sonnet-5.5"),
releaseNotesReviewer: gateway("openai/gpt-6.1-sol"),
};
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import {
containsUnsupportedGeneratedMarkdown,
formatUntrustedInlineMarkdown,
inlineCodeValues,
} from "../ai/generated-copy.ts";
import { FILTERED_DOMAINS } from "../change-classifier.ts";
import type {
@@ -107,8 +108,8 @@ export function formatReleaseBody(
lines.push(`- ${title}${prLink}`);
if (changeType === "breaking" && rewrite?.migration) {
lines.push(`> **Migration:** ${rewrite.migration}`);
if (rewrite?.migration) {
lines.push(`> **Action required:** ${rewrite.migration}`);
} else if (
changeType === "breaking" &&
entry.changesetDescription &&
@@ -119,7 +120,7 @@ export function formatReleaseBody(
) {
const [migration, ...details] =
entry.changesetDescription.split("\n");
lines.push(`> **Migration:** ${migration}`);
lines.push(`> **Action required:** ${migration}`);
for (const line of details) {
lines.push(line ? `> ${line}` : ">");
}
@@ -193,17 +194,15 @@ function readReleaseRewrites(
parsed.rewrites.map((rewrite) => [rewrite.id, rewrite]),
);
const breakingKeys = new Set(
manifest.entries
.filter((entry) => entry.breaking)
.map((entry) => entry.rewriteKey),
const entriesByKey = new Map(
manifest.entries.map((entry) => [entry.rewriteKey, entry]),
);
const rewrites: ReleaseRewrites = {};
for (const [key, value] of generatedByKey) {
try {
rewrites[key] = validateGeneratedReleaseRewrite(
value,
breakingKeys.has(key),
entriesByKey.get(key)!,
);
} catch (error) {
console.warn(
@@ -217,7 +216,10 @@ function readReleaseRewrites(
export function validateGeneratedReleaseRewrite(
value: GeneratedReleaseRewrites[number],
breaking: boolean,
{
changeType,
changesetDescription,
}: Pick<ReleaseEntry, "changeType" | "changesetDescription">,
): ReleaseRewrites[string] {
const title = validateGeneratedCopy(value.title, "title", value.id, 300);
const migration =
@@ -225,14 +227,17 @@ export function validateGeneratedReleaseRewrite(
? undefined
: validateGeneratedCopy(value.migration, "migration", value.id, 500);
if (breaking && !migration) {
if (changeType === "breaking" && !migration) {
throw new Error(
`Breaking AI rewrite ${value.id} must have a single-line migration`,
);
}
if (!breaking && migration !== undefined) {
// Users act on this line, so it may only quote code a maintainer approved.
const quotedCode = inlineCodeValues(migration ?? "");
if (quotedCode.some((code) => !changesetDescription?.includes(code))) {
throw new Error(
`Non-breaking AI rewrite ${value.id} cannot have a migration`,
`AI rewrite ${value.id} migration quotes code that is not in the changeset`,
);
}
@@ -14,5 +14,9 @@ Keep every title to one clear sentence and one line. Start with a past-tense
verb. Wrap code identifiers in backticks. Do not include links, HTML, images,
bold or italic emphasis, `@mentions`, PR numbers, or author attribution.
Return every input ID exactly once. Use `migration: null` for non-breaking
changes and a single-line migration action for breaking changes.
Return every input ID exactly once. Write a single-line `migration` for a
breaking change, and for any other change only when the description says
upgrading alone is not enough, such as when users must change code or
configuration or run a database migration. Start it with an imperative verb or
an "If you ..." clause, and quote only code that appears in the description.
Otherwise use `migration: null`.
@@ -8,11 +8,15 @@ Review every rewrite against its matching context. Approve it only when it:
- preserves the direction and user-visible meaning of the change
- makes no claim unsupported by the title or changeset description
- keeps API names, compatibility conditions, security guarantees, and
migration requirements that users need to act on the change
- keeps API names, compatibility conditions, security guarantees, migration
requirements, the affected case, and the restored version that users need
to judge or act on the change
- is clear, grammatical, and concise
- follows the requested change type and contains migration guidance only for a
breaking change
- has migration guidance for a breaking change, and for any other change only
when the description says upgrading alone is not enough, such as when users
must change code or configuration or run a database migration
- addresses migration guidance only to the affected users and quotes only code
that appears in the description
Do not reject copy merely because it omits internal implementation details.
@@ -24,6 +24,8 @@ the final Markdown deterministically.
- Start with a past-tense verb such as "Fixed", "Added", or "Improved"
- Keep each title to one sentence and one line
- Describe the user-visible impact, not the internal implementation
- Keep the affected case, the security check, or the restored version when the
description states it, because most users read only the title
- Wrap code identifiers in backticks, but not general concepts
- Do not include PR numbers or author attribution in titles
- Do not include links, HTML, images, bold or italic emphasis, or `@mentions`
@@ -31,13 +33,25 @@ the final Markdown deterministically.
- If a title remains unclear, preserve its factual meaning without inventing
behavior that is not present in the supplied context
For a change whose `changeType` is `breaking`, also write a single-line
`migration` explaining what users must change. Do not add `migration` to any
other change type.
Write a single-line `migration` for a `breaking` change, and for any other
change whose description says upgrading alone is not enough, such as when
users must change code or configuration or run a database migration. Use
`migration: null` for every other change. It renders as "Action required", so
start with an imperative verb, or with an "If you ..." clause when only some
users are affected. Quote only code that appears in the changeset description,
because users run what this line tells them to.
For example, a fix whose description tells users to add the domain to
`trustedOrigins`:
```json
{
"title": "Fixed magic link verification to reject links opened from an untrusted origin",
"migration": "If you open magic links from another domain, add it to `trustedOrigins`."
}
```
## Output rules
- Include every input change ID exactly once
- Do not add unknown change IDs
- Use `migration: null` for non-breaking changes
- Use a migration string for breaking changes
@@ -21,7 +21,7 @@ const maxBatchEntries = 30;
const maxBatchCharacters = 60_000;
const maxContextCharacters = 500_000;
const maxOutputTokensPerBatch = 32_000;
const maxReviewOutputTokensPerBatch = 8_000;
const maxReviewOutputTokensPerBatch = 32_000;
const defaultFallbackReason = "The rewrite was not approved by review.";
const invalidCopyFallbackReason =
"The rewrite did not pass deterministic copy validation.";
@@ -136,7 +136,7 @@ function validationFeedback(
if (!entry)
throw new Error(`Release rewrite context ${rewrite.id} is missing`);
try {
validateGeneratedReleaseRewrite(rewrite, entry.changeType === "breaking");
validateGeneratedReleaseRewrite(rewrite, entry);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
@@ -71,7 +71,7 @@ const releaseRewriteSchema = z.strictObject({
.max(500)
.regex(/^[^\r\n]+$/)
.nullable()
.describe("A migration action for breaking changes, otherwise null"),
.describe("An action users must take, otherwise null"),
});
export const releaseRewritesSchema = z.strictObject({
@@ -27,6 +27,7 @@ interface CommandResult {
function renderReleaseRewrites(
rewrites: JSONValue,
breaking = false,
changesetDescription = "Avoid duplicate session requests.",
): CommandResult {
const directory = mkdtempSync(
resolve(tmpdir(), "better-auth-release-notes-"),
@@ -56,7 +57,7 @@ function renderReleaseRewrites(
id: "pr-10769:better-auth",
rewriteKey: "pr-10769",
title: "fix(client): avoid duplicate requests",
changesetDescription: "Avoid duplicate session requests.",
changesetDescription,
prNumber: 10769,
author: "bytaesu",
domain: "core",
@@ -144,7 +145,7 @@ describe("AI release-note rewrites", () => {
);
expect(output).not.toContain("Read https://example.com before upgrading");
expect(output).toContain(
"> **Migration:** Update custom session refresh integrations.",
"> **Action required:** Update custom session refresh integrations.",
);
expect(output).toContain(
"- Changed session refresh behavior Injected paragraph",
@@ -243,21 +244,84 @@ describe("AI release-note rewrites", () => {
);
});
it("uses deterministic copy when a non-breaking rewrite has a migration", () => {
it("renders migration copy for a non-breaking change that asks users to act", () => {
const result = renderReleaseRewrites(
{
rewrites: [
{
id: "pr-10769",
title: "Fixed duplicate requests",
migration: "Set `clientId` on the provider.",
},
],
},
false,
"Set `clientId` on the provider.",
);
expect(result.status, result.stderr).toBe(0);
expect(result.output).toContain("- Fixed duplicate requests");
expect(result.output).toContain(
"> **Action required:** Set `clientId` on the provider.",
);
});
it("uses deterministic copy when a migration quotes code missing from the changeset", () => {
const result = renderReleaseRewrites({
rewrites: [
{
id: "pr-10769",
title: "Fixed duplicate requests",
migration: "Change an unrelated setting",
migration: "Run `npx unknown-package` before upgrading.",
},
],
});
expect(result.status).toBe(0);
expect(result.output).toContain("fix(client): avoid duplicate requests");
expect(result.output).not.toContain("unknown-package");
expect(result.stderr).toContain(
"Non-breaking AI rewrite pr-10769 cannot have a migration",
"AI rewrite pr-10769 migration quotes code that is not in the changeset",
);
});
it("uses deterministic copy when a migration hides code in doubled backticks", () => {
const result = renderReleaseRewrites({
rewrites: [
{
id: "pr-10769",
title: "Fixed duplicate requests",
migration: "Run ``npx unknown-package`` before upgrading.",
},
],
});
expect(result.status).toBe(0);
expect(result.output).not.toContain("unknown-package");
expect(result.stderr).toContain(
"AI rewrite pr-10769 migration quotes code that is not in the changeset",
);
});
it("uses deterministic copy when an escaped backtick shifts a code span", () => {
const result = renderReleaseRewrites(
{
rewrites: [
{
id: "pr-10769",
title: "Fixed duplicate requests",
migration: "Run \\`clientId`npx unknown-package`clientId` now.",
},
],
},
false,
"Set `clientId` on the provider.",
);
expect(result.status).toBe(0);
expect(result.output).not.toContain("unknown-package");
expect(result.stderr).toContain(
"AI rewrite pr-10769 migration quotes code that is not in the changeset",
);
});
@@ -298,7 +362,7 @@ describe("AI release-note rewrites", () => {
expect(result.status, result.stderr).toBe(0);
expect(result.output).toContain(
"> **Migration:** Update custom session refresh integrations.",
"> **Action required:** Update custom session refresh integrations.",
);
});
@@ -483,8 +483,8 @@ describe("release-note rewriting", () => {
rewrites: [
{
id,
title: "Preserved safe output",
migration: "Change an unrelated setting.",
title: "Preserved [safe output](https://example.com)",
migration: null,
},
],
}
+21 -21
View File
@@ -1693,8 +1693,8 @@ importers:
specifier: 22.0.1
version: 22.0.1
ai:
specifier: 7.0.112
version: 7.0.112(zod@4.5.4)
specifier: 7.0.143
version: 7.0.143(zod@4.5.4)
conventional-changelog-conventionalcommits:
specifier: 10.4.0
version: 10.4.0
@@ -1951,8 +1951,8 @@ packages:
peerDependencies:
zod: 4.5.4
'@ai-sdk/gateway@4.0.90':
resolution: {integrity: sha512-mtmiicpMapQU8K0ynNXCRcO55GVyBv6kjbwsD15RMxJKZUUPR99HfQ7/BsdRLdjSQcL7YcCauMLbguw6J+JFjQ==}
'@ai-sdk/gateway@4.0.116':
resolution: {integrity: sha512-YK2As2GpxmyHi5w6R4FLepiLcs6MbIMxh6nyTMgHaoHKoM5aWEXvKDe/JqdMQ1ivOy3EPJiEBgRI677YTGxfHQ==}
engines: {node: '>=22'}
peerDependencies:
zod: 4.5.4
@@ -1975,8 +1975,8 @@ packages:
peerDependencies:
zod: 4.5.4
'@ai-sdk/provider-utils@5.0.46':
resolution: {integrity: sha512-XYpiJbd3jW12HC4yzqNmUuPbZ7ooKXVxQjnXCC3O/VzzV2OvRWD6lDFUYYPLs/ZJgTcWWUkmHL/vIyvnxyplVA==}
'@ai-sdk/provider-utils@5.0.62':
resolution: {integrity: sha512-//6sHa6QNQdM2KHOg5pxp1UMFQB4U8cqdw/kSm9BMr0DLeaFgG3TXZfxhpwRZmxgBpOrzMVZ/gwLXH309+Ortw==}
engines: {node: '>=22'}
peerDependencies:
zod: 4.5.4
@@ -1985,8 +1985,8 @@ packages:
resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==}
engines: {node: '>=18'}
'@ai-sdk/provider@4.0.18':
resolution: {integrity: sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ==}
'@ai-sdk/provider@4.0.29':
resolution: {integrity: sha512-nV7UpLADtKn9f0xfnxVZAeFEKuNkScJv5rOpHL7qeWe2EwEEBa3pd6P0QVK6D6coCJ0E8FyCgoM50AffTYrzDQ==}
engines: {node: '>=22'}
'@ai-sdk/react@3.0.210':
@@ -8019,8 +8019,8 @@ packages:
peerDependencies:
zod: 4.5.4
ai@7.0.112:
resolution: {integrity: sha512-JDIaxfynFQoV/MPh8AUeHIpuFAUjA2uPBXd6Aa8oZBHtiQ58gJ+Q3NHsQUgavIHsA734oCIr3y9QWkhlgCEA0g==}
ai@7.0.143:
resolution: {integrity: sha512-y9QNgCK9QRHRCcHEUNoU2vemJ+q2zFX5M9T7xHCMZutfcWCqZPckkAfDbp+KHwqZDiJs5AqoKazj64cb20m5PA==}
engines: {node: '>=22'}
peerDependencies:
zod: 4.5.4
@@ -15928,10 +15928,10 @@ snapshots:
'@vercel/oidc': 3.2.0
zod: 4.5.4
'@ai-sdk/gateway@4.0.90(zod@4.5.4)':
'@ai-sdk/gateway@4.0.116(zod@4.5.4)':
dependencies:
'@ai-sdk/provider': 4.0.18
'@ai-sdk/provider-utils': 5.0.46(zod@4.5.4)
'@ai-sdk/provider': 4.0.29
'@ai-sdk/provider-utils': 5.0.62(zod@4.5.4)
'@vercel/oidc': 3.2.0
zod: 4.5.4
@@ -15954,20 +15954,20 @@ snapshots:
eventsource-parser: 3.1.0
zod: 4.5.4
'@ai-sdk/provider-utils@5.0.46(zod@4.5.4)':
'@ai-sdk/provider-utils@5.0.62(zod@4.5.4)':
dependencies:
'@ai-sdk/provider': 4.0.18
'@ai-sdk/provider': 4.0.29
'@standard-schema/spec': 1.1.0
'@workflow/serde': 4.1.0
eventsource-parser: 3.1.0
undici: 7.29.0
undici: 7.30.0
zod: 4.5.4
'@ai-sdk/provider@3.0.10':
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@4.0.18':
'@ai-sdk/provider@4.0.29':
dependencies:
json-schema: 0.4.0
@@ -24025,11 +24025,11 @@ snapshots:
'@opentelemetry/api': 1.9.1
zod: 4.5.4
ai@7.0.112(zod@4.5.4):
ai@7.0.143(zod@4.5.4):
dependencies:
'@ai-sdk/gateway': 4.0.90(zod@4.5.4)
'@ai-sdk/provider': 4.0.18
'@ai-sdk/provider-utils': 5.0.46(zod@4.5.4)
'@ai-sdk/gateway': 4.0.116(zod@4.5.4)
'@ai-sdk/provider': 4.0.29
'@ai-sdk/provider-utils': 5.0.62(zod@4.5.4)
zod: 4.5.4
ajv-formats@3.0.1(ajv@8.18.0):