From 3b54ef7ab4f4c6f3bae3a2813f266f02a3539d97 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Tue, 15 Sep 2026 12:43:19 +0000 Subject: [PATCH] Add initial support for having GPUI release notes (#64203) Release Notes: - N/A --- .zed/settings.json | 4 ++ script/danger/dangerfile.ts | 48 ++++++++++++++++++++-- script/danger/package.json | 6 ++- script/danger/pnpm-lock.yaml | 25 ++++++++++++ script/danger/tsconfig.json | 11 +++++ script/draft-release-notes | 79 ++++++++++++++++++++++++++++-------- script/lib/release-notes.js | 27 ++++++++++++ 7 files changed, 176 insertions(+), 24 deletions(-) create mode 100644 script/danger/tsconfig.json create mode 100644 script/lib/release-notes.js diff --git a/.zed/settings.json b/.zed/settings.json index 903e97c5c1..10f37b3f52 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -26,6 +26,10 @@ "tab_size": 2, "formatter": "prettier", }, + "TypeScript": { + "tab_size": 2, + "formatter": "prettier", + }, "CSS": { "tab_size": 2, "formatter": "prettier", diff --git a/script/danger/dangerfile.ts b/script/danger/dangerfile.ts index 919624a861..56f9166b99 100644 --- a/script/danger/dangerfile.ts +++ b/script/danger/dangerfile.ts @@ -1,4 +1,6 @@ import { danger, message, warn, fail, schedule } from "danger"; +import { releaseNotesSection, releaseNotesEntries } from "../lib/release-notes"; + const { prHygiene } = require("danger-plugin-pr-hygiene"); prHygiene({ @@ -12,10 +14,9 @@ prHygiene({ }, }); -const RELEASE_NOTES_PATTERN = /Release Notes:(\r?\n)+- /gm; const body = danger.github.pr.body; - -const hasReleaseNotes = RELEASE_NOTES_PATTERN.test(body); +const releaseNotes = releaseNotesSection(body); +const hasReleaseNotes = releaseNotes !== ""; if (!hasReleaseNotes) { warn( @@ -36,14 +37,53 @@ if (!hasReleaseNotes) { "", "- N/A", "```", + "", + "If your change touches a `gpui`-related crate, you must also add an entry for the GPUI release notes:", + "```", + "Release Notes:", + "", + "- Added/Fixed/Improved ...", + "- [GPUI] Added/Fixed/Improved ...", + "```", + "", + 'If the change is not user-facing for GPUI users, use "- [GPUI] N/A" for that entry.', ].join("\n"), ); } +const GPUI_RELEASE_NOTES_PATTERN = /^- \[GPUI\]/im; + +const gpuiCrates = danger.git.fileMatch("crates/gpui*/**"); + +if (gpuiCrates.edited || gpuiCrates.deleted) { + if (!releaseNotesEntries(releaseNotes).some((entry) => GPUI_RELEASE_NOTES_PATTERN.test(entry))) { + const { edited, deleted } = gpuiCrates.getKeyedPaths(); + const touchedGpuiCratesStr = [...edited, ...deleted] + .map((file) => "`" + file.split("/")[1] + "`") + .filter((crate, index, self) => self.indexOf(crate) === index) + .join(", "); + fail( + [ + `This PR modifies GPUI crates (${touchedGpuiCratesStr}), which requires a GPUI release notes entry.`, + "", + 'Please add at least one entry prefixed with `[GPUI]` to the "Release Notes" section:', + "```", + "Release Notes:", + "", + "- Added/Fixed/Improved ...", + "- [GPUI] Added/Fixed/Improved ...", + "```", + "", + 'If the change is not user-facing for GPUI users, use "- [GPUI] N/A" for that entry.', + ].join("\n"), + ); + } +} + const ISSUE_LINK_PATTERN = /(?:- )?(?= 14'} @@ -400,6 +409,14 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + universal-user-agent@6.0.1: resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} @@ -500,6 +517,10 @@ snapshots: dependencies: '@octokit/openapi-types': 24.2.0 + '@types/node@24.13.4': + dependencies: + undici-types: 7.18.2 + agent-base@7.1.4: {} ansi-styles@4.3.0: @@ -824,6 +845,10 @@ snapshots: tr46@0.0.3: {} + typescript@5.9.3: {} + + undici-types@7.18.2: {} + universal-user-agent@6.0.1: {} webidl-conversions@3.0.1: {} diff --git a/script/danger/tsconfig.json b/script/danger/tsconfig.json new file mode 100644 index 0000000000..ceb7eddf4d --- /dev/null +++ b/script/danger/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "strict": true, + "allowJs": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["dangerfile.ts"] +} diff --git a/script/draft-release-notes b/script/draft-release-notes index da819da88e..ac32ce9ad8 100755 --- a/script/draft-release-notes +++ b/script/draft-release-notes @@ -1,21 +1,52 @@ #!/usr/bin/env node --redirect-warnings=/dev/null const { execFileSync } = require("child_process"); +const { parseArgs } = require("util"); +const { releaseNotesSection, releaseNotesEntries } = require("./lib/release-notes"); -main(); +const GPUI_PREFIX_PATTERN = /^- \[GPUI\]\s*/i; -async function main() { - let version = process.argv[2]; - let channel = process.argv[3]; - let parts = version.split("."); +const USAGE = [ + "Usage: draft-release-notes {stable|preview} [--gpui]", + "", + "By default, release notes entries prefixed with `- [GPUI]` are excluded.", + "With --gpui, only those entries are included, with the prefix removed.", +].join("\n"); - if ( - process.argv.length != 4 || - parts.length != 3 || - parts.find((part) => isNaN(part)) != null || - (channel != "stable" && channel != "preview") - ) { - console.log("Usage: draft-release-notes {stable|preview}"); +function parseArguments() { + let values, positionals; + try { + ({ values, positionals } = parseArgs({ + allowPositionals: true, + options: { gpui: { type: "boolean", default: false } }, + })); + } catch (e) { + return { error: e.message }; + } + + let [version, channel, ...rest] = positionals; + if (version == null || channel == null || rest.length > 0) { + return { error: "Expected exactly two positional arguments: {stable|preview}" }; + } + + let parts = version.split(".").map((part) => Number(part)); + if (parts.length != 3 || parts.some((part) => !Number.isInteger(part))) { + return { error: `Invalid version "${version}", expected ..` }; + } + + if (channel != "stable" && channel != "preview") { + return { error: `Invalid channel "${channel}", expected "stable" or "preview"` }; + } + + return { version, channel, parts, gpui: values.gpui }; +} + +function main() { + let { error, version, channel, parts, gpui } = parseArguments(); + if (error) { + console.error(error); + console.error(""); + console.error(USAGE); process.exit(1); } @@ -61,7 +92,8 @@ async function main() { for (const commit of newCommits) { let link = "https://github.com/zed-industries/zed/pull/" + commit.pr; - let notes = commit.releaseNotes; + let filteredNotes = filterReleaseNotes(commit.releaseNotes, gpui); + let notes = filteredNotes; if (commit.pr == "") { link = "https://github.com/zed-industries/zed/commits/" + commit.hash; } else if (!notes.includes("zed-industries/zed/issues")) { @@ -70,7 +102,7 @@ async function main() { if (commit.releaseNotes == "") { missing.push("- MISSING " + commit.firstLine + " " + link); - } else if (commit.releaseNotes.startsWith("- N/A")) { + } else if (filteredNotes == "" || filteredNotes.startsWith("- N/A")) { skipped.push("- N/A " + commit.firstLine + " " + link); } else { releaseNotes.push(notes); @@ -85,6 +117,16 @@ async function main() { } } +// Entries prefixed with `- [GPUI]` describe changes for GPUI users. They are kept +// (with the prefix removed) only when drafting GPUI release notes, and dropped otherwise. +function filterReleaseNotes(releaseNotes, gpui) { + return releaseNotes + .split("\n") + .filter((line) => GPUI_PREFIX_PATTERN.test(line) == gpui) + .map((line) => (gpui ? line.replace(GPUI_PREFIX_PATTERN, "- ") : line)) + .join("\n"); +} + function getCommits(oldTag, newTag) { const pullRequestNumbers = execFileSync( "git", @@ -98,10 +140,7 @@ function getCommits(oldTag, newTag) { let [hash, firstLine] = commit.split("\n")[0].split("|||"); let cherryPick = firstLine.match(/\(cherry-pick #([0-9]+)\)/)?.[1] || ""; let pr = firstLine.match(/\(#(\d+)\)$/)?.[1] || ""; - let releaseNotes = (commit.split(/Release notes:.*\n/i)[1] || "") - .split("\n\n")[0] - .trim() - .replace(/\n(?![\n-])/g, " "); + let releaseNotes = releaseNotesEntries(releaseNotesSection(commit)).join("\n"); if (releaseNotes.includes("")) { releaseNotes = ""; @@ -118,3 +157,7 @@ function getCommits(oldTag, newTag) { return pullRequestNumbers; } + +if (require.main === module) { + main(); +} diff --git a/script/lib/release-notes.js b/script/lib/release-notes.js new file mode 100644 index 0000000000..caf5c75a8a --- /dev/null +++ b/script/lib/release-notes.js @@ -0,0 +1,27 @@ +function releaseNotesSection(body) { + // Inline mentions and bullet text must not override the final release notes heading. + const headings = [...body.matchAll(/^Release Notes:(?:\r?\n)+(?=- )/gim)]; + const lastHeading = headings[headings.length - 1]; + return lastHeading ? body.slice(lastHeading.index + lastHeading[0].length) : ""; +} + +function releaseNotesEntries(section) { + const entries = []; + // Blank lines can separate bullets, but a non-bullet paragraph ends the notes + // so commit trailers and other trailing text are not published as release notes. + for (const paragraph of section.split(/\r?\n(?:[ \t]*\r?\n)+/)) { + if (!paragraph.startsWith("- ")) { + break; + } + + entries.push( + ...paragraph + .trimEnd() + .split(/\r?\n(?=- )/) + .map((entry) => entry.replace(/\r?\n/g, " ")), + ); + } + return entries; +} + +module.exports = { releaseNotesSection, releaseNotesEntries };