diff --git a/packages/release-tooling/package.json b/packages/release-tooling/package.json index a3ed325caa..21b76e6338 100644 --- a/packages/release-tooling/package.json +++ b/packages/release-tooling/package.json @@ -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", diff --git a/packages/release-tooling/src/ai/generated-copy.ts b/packages/release-tooling/src/ai/generated-copy.ts index 5191e1a8f1..6c1b411efd 100644 --- a/packages/release-tooling/src/ai/generated-copy.ts +++ b/packages/release-tooling/src/ai/generated-copy.ts @@ -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 { diff --git a/packages/release-tooling/src/ai/models.ts b/packages/release-tooling/src/ai/models.ts index e83676eb95..844713ae03 100644 --- a/packages/release-tooling/src/ai/models.ts +++ b/packages/release-tooling/src/ai/models.ts @@ -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"), }; diff --git a/packages/release-tooling/src/release-notes/render.ts b/packages/release-tooling/src/release-notes/render.ts index adb9f04572..b0d53a30f4 100644 --- a/packages/release-tooling/src/release-notes/render.ts +++ b/packages/release-tooling/src/release-notes/render.ts @@ -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, ): 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`, ); } diff --git a/packages/release-tooling/src/release-notes/repair.prompt.md b/packages/release-tooling/src/release-notes/repair.prompt.md index eebcbfa262..4d51f86973 100644 --- a/packages/release-tooling/src/release-notes/repair.prompt.md +++ b/packages/release-tooling/src/release-notes/repair.prompt.md @@ -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`. diff --git a/packages/release-tooling/src/release-notes/review.prompt.md b/packages/release-tooling/src/release-notes/review.prompt.md index 57d250adee..9d9eaa0a4a 100644 --- a/packages/release-tooling/src/release-notes/review.prompt.md +++ b/packages/release-tooling/src/release-notes/review.prompt.md @@ -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. diff --git a/packages/release-tooling/src/release-notes/rewrite.prompt.md b/packages/release-tooling/src/release-notes/rewrite.prompt.md index ecbc2d6dd0..1108e2ad30 100644 --- a/packages/release-tooling/src/release-notes/rewrite.prompt.md +++ b/packages/release-tooling/src/release-notes/rewrite.prompt.md @@ -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 diff --git a/packages/release-tooling/src/release-notes/rewrite.ts b/packages/release-tooling/src/release-notes/rewrite.ts index a449333fd9..8ea6702f3a 100644 --- a/packages/release-tooling/src/release-notes/rewrite.ts +++ b/packages/release-tooling/src/release-notes/rewrite.ts @@ -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); diff --git a/packages/release-tooling/src/release-notes/schema.ts b/packages/release-tooling/src/release-notes/schema.ts index f3f226a1e8..2f93f560f2 100644 --- a/packages/release-tooling/src/release-notes/schema.ts +++ b/packages/release-tooling/src/release-notes/schema.ts @@ -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({ diff --git a/packages/release-tooling/test/release-render.test.ts b/packages/release-tooling/test/release-render.test.ts index 3ed7ae720c..8703805b4c 100644 --- a/packages/release-tooling/test/release-render.test.ts +++ b/packages/release-tooling/test/release-render.test.ts @@ -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.", ); }); diff --git a/packages/release-tooling/test/release-rewrite.test.ts b/packages/release-tooling/test/release-rewrite.test.ts index a1cb2cc3ff..81a065bfea 100644 --- a/packages/release-tooling/test/release-rewrite.test.ts +++ b/packages/release-tooling/test/release-rewrite.test.ts @@ -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, }, ], } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2a4a88ca1..99b94dcfce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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):