From 81921ee70a27bbea800fb9ff2624a8a8e10b2459 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 02:27:19 -0700 Subject: [PATCH 01/13] feat(release): release notes lead with a short summary, full list folded under it Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 78 +++- apps/desktop/src/updates/releaseNotes.test.ts | 31 ++ apps/desktop/src/updates/releaseNotes.ts | 12 +- docs/operations/release.md | 1 + scripts/generate-release-notes.test.ts | 45 ++ scripts/generate-release-notes.ts | 390 ++++++++++++++++++ scripts/notify-discord-release.test.ts | 9 + scripts/notify-discord-release.ts | 8 +- 8 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 scripts/generate-release-notes.test.ts create mode 100644 scripts/generate-release-notes.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8f300242f054..923908b906537 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -740,11 +740,66 @@ jobs: resource_key: win32-arm64 cli_archive: true + # Writes a short model summary with GitHub's full list folded under it. It + # runs beside the builds, and the release falls back to GitHub's generated + # notes when no notes file comes out of it. + release_notes: + name: Write release notes + needs: [preflight] + if: needs.preflight.outputs.release_channel != 'preview' && needs.preflight.outputs.previous_tag != '' + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 10 + permissions: + # The generate-notes endpoint requires contents: write; nothing here pushes. + contents: write + pull-requests: read + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + # The commit range needs history back to the previous tag, not file contents. + fetch-depth: 0 + filter: blob:none + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/scripts... + + - name: Generate release notes + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + node scripts/generate-release-notes.ts \ + --repository "${{ github.repository }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --ref "${{ needs.preflight.outputs.ref }}" \ + --previous-tag "${{ needs.preflight.outputs.previous_tag }}" \ + --version "${{ needs.preflight.outputs.version }}" \ + --output release-notes.md + cat release-notes.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload release notes + if: hashFiles('release-notes.md') != '' + uses: actions/upload-artifact@v7 + with: + name: release-notes + path: release-notes.md + retention-days: 7 + release: name: Publish GitHub Release needs: [ preflight, + release_notes, relay_public_config, quality, test, @@ -757,7 +812,7 @@ jobs: desktop_win_x64, desktop_win_arm64, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} + if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: @@ -892,6 +947,23 @@ jobs: echo 'EOF' } >> "$GITHUB_OUTPUT" + - name: Download release notes + if: needs.preflight.outputs.release_channel != 'preview' + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: release-notes + + - id: release_notes + name: Check release notes + shell: bash + run: | + if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" && -s release-notes.md ]]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + fi + # A preview release gets a warning instead of generated notes. Generated # notes would list every commit since the previous preview, which is # unmerged branch history no one should read as a changelog, and would @@ -917,8 +989,8 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} - body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} + generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' && steps.release_notes.outputs.ready != 'true' }} + body_path: ${{ (needs.preflight.outputs.release_channel == 'preview' || steps.release_notes.outputs.ready == 'true') && 'release-notes.md' || '' }} previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 028e42fa6e3b7..fe0061c042dec 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -60,6 +60,37 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); + it("shows a generated summary in order and skips the folded full list", () => { + const fixes = Array.from({ length: 9 }, (_, index) => `- Fix ${index + 1} (#${index + 1})`); + const result = normalizeDesktopUpdateReleaseNotes( + [ + "**v1.2.4** · Oct 29, 2026 · 12 changes from 3 contributors in 2 days", + "## Highlights", + "Link machines (beta).", + "## Fixes", + ...fixes, + "
", + "Full changelog", + "* fix: raw entry by @human in https://github.com/pingdotgg/t3code/pull/1", + "
", + ].join("\n"), + "1.2.4", + "latest", + ); + + expect(result.releaseNotes).toEqual([ + { + version: "1.2.4", + items: [ + "v1.2.4 · Oct 29, 2026 · 12 changes from 3 contributors in 2 days", + "Link machines (beta).", + ...fixes.slice(0, 6).map((fix) => fix.slice(2)), + ], + totalItems: 11, + }, + ]); + }); + it("does not count Markdown or HTML section headings as changes", () => { const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); const result = normalizeDesktopUpdateReleaseNotes( diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 8698d8d499118..e6d14a7c4c07a 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -98,9 +98,12 @@ interface ExtractedReleaseNoteItems { function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { if (!note) return { items: [], totalItems: 0 }; + // Generated summaries fold GitHub's full list under
; show the summary in order. + const summaryEnd = note.search(/= 0; const items: string[] = []; let totalItems = 0; - for (const rawLine of stripMarkup(note).split("\n")) { + for (const rawLine of stripMarkup(isSummary ? note.slice(0, summaryEnd) : note).split("\n")) { const item = rawLine .trim() .replace(/^[-*]\s+/, "") @@ -111,10 +114,15 @@ function extractReleaseNoteItems(note: string | null | undefined): ExtractedRele if (/^#{1,6}\s+/.test(item)) continue; if (isIgnoredReleaseNoteLine(item)) continue; totalItems += 1; + if (isSummary) { + if (items.length < MAX_RELEASE_NOTE_ITEMS_PER_GROUP) + items.push(truncateReleaseNoteItem(item)); + continue; + } items.push(truncateReleaseNoteItem(item)); if (items.length > MAX_RELEASE_NOTE_ITEMS_PER_GROUP) items.shift(); } - return { items: items.toReversed(), totalItems }; + return { items: isSummary ? items : items.toReversed(), totalItems }; } interface NormalizedDesktopUpdateReleaseNotes { diff --git a/docs/operations/release.md b/docs/operations/release.md index 39f4f63ddd91b..0570a1af1955f 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -42,6 +42,7 @@ the version. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. + - The `release_notes` job (`scripts/generate-release-notes.ts`) writes the release body while the builds run: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
`. It needs the `OPENAI_API_KEY` secret. The summary covers web, desktop, and server only, because mobile-only commit scopes are filtered out. The desktop update popover and the nightly Discord post show only the part above `
`. If the OpenAI call fails, the body is the header plus GitHub's list. If the job produces no file, the release uses GitHub's generated notes as before. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts new file mode 100644 index 0000000000000..82a1c45fcd602 --- /dev/null +++ b/scripts/generate-release-notes.test.ts @@ -0,0 +1,45 @@ +import { assert, it } from "@effect/vitest"; + +import { + countContributors, + isReleaseNoteCommit, + type PullRequest, +} from "./generate-release-notes.ts"; + +const pullRequest = (author: PullRequest["author"]): PullRequest => ({ + body: "", + additions: 1, + deletions: 1, + changedFiles: 1, + author, +}); + +it("counts each person once and leaves out bots", () => { + const commits = [ + { subject: "fix(web): one (#1)", authorName: "Ada Lovelace" }, + { subject: "fix(web): two (#2)", authorName: "Ada Lovelace" }, + { subject: "chore: bump (#3)", authorName: "dependabot[bot]" }, + { subject: "fix: direct push by a PR author", authorName: "Ada Lovelace" }, + { subject: "fix: direct push by someone else", authorName: "Grace Hopper" }, + ]; + const pullRequests = new Map([ + [1, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], + [2, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], + [3, pullRequest({ __typename: "Bot", login: "dependabot" })], + ]); + + assert.equal(countContributors(commits, pullRequests), 2); +}); + +it("leaves mobile-only scopes out and keeps shared ones", () => { + const subjects = { + "fix(mobile): a": false, + "feat(android): b": false, + "fix(ios)!: c": false, + "fix(web,mobile): d": true, + "fix(server): e": true, + }; + for (const [subject, kept] of Object.entries(subjects)) { + assert.equal(isReleaseNoteCommit({ subject, authorName: "" }), kept, subject); + } +}); diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts new file mode 100644 index 0000000000000..19e5da726cd0c --- /dev/null +++ b/scripts/generate-release-notes.ts @@ -0,0 +1,390 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Config from "effect/Config"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/cli"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +// Release notes cover web, desktop, and server. Mobile ships its own store notes. +const MOBILE_ONLY_SCOPE = /^[a-z]+\((?:mobile|android|ios)[^)]*\)!?:/i; +const PULL_REQUEST_NUMBER = /\(#(\d+)\)$/; +const PULL_REQUEST_BODY_LIMIT = 300; +const SUMMARY_MODEL = "gpt-6.1-terra"; + +const SUMMARY_INSTRUCTIONS = `You write release notes for T3 Code, a GUI for coding agents (web and desktop apps backed by a server; providers like Codex, Claude, Cursor, OpenCode). + +These notes cover the web app, desktop app, and server only. The mobile app has its own release notes, so leave out anything only about mobile, iOS, or Android. + +You get the commit subjects merged since the last release, oldest first. Most end with a PR number like (#1234). Each commit shows lines added and removed and files changed, then a trimmed PR description on the next line starting with ">". Use the descriptions to understand what changed for users and why it matters. Line counts hint at size, but a small change can still matter most. + +Output Markdown in exactly this shape and nothing else: + +## Highlights +2 to 4 short sentences. Name only the new features a user would care about most. Each feature gets a few words that make the benefit clear, not a description of how it works or where the button is. + +## Fixes +- 5 to 10 bullets, one fix each, under 10 words, ending with the PR number like (#123). +- Each bullet names what was broken and says it works now. A user who hit the bug should recognize it. +- Only list fixes from fix commits. New abilities go in Highlights or nowhere. Say "again" only if the commit says it. + +How to write: +- Pick only significant, user-facing changes. Leave out visual polish (focus rings, spacing, colors), layout tweaks, refactors, tests, CI, build, docs, deps, internal tooling, and performance or efficiency work a user would not feel. +- Include Labs and beta features, with "(beta)" after the feature. Leave out work marked internal testing or dev-only. +- Describe each feature as it is at the end of the range. If a beta feature became standard later in the range, it is not beta. +- Say what the user gets, as short as possible. Cut details, conditions, and mechanics. +- No defaults or rollout state ("on by default", "now available", "behind a flag"). +- For lists of file types or formats, use common extensions: "previews for .docx, .ttf, .sqlite, .zip and more". +- No internal names, env vars, file paths, or code terms. +- No em dashes. No hype words. Do not invent anything that is not in the input. + +Examples of the tone: +- Bad: "Cmd+K now finds the right thread first, and it searches message text too." Good: "Much better Cmd+K search." +- Bad: "You can share a thread with a guest who watches it live and sends messages that you approve." Good: "Share threads with guests, safely." +- Bad: "Triage issues from the command palette." Good: "Fix and debug T3 Code itself with a new 'triage' command." +- Bad: "Settings and Usage open as large modals over your thread." Good: leave it out. +- Bad: "Cmd/Ctrl+W closes tabs safely (#25860)" Good: "Cmd/Ctrl+W no longer closes the whole window (#25860)"`; + +export class EmptyReleaseRangeError extends Schema.TaggedError()( + "EmptyReleaseRangeError", + { range: Schema.String }, +) {} + +export interface ReleaseCommit { + readonly subject: string; + readonly authorName: string; +} + +const PullRequest = Schema.Struct({ + body: Schema.String, + additions: Schema.Number, + deletions: Schema.Number, + changedFiles: Schema.Number, + author: Schema.NullOr( + Schema.Struct({ + __typename: Schema.String, + login: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); +export type PullRequest = typeof PullRequest.Type; + +const PullRequestQueryResponse = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Record(Schema.String, Schema.NullOr(PullRequest)), + }), +}); + +const GeneratedNotes = Schema.Struct({ body: Schema.String }); + +const SummaryResponse = Schema.Struct({ + output: Schema.Array( + Schema.Struct({ + type: Schema.String, + content: Schema.optional( + Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) })), + ), + }), + ), + usage: Schema.optional( + Schema.Struct({ input_tokens: Schema.Number, output_tokens: Schema.Number }), + ), +}); + +export const pullRequestNumber = (subject: string) => { + const match = PULL_REQUEST_NUMBER.exec(subject); + return match ? Number(match[1]) : undefined; +}; + +export const isReleaseNoteCommit = (commit: ReleaseCommit) => + !MOBILE_ONLY_SCOPE.test(commit.subject); + +/** Keeps the prose of a PR body: no images, tables, headings, HTML, or verification sections. */ +export function trimPullRequestBody(body: string, limit = PULL_REQUEST_BODY_LIMIT): string { + const [prose = ""] = body.split(/\n#+\s*(?:Verification|Council|Test|Testing|Screenshots)\b/i); + const text = prose + .split("\n") + .filter((line) => line.trim() !== "" && !/^\s*[!|#<]/.test(line)) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + return text.length > limit ? `${text.slice(0, limit)}...` : text; +} + +export function buildSummaryInput( + commits: ReadonlyArray, + pullRequests: ReadonlyMap, +): string { + return commits + .flatMap(({ subject }) => { + const number = pullRequestNumber(subject); + const pullRequest = number === undefined ? undefined : pullRequests.get(number); + if (!pullRequest) return [subject]; + const line = `${subject} [+${pullRequest.additions} -${pullRequest.deletions}, ${pullRequest.changedFiles} files]`; + const body = trimPullRequestBody(pullRequest.body); + return body ? [line, ` > ${body}`] : [line]; + }) + .join("\n"); +} + +/** PR authors without bots, plus authors of direct pushes who have no PR in the range. */ +export function countContributors( + commits: ReadonlyArray, + pullRequests: ReadonlyMap, +): number { + const people = new Set(); + const names = new Set(); + for (const { author } of pullRequests.values()) { + if (!author || author.__typename === "Bot" || author.login.endsWith("[bot]")) continue; + people.add(author.login); + if (author.name) names.add(author.name); + } + for (const commit of commits) { + if (pullRequestNumber(commit.subject) === undefined && !names.has(commit.authorName)) { + people.add(commit.authorName); + } + } + return people.size; +} + +export function formatElapsed(milliseconds: number): string { + const units = [ + ["day", 86_400_000], + ["hour", 3_600_000], + ["minute", 60_000], + ] as const; + for (const [unit, size] of units) { + const count = Math.floor(milliseconds / size); + if (count >= 1) return `${count} ${unit}${count === 1 ? "" : "s"}`; + } + return "under a minute"; +} + +export function buildReleaseHeader(options: { + readonly version: string; + /** Epoch milliseconds. */ + readonly builtAt: number; + readonly previousBuiltAt: number; + readonly changes: number; + readonly contributors: number; +}): string { + const date = DateTime.formatUtc(DateTime.makeUnsafe(options.builtAt), { + locale: "en-US", + month: "short", + day: "numeric", + year: "numeric", + }); + const elapsed = formatElapsed(options.builtAt - options.previousBuiltAt); + const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; + return `**v${options.version.replace(/^v/, "")}** · ${date} · ${plural(options.changes, "change")} from ${plural(options.contributors, "contributor")} in ${elapsed}`; +} + +/** The summary leads; GitHub's full list folds under it. Clients cut the body at `
`. */ +export function assembleReleaseNotes( + header: string, + summary: Option.Option, + fullChangelog: string, +): string { + if (Option.isNone(summary)) return `${header}\n\n${fullChangelog.trim()}\n`; + return `${header}\n\n${summary.value.trim()}\n\n
\nFull changelog\n\n${fullChangelog.trim()}\n\n
\n`; +} + +const run = Effect.fn("generateReleaseNotes.run")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner.string(ChildProcess.make(command, args)); +}); + +const decodeJson = (schema: S) => + Schema.decodeUnknownEffect(Schema.fromJsonString(schema)); + +const readCommits = Effect.fn("generateReleaseNotes.readCommits")(function* ( + previousTag: string, + ref: string, +) { + const log = yield* run("git", [ + "log", + "--reverse", + "--format=%s%x1f%an", + `${previousTag}..${ref}`, + ]); + return log + .split("\n") + .filter((line) => line !== "") + .map((line): ReleaseCommit => { + const [subject = "", authorName = ""] = line.split("\x1f"); + return { subject, authorName }; + }) + .filter(isReleaseNoteCommit); +}); + +const readCommitDate = Effect.fn("generateReleaseNotes.readCommitDate")(function* (ref: string) { + return Date.parse((yield* run("git", ["log", "-1", "--format=%cI", ref])).trim()); +}); + +const readPullRequests = Effect.fn("generateReleaseNotes.readPullRequests")(function* ( + repository: string, + numbers: ReadonlyArray, +) { + const [owner, name] = repository.split("/"); + const pullRequests = new Map(); + for (let start = 0; start < numbers.length; start += 100) { + const fields = numbers + .slice(start, start + 100) + .map( + (number) => + `pr${number}: pullRequest(number: ${number}) { body additions deletions changedFiles author { __typename login ... on User { name } } }`, + ) + .join("\n"); + const query = `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields} } }`; + const response = yield* decodeJson(PullRequestQueryResponse)( + yield* run("gh", [ + "api", + "graphql", + "-f", + `query=${query}`, + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + ]), + ); + for (const [alias, pullRequest] of Object.entries(response.data.repository)) { + if (pullRequest) pullRequests.set(Number(alias.slice(2)), pullRequest); + } + } + return pullRequests; +}); + +const readFullChangelog = Effect.fn("generateReleaseNotes.readFullChangelog")(function* (options: { + readonly repository: string; + readonly tag: string; + readonly ref: string; + readonly previousTag: string; +}) { + const response = yield* run("gh", [ + "api", + `repos/${options.repository}/releases/generate-notes`, + "-f", + `tag_name=${options.tag}`, + "-f", + `target_commitish=${options.ref}`, + "-f", + `previous_tag_name=${options.previousTag}`, + ]); + return (yield* decodeJson(GeneratedNotes)(response)).body; +}); + +const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: string) { + const apiKey = yield* Config.Redacted("OPENAI_API_KEY"); + const httpClient = yield* HttpClient.HttpClient; + const response = yield* HttpClientRequest.post("https://api.openai.com/v1/responses").pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.bodyJson({ + model: SUMMARY_MODEL, + reasoning: { effort: "low" }, + instructions: SUMMARY_INSTRUCTIONS, + input: `Commits:\n${input}`, + }), + Effect.flatMap(httpClient.execute), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap(Schema.decodeUnknownEffect(SummaryResponse)), + Effect.timeout("3 minutes"), + ); + yield* Effect.logInfo("release summary generated").pipe( + Effect.annotateLogs({ + model: SUMMARY_MODEL, + inputTokens: response.usage?.input_tokens, + outputTokens: response.usage?.output_tokens, + }), + ); + const text = response.output + .flatMap((item) => item.content ?? []) + .flatMap((part) => (part.type === "output_text" && part.text ? [part.text] : [])) + .join("") + .trim(); + return text === "" ? Option.none() : Option.some(text); +}); + +const command = Command.make( + "generate-release-notes", + { + repository: Flag.String("repository").pipe(Flag.withDescription("GitHub owner/name.")), + tag: Flag.String("tag").pipe(Flag.withDescription("Tag being released.")), + ref: Flag.String("ref").pipe(Flag.withDescription("Commit being released.")), + previousTag: Flag.String("previous-tag").pipe(Flag.withDescription("Previous release tag.")), + version: Flag.String("version").pipe(Flag.withDescription("Version shown in the header.")), + output: Flag.String("output").pipe(Flag.withDescription("File to write the notes to.")), + }, + ({ repository, tag, ref, previousTag, version, output }) => + Effect.gen(function* () { + const commits = yield* readCommits(previousTag, ref); + // An empty log usually means the previous tag is missing from a shallow clone. + if (commits.length === 0) { + return yield* new EmptyReleaseRangeError({ range: `${previousTag}..${ref}` }); + } + const numbers = [ + ...new Set(commits.flatMap(({ subject }) => pullRequestNumber(subject) ?? [])), + ]; + const [pullRequests, fullChangelog, builtAt, previousBuiltAt] = yield* Effect.all( + [ + readPullRequests(repository, numbers), + readFullChangelog({ repository, tag, ref, previousTag }), + readCommitDate(ref), + readCommitDate(previousTag), + ], + { concurrency: "unbounded" }, + ); + const header = buildReleaseHeader({ + version, + builtAt, + previousBuiltAt, + changes: commits.length, + contributors: countContributors(commits, pullRequests), + }); + // A failed summary still publishes the header and GitHub's full list. + const summary = yield* summarize(buildSummaryInput(commits, pullRequests)).pipe( + Effect.catchCause((cause) => + Effect.logWarning("release summary failed, publishing the full list only", cause).pipe( + Effect.as(Option.none()), + ), + ), + ); + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(output, assembleReleaseNotes(header, summary, fullChangelog)); + yield* Effect.logInfo("release notes written").pipe( + Effect.annotateLogs({ + output, + commits: commits.length, + summarized: Option.isSome(summary), + }), + ); + }), +).pipe( + Command.withDescription("Write release notes with a model summary over GitHub's full list."), +); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide( + Layer.mergeAll( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + FetchHttpClient.layer, + ), + ), + NodeRuntime.runMain, + ); +} diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index 6e57ffb56a7ef..7568b214cb951 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -132,6 +132,15 @@ it("preserves custom notes and links outside generated GitHub pull request entri ); }); +it("posts a generated summary without its folded full list", () => { + const summary = "**v1.2.3** · 4 changes\n\n## Fixes\n- Stop works again (#1)"; + const releaseNotes = `${summary}\n\n
\nFull changelog\n\n* fix: raw by @someone in https://github.com/pingdotgg/t3code/pull/1\n\n
\n`; + assert.equal( + payloadDescription(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes })), + `${intro}\n\n${summary}\n\n[Full changelog](${nightlyAnnouncement.releaseUrl.href})`, + ); +}); + it("falls back when notes are missing, empty, or whitespace", () => { const fallback = buildDiscordReleaseAnnouncement(nightlyAnnouncement); assert.equal(payloadDescription(fallback), intro); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 0eb3bf348530a..977d1e9c420ca 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -193,9 +193,15 @@ export const buildDiscordReleaseAnnouncement = ( options.target === "prerelease" ? "A new T3 Code prerelease is available for nightly testers." : `[View full release notes on GitHub](${options.releaseUrl.href})`; + // Generated summaries fold the full list under
, which Discord cannot render. + const [summary = "", folded] = (options.releaseNotes ?? "").split(/^
/m); const notes = options.target === "prerelease" - ? suppressMentions(formatReleaseNotes(options.releaseNotes?.trim() ?? "")) + ? suppressMentions( + folded === undefined + ? formatReleaseNotes(summary.trim()) + : `${summary.trim()}\n\n[Full changelog](${options.releaseUrl.href})`, + ) : ""; const description = notes ? `${intro}\n\n${notes}` : intro; // Separate embeds avoid URL deduplication; reserve title and footer space within Discord's 6000-character limit. From 4e302c0202467fcb5be9aae81e8233de69976a43 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 02:40:18 -0700 Subject: [PATCH 02/13] fix(release): notes run inside the release job and never block it Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 96 +++++------------ apps/desktop/src/updates/releaseNotes.test.ts | 2 +- apps/desktop/src/updates/releaseNotes.ts | 4 +- docs/operations/release.md | 2 +- scripts/generate-release-notes.test.ts | 2 + scripts/generate-release-notes.ts | 102 ++++++++++-------- scripts/notify-discord-release.test.ts | 2 +- scripts/notify-discord-release.ts | 8 +- 8 files changed, 95 insertions(+), 123 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 923908b906537..596193a1c667a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -740,66 +740,11 @@ jobs: resource_key: win32-arm64 cli_archive: true - # Writes a short model summary with GitHub's full list folded under it. It - # runs beside the builds, and the release falls back to GitHub's generated - # notes when no notes file comes out of it. - release_notes: - name: Write release notes - needs: [preflight] - if: needs.preflight.outputs.release_channel != 'preview' && needs.preflight.outputs.previous_tag != '' - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - # The generate-notes endpoint requires contents: write; nothing here pushes. - contents: write - pull-requests: read - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ needs.preflight.outputs.ref }} - # The commit range needs history back to the previous tag, not file contents. - fetch-depth: 0 - filter: blob:none - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false - - - name: Setup dependencies - uses: ./.github/actions/setup-ci-deps - with: - install-args: --filter=@t3tools/scripts... - - - name: Generate release notes - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - node scripts/generate-release-notes.ts \ - --repository "${{ github.repository }}" \ - --tag "${{ needs.preflight.outputs.tag }}" \ - --ref "${{ needs.preflight.outputs.ref }}" \ - --previous-tag "${{ needs.preflight.outputs.previous_tag }}" \ - --version "${{ needs.preflight.outputs.version }}" \ - --output release-notes.md - cat release-notes.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload release notes - if: hashFiles('release-notes.md') != '' - uses: actions/upload-artifact@v7 - with: - name: release-notes - path: release-notes.md - retention-days: 7 - release: name: Publish GitHub Release needs: [ preflight, - release_notes, relay_public_config, quality, test, @@ -812,16 +757,21 @@ jobs: desktop_win_x64, desktop_win_arm64, ] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: contents: write + # Release notes read PR bodies and authors. + pull-requests: read steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + # Release notes need history back to the previous tag, not file contents. + fetch-depth: 0 + filter: blob:none sparse-checkout: | /* !/.repos/ @@ -947,22 +897,24 @@ jobs: echo 'EOF' } >> "$GITHUB_OUTPUT" - - name: Download release notes - if: needs.preflight.outputs.release_channel != 'preview' + # A short model summary over GitHub's full list. Without a notes file, the + # release falls back to GitHub's generated notes. + - name: Write release notes + if: needs.preflight.outputs.release_channel != 'preview' && needs.preflight.outputs.previous_tag != '' continue-on-error: true - uses: actions/download-artifact@v8 - with: - name: release-notes - - - id: release_notes - name: Check release notes - shell: bash + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: | - if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" && -s release-notes.md ]]; then - echo "ready=true" >> "$GITHUB_OUTPUT" - else - echo "ready=false" >> "$GITHUB_OUTPUT" - fi + node scripts/generate-release-notes.ts \ + --repository "${{ github.repository }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --ref "${{ needs.preflight.outputs.ref }}" \ + --previous-tag "${{ needs.preflight.outputs.previous_tag }}" \ + --version "${{ needs.preflight.outputs.version }}" \ + --output release-notes.md + cat release-notes.md >> "$GITHUB_STEP_SUMMARY" # A preview release gets a warning instead of generated notes. Generated # notes would list every commit since the previous preview, which is @@ -989,8 +941,8 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' && steps.release_notes.outputs.ready != 'true' }} - body_path: ${{ (needs.preflight.outputs.release_channel == 'preview' || steps.release_notes.outputs.ready == 'true') && 'release-notes.md' || '' }} + generate_release_notes: ${{ hashFiles('release-notes.md') == '' }} + body_path: ${{ hashFiles('release-notes.md') != '' && 'release-notes.md' || '' }} previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index fe0061c042dec..f8abab64d9ddb 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -84,7 +84,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { items: [ "v1.2.4 · Oct 29, 2026 · 12 changes from 3 contributors in 2 days", "Link machines (beta).", - ...fixes.slice(0, 6).map((fix) => fix.slice(2)), + ...fixes.map((fix) => fix.slice(2)), ], totalItems: 11, }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index e6d14a7c4c07a..6c82af6d19a31 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -114,9 +114,9 @@ function extractReleaseNoteItems(note: string | null | undefined): ExtractedRele if (/^#{1,6}\s+/.test(item)) continue; if (isIgnoredReleaseNoteLine(item)) continue; totalItems += 1; + // A summary is already short; cutting it would drop the highlights mid-sentence. if (isSummary) { - if (items.length < MAX_RELEASE_NOTE_ITEMS_PER_GROUP) - items.push(truncateReleaseNoteItem(item)); + items.push(item); continue; } items.push(truncateReleaseNoteItem(item)); diff --git a/docs/operations/release.md b/docs/operations/release.md index 0570a1af1955f..f607527c3e459 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -42,7 +42,7 @@ the version. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - - The `release_notes` job (`scripts/generate-release-notes.ts`) writes the release body while the builds run: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
`. It needs the `OPENAI_API_KEY` secret. The summary covers web, desktop, and server only, because mobile-only commit scopes are filtered out. The desktop update popover and the nightly Discord post show only the part above `
`. If the OpenAI call fails, the body is the header plus GitHub's list. If the job produces no file, the release uses GitHub's generated notes as before. + - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
`. It needs the `OPENAI_API_KEY` secret. The summary leaves out commits whose scopes are all mobile; the header counts every commit. The desktop update popover and the nightly Discord post show only the part above `
`. If the step fails or there is nothing to summarize, it writes no file and the release uses GitHub's generated notes as before. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts index 82a1c45fcd602..615d53a0acb2d 100644 --- a/scripts/generate-release-notes.test.ts +++ b/scripts/generate-release-notes.test.ts @@ -21,6 +21,7 @@ it("counts each person once and leaves out bots", () => { { subject: "chore: bump (#3)", authorName: "dependabot[bot]" }, { subject: "fix: direct push by a PR author", authorName: "Ada Lovelace" }, { subject: "fix: direct push by someone else", authorName: "Grace Hopper" }, + { subject: "chore: direct push by a bot", authorName: "t3-code[bot]" }, ]; const pullRequests = new Map([ [1, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], @@ -37,6 +38,7 @@ it("leaves mobile-only scopes out and keeps shared ones", () => { "feat(android): b": false, "fix(ios)!: c": false, "fix(web,mobile): d": true, + "feat(mobile,web): f": true, "fix(server): e": true, }; for (const [subject, kept] of Object.entries(subjects)) { diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index 19e5da726cd0c..d17dd1ea9d4f5 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -15,7 +15,8 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr import { ChildProcess, ChildProcessSpawner } from "effect/process"; // Release notes cover web, desktop, and server. Mobile ships its own store notes. -const MOBILE_ONLY_SCOPE = /^[a-z]+\((?:mobile|android|ios)[^)]*\)!?:/i; +const MOBILE_SCOPES = new Set(["mobile", "android", "ios"]); +const COMMIT_SCOPES = /^[a-z]+\(([^)]*)\)!?:/i; const PULL_REQUEST_NUMBER = /\(#(\d+)\)$/; const PULL_REQUEST_BODY_LIMIT = 300; const SUMMARY_MODEL = "gpt-6.1-terra"; @@ -58,7 +59,7 @@ export class EmptyReleaseRangeError extends Schema.TaggedError { +const pullRequestNumber = (subject: string) => { const match = PULL_REQUEST_NUMBER.exec(subject); return match ? Number(match[1]) : undefined; }; -export const isReleaseNoteCommit = (commit: ReleaseCommit) => - !MOBILE_ONLY_SCOPE.test(commit.subject); +/** Drops a commit only when every scope is mobile, so `fix(web,mobile)` stays. */ +export function isReleaseNoteCommit(commit: ReleaseCommit): boolean { + const scopes = COMMIT_SCOPES.exec(commit.subject)?.[1]?.split(","); + return !scopes?.every((scope) => MOBILE_SCOPES.has(scope.trim().toLowerCase())); +} /** Keeps the prose of a PR body: no images, tables, headings, HTML, or verification sections. */ -export function trimPullRequestBody(body: string, limit = PULL_REQUEST_BODY_LIMIT): string { +function trimPullRequestBody(body: string, limit = PULL_REQUEST_BODY_LIMIT): string { const [prose = ""] = body.split(/\n#+\s*(?:Verification|Council|Test|Testing|Screenshots)\b/i); const text = prose .split("\n") @@ -120,7 +124,7 @@ export function trimPullRequestBody(body: string, limit = PULL_REQUEST_BODY_LIMI return text.length > limit ? `${text.slice(0, limit)}...` : text; } -export function buildSummaryInput( +function buildSummaryInput( commits: ReadonlyArray, pullRequests: ReadonlyMap, ): string { @@ -149,14 +153,18 @@ export function countContributors( if (author.name) names.add(author.name); } for (const commit of commits) { - if (pullRequestNumber(commit.subject) === undefined && !names.has(commit.authorName)) { + if ( + pullRequestNumber(commit.subject) === undefined && + !commit.authorName.endsWith("[bot]") && + !names.has(commit.authorName) + ) { people.add(commit.authorName); } } return people.size; } -export function formatElapsed(milliseconds: number): string { +function formatElapsed(milliseconds: number): string { const units = [ ["day", 86_400_000], ["hour", 3_600_000], @@ -169,7 +177,7 @@ export function formatElapsed(milliseconds: number): string { return "under a minute"; } -export function buildReleaseHeader(options: { +function buildReleaseHeader(options: { readonly version: string; /** Epoch milliseconds. */ readonly builtAt: number; @@ -189,13 +197,8 @@ export function buildReleaseHeader(options: { } /** The summary leads; GitHub's full list folds under it. Clients cut the body at `
`. */ -export function assembleReleaseNotes( - header: string, - summary: Option.Option, - fullChangelog: string, -): string { - if (Option.isNone(summary)) return `${header}\n\n${fullChangelog.trim()}\n`; - return `${header}\n\n${summary.value.trim()}\n\n
\nFull changelog\n\n${fullChangelog.trim()}\n\n
\n`; +function assembleReleaseNotes(header: string, summary: string, fullChangelog: string): string { + return `${header}\n\n${summary.trim()}\n\n
\nFull changelog\n\n${fullChangelog.trim()}\n\n
\n`; } const run = Effect.fn("generateReleaseNotes.run")(function* ( @@ -225,13 +228,24 @@ const readCommits = Effect.fn("generateReleaseNotes.readCommits")(function* ( .map((line): ReleaseCommit => { const [subject = "", authorName = ""] = line.split("\x1f"); return { subject, authorName }; - }) - .filter(isReleaseNoteCommit); + }); }); -const readCommitDate = Effect.fn("generateReleaseNotes.readCommitDate")(function* (ref: string) { - return Date.parse((yield* run("git", ["log", "-1", "--format=%cI", ref])).trim()); -}); +/** When the previous release was published, else its commit time. Stable promotes an older nightly commit. */ +const readPreviousReleaseTime = Effect.fn("generateReleaseNotes.readPreviousReleaseTime")( + function* (repository: string, previousTag: string) { + const published = Date.parse( + yield* run("gh", [ + "api", + `repos/${repository}/releases/tags/${previousTag}`, + "--jq", + ".published_at", + ]).pipe(Effect.map((output) => output.trim())), + ); + if (!Number.isNaN(published)) return published; + return Date.parse((yield* run("git", ["log", "-1", "--format=%cI", previousTag])).trim()); + }, +); const readPullRequests = Effect.fn("generateReleaseNotes.readPullRequests")(function* ( repository: string, @@ -330,46 +344,44 @@ const command = Command.make( }, ({ repository, tag, ref, previousTag, version, output }) => Effect.gen(function* () { - const commits = yield* readCommits(previousTag, ref); - // An empty log usually means the previous tag is missing from a shallow clone. - if (commits.length === 0) { + // The header counts everything in the folded list; the summary skips mobile-only work. + const allCommits = yield* readCommits(previousTag, ref); + // The spawner ignores exit codes, so a failed git log shows up as an empty range. + if (allCommits.length === 0) { return yield* new EmptyReleaseRangeError({ range: `${previousTag}..${ref}` }); } + const commits = allCommits.filter(isReleaseNoteCommit); const numbers = [ - ...new Set(commits.flatMap(({ subject }) => pullRequestNumber(subject) ?? [])), + ...new Set(allCommits.flatMap(({ subject }) => pullRequestNumber(subject) ?? [])), ]; - const [pullRequests, fullChangelog, builtAt, previousBuiltAt] = yield* Effect.all( + const [pullRequests, fullChangelog, previousBuiltAt] = yield* Effect.all( [ readPullRequests(repository, numbers), readFullChangelog({ repository, tag, ref, previousTag }), - readCommitDate(ref), - readCommitDate(previousTag), + readPreviousReleaseTime(repository, previousTag), ], { concurrency: "unbounded" }, ); + const builtAt = DateTime.toEpochMillis(yield* DateTime.now); const header = buildReleaseHeader({ version, builtAt, previousBuiltAt, - changes: commits.length, - contributors: countContributors(commits, pullRequests), + changes: allCommits.length, + contributors: countContributors(allCommits, pullRequests), }); - // A failed summary still publishes the header and GitHub's full list. - const summary = yield* summarize(buildSummaryInput(commits, pullRequests)).pipe( - Effect.catchCause((cause) => - Effect.logWarning("release summary failed, publishing the full list only", cause).pipe( - Effect.as(Option.none()), - ), - ), - ); + // With no summary, write nothing: the release falls back to GitHub's generated notes. + const summary = + commits.length === 0 + ? Option.none() + : yield* summarize(buildSummaryInput(commits, pullRequests)); + if (Option.isNone(summary)) { + return yield* Effect.logWarning("no release summary, leaving GitHub's notes in place"); + } const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(output, assembleReleaseNotes(header, summary, fullChangelog)); + yield* fs.writeFileString(output, assembleReleaseNotes(header, summary.value, fullChangelog)); yield* Effect.logInfo("release notes written").pipe( - Effect.annotateLogs({ - output, - commits: commits.length, - summarized: Option.isSome(summary), - }), + Effect.annotateLogs({ output, commits: commits.length }), ); }), ).pipe( diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index 7568b214cb951..84a43869be6a7 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -137,7 +137,7 @@ it("posts a generated summary without its folded full list", () => { const releaseNotes = `${summary}\n\n
\nFull changelog\n\n* fix: raw by @someone in https://github.com/pingdotgg/t3code/pull/1\n\n
\n`; assert.equal( payloadDescription(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes })), - `${intro}\n\n${summary}\n\n[Full changelog](${nightlyAnnouncement.releaseUrl.href})`, + `${intro}\n\n**v1.2.3** · 4 changes\n\n## Fixes\n- Stop works again ([#1](https://github.com/pingdotgg/t3code/pull/1))\n\n[Full changelog](${nightlyAnnouncement.releaseUrl.href})`, ); }); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 977d1e9c420ca..29d94561c8fbc 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -135,6 +135,12 @@ const suppressMentions = (text: string) => const escapeLinkLabel = (text: string) => text.replace(/[\\[\]]/g, "\\$&"); +/** GitHub links `(#123)` on the release page; Discord needs the link written out. */ +function linkPullRequestNumbers(notes: string, releaseUrl: URL) { + const repositoryUrl = releaseUrl.href.split("/releases/")[0]; + return notes.replace(/#(\d+)\b/g, `[#$1](${repositoryUrl}/pull/$1)`); +} + function formatReleaseNotes(notes: string) { return notes .replace( @@ -200,7 +206,7 @@ export const buildDiscordReleaseAnnouncement = ( ? suppressMentions( folded === undefined ? formatReleaseNotes(summary.trim()) - : `${summary.trim()}\n\n[Full changelog](${options.releaseUrl.href})`, + : `${linkPullRequestNumbers(summary.trim(), options.releaseUrl)}\n\n[Full changelog](${options.releaseUrl.href})`, ) : ""; const description = notes ? `${intro}\n\n${notes}` : intro; From 0627538de85ecf26d6492ecba746366e5aa46599 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 02:45:11 -0700 Subject: [PATCH 03/13] fix(release): a skipped summary no longer marks the notes step failed Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 596193a1c667a..e9c731cf6fa06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -914,7 +914,9 @@ jobs: --previous-tag "${{ needs.preflight.outputs.previous_tag }}" \ --version "${{ needs.preflight.outputs.version }}" \ --output release-notes.md - cat release-notes.md >> "$GITHUB_STEP_SUMMARY" + if [[ -f release-notes.md ]]; then + cat release-notes.md >> "$GITHUB_STEP_SUMMARY" + fi # A preview release gets a warning instead of generated notes. Generated # notes would list every commit since the previous preview, which is From 86913cd598791192928468f066668d1b04dab5ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 02:45:45 -0700 Subject: [PATCH 04/13] chore(release): tidy release notes script comment and export Co-Authored-By: Claude Opus 5.5 --- scripts/generate-release-notes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index d17dd1ea9d4f5..ae67794eba09a 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -54,7 +54,7 @@ Examples of the tone: - Bad: "Settings and Usage open as large modals over your thread." Good: leave it out. - Bad: "Cmd/Ctrl+W closes tabs safely (#25860)" Good: "Cmd/Ctrl+W no longer closes the whole window (#25860)"`; -export class EmptyReleaseRangeError extends Schema.TaggedError()( +class EmptyReleaseRangeError extends Schema.TaggedError()( "EmptyReleaseRangeError", { range: Schema.String }, ) {} @@ -344,7 +344,7 @@ const command = Command.make( }, ({ repository, tag, ref, previousTag, version, output }) => Effect.gen(function* () { - // The header counts everything in the folded list; the summary skips mobile-only work. + // The header counts every commit in the range; the summary skips mobile-only work. const allCommits = yield* readCommits(previousTag, ref); // The spawner ignores exit codes, so a failed git log shows up as an empty range. if (allCommits.length === 0) { From 1835565a0e62fecd20e69415b5954484e39a397b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 02:51:40 -0700 Subject: [PATCH 05/13] fix(release): a PR author's direct push no longer counts twice Co-Authored-By: Claude Opus 5.5 --- scripts/generate-release-notes.test.ts | 5 ++++- scripts/generate-release-notes.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts index 615d53a0acb2d..077e5ed05d672 100644 --- a/scripts/generate-release-notes.test.ts +++ b/scripts/generate-release-notes.test.ts @@ -22,14 +22,17 @@ it("counts each person once and leaves out bots", () => { { subject: "fix: direct push by a PR author", authorName: "Ada Lovelace" }, { subject: "fix: direct push by someone else", authorName: "Grace Hopper" }, { subject: "chore: direct push by a bot", authorName: "t3-code[bot]" }, + { subject: "fix(web): four (#4)", authorName: "Linus T" }, + { subject: "fix: direct push by a PR author with no profile name", authorName: "Linus T" }, ]; const pullRequests = new Map([ [1, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], [2, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], [3, pullRequest({ __typename: "Bot", login: "dependabot" })], + [4, pullRequest({ __typename: "User", login: "linus", name: null })], ]); - assert.equal(countContributors(commits, pullRequests), 2); + assert.equal(countContributors(commits, pullRequests), 3); }); it("leaves mobile-only scopes out and keeps shared ones", () => { diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index ae67794eba09a..a04f5b7ea4414 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -152,6 +152,10 @@ export function countContributors( people.add(author.login); if (author.name) names.add(author.name); } + // A PR's squash commit carries its author's git name, which may differ from the profile name. + for (const commit of commits) { + if (pullRequestNumber(commit.subject) !== undefined) names.add(commit.authorName); + } for (const commit of commits) { if ( pullRequestNumber(commit.subject) === undefined && From d67a2eca31435bd244ecc583ae226da480262f8a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 02:57:43 -0700 Subject: [PATCH 06/13] fix(release): release notes write atomically so a partial file is never published Co-Authored-By: Claude Opus 5.5 --- scripts/generate-release-notes.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index a04f5b7ea4414..05263e46777df 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -383,7 +383,12 @@ const command = Command.make( return yield* Effect.logWarning("no release summary, leaving GitHub's notes in place"); } const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(output, assembleReleaseNotes(header, summary.value, fullChangelog)); + // Publish uses the file when it exists, so a partial write must never land at `output`. + yield* fs.writeFileString( + `${output}.tmp`, + assembleReleaseNotes(header, summary.value, fullChangelog), + ); + yield* fs.rename(`${output}.tmp`, output); yield* Effect.logInfo("release notes written").pipe( Effect.annotateLogs({ output, commits: commits.length }), ); From 557a43706d27f0fce461832e16e3723dc94f6b0a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 03:29:02 -0700 Subject: [PATCH 07/13] feat(desktop): update popover shows release notes on every channel, with how long ago the release shipped Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/DesktopUpdates.ts | 2 + apps/desktop/src/updates/releaseNotes.test.ts | 6 +- apps/desktop/src/updates/releaseNotes.ts | 2 + .../components/sidebar/SidebarUpdatePill.tsx | 2 +- .../sidebar/SidebarUpdateReleaseNotes.tsx | 10 ++- packages/contracts/src/ipc.ts | 3 + scripts/generate-release-notes.ts | 63 +++---------------- 7 files changed, 28 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 4da742f453aa6..3a0b41cd9e369 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -70,6 +70,7 @@ const UpdateInfo = Schema.Struct({ version: Schema.String, // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), + releaseDate: Schema.optional(Schema.String), }); const DownloadProgressInfo = Schema.Struct({ @@ -895,6 +896,7 @@ export const make = Effect.gen(function* () { info.releaseNotes, info.version, state.channel, + info.releaseDate, ); yield* setState( reduceDesktopUpdateStateOnUpdateAvailable( diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index f8abab64d9ddb..2da4df049492f 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -64,7 +64,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { const fixes = Array.from({ length: 9 }, (_, index) => `- Fix ${index + 1} (#${index + 1})`); const result = normalizeDesktopUpdateReleaseNotes( [ - "**v1.2.4** · Oct 29, 2026 · 12 changes from 3 contributors in 2 days", + "**v1.2.4** · 12 changes from 3 contributors", "## Highlights", "Link machines (beta).", "## Fixes", @@ -76,13 +76,15 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ].join("\n"), "1.2.4", "latest", + "2026-10-29T10:00:00.000Z", ); expect(result.releaseNotes).toEqual([ { version: "1.2.4", + releasedAt: "2026-10-29T10:00:00.000Z", items: [ - "v1.2.4 · Oct 29, 2026 · 12 changes from 3 contributors in 2 days", + "v1.2.4 · 12 changes from 3 contributors", "Link machines (beta).", ...fixes.map((fix) => fix.slice(2)), ], diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 6c82af6d19a31..fe2e528fdb64a 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -135,6 +135,7 @@ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, channel: DesktopUpdateChannel, + releaseDate?: string, ): NormalizedDesktopUpdateReleaseNotes { const rawNotes = ( typeof releaseNotes === "string" @@ -152,6 +153,7 @@ export function normalizeDesktopUpdateReleaseNotes( version: entry.version, items, totalItems, + ...(releaseDate && entry.version === fallbackVersion ? { releasedAt: releaseDate } : {}), }, ]; }); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index d9f45cf92f934..847dc3ae72b3a 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -53,7 +53,7 @@ function shouldUseSidebarUpdateReleaseNotesPopover( showUpdateDetails: boolean, state: DesktopUpdateState | null, ): boolean { - return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0; + return showUpdateDetails && (state?.releaseNotes.length ?? 0) > 0; } function handleSidebarUpdateReleaseNotesPopoverOpenChange( diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index b9f29741bfa63..36162915c862c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -5,6 +5,7 @@ import { getDesktopUpdateReleaseHistoryUrl, getDesktopUpdateReleaseUrl, } from "../desktopUpdate.logic"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast"; import { Separator } from "../ui/separator"; @@ -52,9 +53,12 @@ export function SidebarUpdateReleaseNotes({ readonly state: DesktopUpdateState; readonly tooltip: string; }) { - if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + if (state.releaseNotes.length === 0) { return <>{tooltip}; } + const releasedAt = state.releaseNotes.find( + (releaseNote) => releaseNote.version === state.availableVersion, + )?.releasedAt; return (
@@ -66,7 +70,9 @@ export function SidebarUpdateReleaseNotes({
{state.availableVersion ? (
- {state.availableVersion} + {releasedAt + ? `${state.availableVersion} · ${formatRelativeTimeLabel(releasedAt)}` + : state.availableVersion}
) : null} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8d848a86b22cf..328ef74e5d607 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -288,12 +288,15 @@ export interface DesktopUpdateReleaseNote { version: string; items: ReadonlyArray; totalItems: number; + /** ISO time the update feed reports for this version. Only the offered version has one. */ + releasedAt?: string; } export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ version: Schema.String, items: Schema.Array(Schema.String), totalItems: Schema.Number, + releasedAt: Schema.optionalKey(Schema.String), }); export const DesktopUpdateStateSchema = Schema.Struct({ diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index 05263e46777df..276adb497cfe9 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -3,7 +3,6 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -168,36 +167,10 @@ export function countContributors( return people.size; } -function formatElapsed(milliseconds: number): string { - const units = [ - ["day", 86_400_000], - ["hour", 3_600_000], - ["minute", 60_000], - ] as const; - for (const [unit, size] of units) { - const count = Math.floor(milliseconds / size); - if (count >= 1) return `${count} ${unit}${count === 1 ? "" : "s"}`; - } - return "under a minute"; -} - -function buildReleaseHeader(options: { - readonly version: string; - /** Epoch milliseconds. */ - readonly builtAt: number; - readonly previousBuiltAt: number; - readonly changes: number; - readonly contributors: number; -}): string { - const date = DateTime.formatUtc(DateTime.makeUnsafe(options.builtAt), { - locale: "en-US", - month: "short", - day: "numeric", - year: "numeric", - }); - const elapsed = formatElapsed(options.builtAt - options.previousBuiltAt); +/** No date here: the app shows how long ago a release shipped, and GitHub shows the date. */ +function buildReleaseHeader(version: string, changes: number, contributors: number): string { const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; - return `**v${options.version.replace(/^v/, "")}** · ${date} · ${plural(options.changes, "change")} from ${plural(options.contributors, "contributor")} in ${elapsed}`; + return `**v${version.replace(/^v/, "")}** · ${plural(changes, "change")} from ${plural(contributors, "contributor")}`; } /** The summary leads; GitHub's full list folds under it. Clients cut the body at `
`. */ @@ -235,22 +208,6 @@ const readCommits = Effect.fn("generateReleaseNotes.readCommits")(function* ( }); }); -/** When the previous release was published, else its commit time. Stable promotes an older nightly commit. */ -const readPreviousReleaseTime = Effect.fn("generateReleaseNotes.readPreviousReleaseTime")( - function* (repository: string, previousTag: string) { - const published = Date.parse( - yield* run("gh", [ - "api", - `repos/${repository}/releases/tags/${previousTag}`, - "--jq", - ".published_at", - ]).pipe(Effect.map((output) => output.trim())), - ); - if (!Number.isNaN(published)) return published; - return Date.parse((yield* run("git", ["log", "-1", "--format=%cI", previousTag])).trim()); - }, -); - const readPullRequests = Effect.fn("generateReleaseNotes.readPullRequests")(function* ( repository: string, numbers: ReadonlyArray, @@ -358,22 +315,18 @@ const command = Command.make( const numbers = [ ...new Set(allCommits.flatMap(({ subject }) => pullRequestNumber(subject) ?? [])), ]; - const [pullRequests, fullChangelog, previousBuiltAt] = yield* Effect.all( + const [pullRequests, fullChangelog] = yield* Effect.all( [ readPullRequests(repository, numbers), readFullChangelog({ repository, tag, ref, previousTag }), - readPreviousReleaseTime(repository, previousTag), ], { concurrency: "unbounded" }, ); - const builtAt = DateTime.toEpochMillis(yield* DateTime.now); - const header = buildReleaseHeader({ + const header = buildReleaseHeader( version, - builtAt, - previousBuiltAt, - changes: allCommits.length, - contributors: countContributors(allCommits, pullRequests), - }); + allCommits.length, + countContributors(allCommits, pullRequests), + ); // With no summary, write nothing: the release falls back to GitHub's generated notes. const summary = commits.length === 0 From d9373e3b5e518aa0bf9a7021450c8003a04a4d9c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 03:39:41 -0700 Subject: [PATCH 08/13] feat(desktop): generated release notes read as a note in the update popover Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/releaseNotes.test.ts | 12 ++--- apps/desktop/src/updates/releaseNotes.ts | 49 +++++++++++++++---- .../sidebar/SidebarUpdateReleaseNotes.tsx | 37 ++++++++++++++ packages/contracts/src/ipc.ts | 6 +++ 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 2da4df049492f..f89f4c833d00c 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -60,7 +60,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); - it("shows a generated summary in order and skips the folded full list", () => { + it("reads a generated summary as a note and skips the folded full list", () => { const fixes = Array.from({ length: 9 }, (_, index) => `- Fix ${index + 1} (#${index + 1})`); const result = normalizeDesktopUpdateReleaseNotes( [ @@ -83,12 +83,10 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { { version: "1.2.4", releasedAt: "2026-10-29T10:00:00.000Z", - items: [ - "v1.2.4 · 12 changes from 3 contributors", - "Link machines (beta).", - ...fixes.map((fix) => fix.slice(2)), - ], - totalItems: 11, + stats: "12 changes from 3 contributors", + intro: "Link machines (beta).", + items: fixes.map((_, index) => `Fix ${index + 1}`), + totalItems: 10, }, ]); }); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index fe2e528fdb64a..0d568d053a101 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -93,17 +93,49 @@ function isIgnoredReleaseNoteLine(line: string): boolean { interface ExtractedReleaseNoteItems { readonly items: ReadonlyArray; readonly totalItems: number; + readonly intro?: string; + readonly stats?: string; +} + +/** + * Reads a generated summary (`scripts/generate-release-notes.ts`): a `v1.2.3 · N changes…` + * header, a highlights paragraph, and fix bullets. PR numbers are left to the GitHub page. + */ +function extractReleaseNoteSummary(summary: string): ExtractedReleaseNoteItems { + let stats: string | undefined; + const intro: string[] = []; + const items: string[] = []; + for (const rawLine of stripMarkup(summary).split("\n")) { + const line = rawLine.trim().replace(/\s+/g, " "); + if (line === "" || /^#{1,6}\s+/.test(line)) continue; + if (stats === undefined && /^v\S+ · /.test(line)) { + stats = line.slice(line.indexOf(" · ") + 3); + continue; + } + const text = line + .replace(/^[-*]\s+/, "") + .replace(/\s*\((?:#\d+(?:,\s*)?)+\)\.?$/, "") + .trim(); + if (/^[-*]\s+/.test(line)) items.push(text); + else intro.push(text); + } + return { + items, + totalItems: items.length + intro.length, + ...(intro.length > 0 ? { intro: intro.join(" ") } : {}), + ...(stats ? { stats } : {}), + }; } function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { if (!note) return { items: [], totalItems: 0 }; - // Generated summaries fold GitHub's full list under
; show the summary in order. + // Generated summaries fold GitHub's full list under
. const summaryEnd = note.search(/= 0; + if (summaryEnd >= 0) return extractReleaseNoteSummary(note.slice(0, summaryEnd)); const items: string[] = []; let totalItems = 0; - for (const rawLine of stripMarkup(isSummary ? note.slice(0, summaryEnd) : note).split("\n")) { + for (const rawLine of stripMarkup(note).split("\n")) { const item = rawLine .trim() .replace(/^[-*]\s+/, "") @@ -114,15 +146,10 @@ function extractReleaseNoteItems(note: string | null | undefined): ExtractedRele if (/^#{1,6}\s+/.test(item)) continue; if (isIgnoredReleaseNoteLine(item)) continue; totalItems += 1; - // A summary is already short; cutting it would drop the highlights mid-sentence. - if (isSummary) { - items.push(item); - continue; - } items.push(truncateReleaseNoteItem(item)); if (items.length > MAX_RELEASE_NOTE_ITEMS_PER_GROUP) items.shift(); } - return { items: isSummary ? items : items.toReversed(), totalItems }; + return { items: items.toReversed(), totalItems }; } interface NormalizedDesktopUpdateReleaseNotes { @@ -146,13 +173,15 @@ export function normalizeDesktopUpdateReleaseNotes( ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { - const { items, totalItems } = extractReleaseNoteItems(entry.note); + const { items, totalItems, intro, stats } = extractReleaseNoteItems(entry.note); if (totalItems === 0) return []; return [ { version: entry.version, items, totalItems, + ...(intro ? { intro } : {}), + ...(stats ? { stats } : {}), ...(releaseDate && entry.version === fallbackVersion ? { releasedAt: releaseDate } : {}), }, ]; diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index 36162915c862c..71d80dc0b0abc 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -89,6 +89,43 @@ export function SidebarUpdateReleaseNotes({ ? "View release on GitHub" : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`; + if (releaseNote.intro !== undefined) { + return ( +
+ {index > 0 && } +
+ {index > 0 ? ( +

+ {releaseNote.version} +

+ ) : null} +

+ {releaseNote.intro} +

+ {releaseNote.items.length > 0 ? ( + <> +

+ Fixes +

+
+ {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => ( +

+ {item} +

+ ))} +
+ + ) : null} + {releaseUrl ? ( + + {releaseNote.stats ?? "View release on GitHub"} + + ) : null} +
+
+ ); + } + return (
{index > 0 && } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 328ef74e5d607..d0e3b28b0a8e5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -290,6 +290,10 @@ export interface DesktopUpdateReleaseNote { totalItems: number; /** ISO time the update feed reports for this version. Only the offered version has one. */ releasedAt?: string; + /** Generated summaries only: the highlights paragraph. `items` then holds the fixes. */ + intro?: string; + /** Generated summaries only, for example "275 changes from 22 contributors". */ + stats?: string; } export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ @@ -297,6 +301,8 @@ export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ items: Schema.Array(Schema.String), totalItems: Schema.Number, releasedAt: Schema.optionalKey(Schema.String), + intro: Schema.optionalKey(Schema.String), + stats: Schema.optionalKey(Schema.String), }); export const DesktopUpdateStateSchema = Schema.Struct({ From d264f444e4e9360dd0dcc248c1ba57cb32c39f60 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 03:55:05 -0700 Subject: [PATCH 09/13] feat(desktop): update popover shows each release as a short note Co-Authored-By: Claude Opus 5.5 --- .../SidebarUpdatePill.browser.test.tsx | 2 +- .../components/sidebar/SidebarUpdatePill.tsx | 2 +- .../sidebar/SidebarUpdateReleaseNotes.tsx | 62 +++++++------------ docs/operations/release.md | 2 +- 4 files changed, 24 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 714d1564bac0a..18ebd70c6162c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -81,7 +81,7 @@ function publish(update: DesktopUpdateState | null) { }); } -const releaseNotes = () => document.querySelector('[aria-label="Nightly update release notes"]'); +const releaseNotes = () => document.querySelector('[aria-label="Update release notes"]'); describe("SidebarUpdatePill release notes", () => { it("closes when the release notes stop applying", async () => { diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 847dc3ae72b3a..7e0ee498cece5 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -542,7 +542,7 @@ function SidebarUpdateControl() { {showReleaseNotesPopover && state ? ( { if ( diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index 71d80dc0b0abc..01efc9f53e413 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -7,7 +7,6 @@ import { } from "../desktopUpdate.logic"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast"; -import { Separator } from "../ui/separator"; type DesktopUpdateShell = Pick; @@ -31,7 +30,7 @@ function ReleaseLink({ }) { return ( { event.preventDefault(); @@ -39,7 +38,7 @@ function ReleaseLink({ }} > {children} - + ); } @@ -61,7 +60,7 @@ export function SidebarUpdateReleaseNotes({ )?.releasedAt; return ( -
+
{state.status === "available" ? (
@@ -80,7 +79,7 @@ export function SidebarUpdateReleaseNotes({
{tooltip}
)}
-
+
{state.releaseNotes.map((releaseNote, index) => { const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version); const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length); @@ -91,44 +90,26 @@ export function SidebarUpdateReleaseNotes({ if (releaseNote.intro !== undefined) { return ( -
- {index > 0 && } -
- {index > 0 ? ( -

- {releaseNote.version} -

- ) : null} -

- {releaseNote.intro} -

- {releaseNote.items.length > 0 ? ( - <> -

- Fixes -

-
- {keyReleaseNoteItems(releaseNote.items).map(({ item, key }) => ( -

- {item} -

- ))} -
- - ) : null} - {releaseUrl ? ( - - {releaseNote.stats ?? "View release on GitHub"} - - ) : null} -
-
+
0 ? "mt-4" : undefined} key={releaseNote.version}> + {index > 0 ? ( +

+ {releaseNote.version} +

+ ) : null} +

+ {releaseNote.intro} +

+ {releaseUrl ? ( + + {releaseNote.stats ?? "View release on GitHub"} + + ) : null} +
); } return ( -
- {index > 0 && } +
0 ? "mt-4" : undefined} key={releaseNote.version}>

{index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} @@ -150,8 +131,7 @@ export function SidebarUpdateReleaseNotes({ ); })} {state.omittedReleaseCount > 0 ? ( -
- +
{`${state.omittedReleaseCount} older ${state.omittedReleaseCount === 1 ? "release" : "releases"} on GitHub`} diff --git a/docs/operations/release.md b/docs/operations/release.md index f607527c3e459..89003a5e76126 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -42,7 +42,7 @@ the version. - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
`. It needs the `OPENAI_API_KEY` secret. The summary leaves out commits whose scopes are all mobile; the header counts every commit. The desktop update popover and the nightly Discord post show only the part above `
`. If the step fails or there is nothing to summarize, it writes no file and the release uses GitHub's generated notes as before. + - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
`. It needs the `OPENAI_API_KEY` secret. The summary leaves out commits whose scopes are all mobile; the header counts every commit. The desktop update popover, on every channel, shows the highlights paragraph and the header counts as a short note per release, with the offered release's age from the update feed's `releaseDate`. The nightly Discord post shows the part above `
`. If the step fails or there is nothing to summarize, it writes no file and the release uses GitHub's generated notes as before. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. From c99587d1f6318974f231a8b7c80d8ef294756844 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 04:03:01 -0700 Subject: [PATCH 10/13] fix(release): authors of commits whose PR does not resolve still count as contributors Co-Authored-By: Claude Opus 5.5 --- scripts/generate-release-notes.test.ts | 3 ++- scripts/generate-release-notes.ts | 29 +++++++++++++------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts index 077e5ed05d672..e2701baee92f0 100644 --- a/scripts/generate-release-notes.test.ts +++ b/scripts/generate-release-notes.test.ts @@ -24,6 +24,7 @@ it("counts each person once and leaves out bots", () => { { subject: "chore: direct push by a bot", authorName: "t3-code[bot]" }, { subject: "fix(web): four (#4)", authorName: "Linus T" }, { subject: "fix: direct push by a PR author with no profile name", authorName: "Linus T" }, + { subject: "fix(web): five, whose PR did not resolve (#5)", authorName: "Ken T" }, ]; const pullRequests = new Map([ [1, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], @@ -32,7 +33,7 @@ it("counts each person once and leaves out bots", () => { [4, pullRequest({ __typename: "User", login: "linus", name: null })], ]); - assert.equal(countContributors(commits, pullRequests), 3); + assert.equal(countContributors(commits, pullRequests), 4); }); it("leaves mobile-only scopes out and keeps shared ones", () => { diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index 276adb497cfe9..7aef4341a35dd 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -145,24 +145,23 @@ export function countContributors( pullRequests: ReadonlyMap, ): number { const people = new Set(); - const names = new Set(); - for (const { author } of pullRequests.values()) { - if (!author || author.__typename === "Bot" || author.login.endsWith("[bot]")) continue; - people.add(author.login); - if (author.name) names.add(author.name); - } // A PR's squash commit carries its author's git name, which may differ from the profile name. + const prAuthorNames = new Set(); + const directAuthors: string[] = []; for (const commit of commits) { - if (pullRequestNumber(commit.subject) !== undefined) names.add(commit.authorName); - } - for (const commit of commits) { - if ( - pullRequestNumber(commit.subject) === undefined && - !commit.authorName.endsWith("[bot]") && - !names.has(commit.authorName) - ) { - people.add(commit.authorName); + const number = pullRequestNumber(commit.subject); + const author = number === undefined ? undefined : pullRequests.get(number)?.author; + if (!author) { + directAuthors.push(commit.authorName); + continue; } + if (author.__typename === "Bot" || author.login.endsWith("[bot]")) continue; + people.add(author.login); + prAuthorNames.add(commit.authorName); + if (author.name) prAuthorNames.add(author.name); + } + for (const name of directAuthors) { + if (!name.endsWith("[bot]") && !prAuthorNames.has(name)) people.add(name); } return people.size; } From ed6ae260568b0443ca2eadd2d59cf45f1e5e5479 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 04:06:09 -0700 Subject: [PATCH 11/13] feat(desktop): older nightlies in the update popover show how many days ago they shipped Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/releaseNotes.test.ts | 18 ++++++++++++++++++ apps/desktop/src/updates/releaseNotes.ts | 12 +++++++++++- .../sidebar/SidebarUpdateReleaseNotes.tsx | 18 ++++++++++++++++-- packages/contracts/src/ipc.ts | 2 +- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index f89f4c833d00c..a75bf1d54ad7a 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -28,6 +28,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { releaseNotes: [ { version: "0.0.36-nightly.20260828.1213", + releasedAt: "2026-08-28", items: [ "fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", "fix(web): keep long task drawers usable on small screens by @human in #8313", @@ -111,6 +112,23 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ]); }); + it("dates the offered release from the feed and older nightlies by their build day", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "0.0.41-nightly.20260914.1707", note: "- Nightly change 2" }, + { version: "0.0.41-nightly.20260913.1700", note: "- Nightly change 1" }, + ], + "0.0.41-nightly.20260914.1707", + "nightly", + "2026-09-14T18:30:00.000Z", + ); + + expect(result.releaseNotes.map(({ releasedAt }) => releasedAt)).toEqual([ + "2026-09-14T18:30:00.000Z", + "2026-09-13", + ]); + }); + it("drops releases from other trains before grouping on nightly", () => { const releaseNotes = [ { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 0d568d053a101..4f3988e4dde10 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -152,6 +152,15 @@ function extractReleaseNoteItems(note: string | null | undefined): ExtractedRele return { items: items.toReversed(), totalItems }; } +// The update feed dates only the offered version; prerelease versions carry their UTC build day. +const PRERELEASE_BUILD_DAY = /-(?:nightly|preview)\.(\d{4})(\d{2})(\d{2})\.\d+$/; + +function resolveReleasedAt(version: string, offeredVersion: string, releaseDate?: string) { + if (version === offeredVersion && releaseDate) return releaseDate; + const day = PRERELEASE_BUILD_DAY.exec(version); + return day ? `${day[1]}-${day[2]}-${day[3]}` : undefined; +} + interface NormalizedDesktopUpdateReleaseNotes { readonly releaseNotes: ReadonlyArray; readonly omittedReleaseCount: number; @@ -175,6 +184,7 @@ export function normalizeDesktopUpdateReleaseNotes( const normalizedNotes = rawNotes.flatMap((entry) => { const { items, totalItems, intro, stats } = extractReleaseNoteItems(entry.note); if (totalItems === 0) return []; + const releasedAt = resolveReleasedAt(entry.version, fallbackVersion, releaseDate); return [ { version: entry.version, @@ -182,7 +192,7 @@ export function normalizeDesktopUpdateReleaseNotes( totalItems, ...(intro ? { intro } : {}), ...(stats ? { stats } : {}), - ...(releaseDate && entry.version === fallbackVersion ? { releasedAt: releaseDate } : {}), + ...(releasedAt ? { releasedAt } : {}), }, ]; }); diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index 01efc9f53e413..215849b1f960f 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -19,6 +19,18 @@ function keyReleaseNoteItems(items: ReadonlyArray) { }); } +/** Feed times read "3h ago"; build days from a prerelease version read "today" or "2d ago". */ +function formatReleaseAge(releasedAt: string): string { + const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(releasedAt); + if (!day) return formatRelativeTimeLabel(releasedAt); + const now = new Date(); + const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const days = Math.round( + (today - Date.UTC(Number(day[1]), Number(day[2]) - 1, Number(day[3]))) / 86_400_000, + ); + return days <= 0 ? "today" : `${days}d ago`; +} + function ReleaseLink({ children, releaseUrl, @@ -70,7 +82,7 @@ export function SidebarUpdateReleaseNotes({ {state.availableVersion ? (
{releasedAt - ? `${state.availableVersion} · ${formatRelativeTimeLabel(releasedAt)}` + ? `${state.availableVersion} · ${formatReleaseAge(releasedAt)}` : state.availableVersion}
) : null} @@ -93,7 +105,9 @@ export function SidebarUpdateReleaseNotes({
0 ? "mt-4" : undefined} key={releaseNote.version}> {index > 0 ? (

- {releaseNote.version} + {releaseNote.releasedAt + ? `${releaseNote.version} · ${formatReleaseAge(releaseNote.releasedAt)}` + : releaseNote.version}

) : null}

diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d0e3b28b0a8e5..f460619f11355 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -288,7 +288,7 @@ export interface DesktopUpdateReleaseNote { version: string; items: ReadonlyArray; totalItems: number; - /** ISO time the update feed reports for this version. Only the offered version has one. */ + /** ISO time from the update feed for the offered version, else `YYYY-MM-DD` from a prerelease version. */ releasedAt?: string; /** Generated summaries only: the highlights paragraph. `items` then holds the fixes. */ intro?: string; From f16a67f3d522969fe815586a39509e85decdcf55 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 04:08:46 -0700 Subject: [PATCH 12/13] fix(release): only generated notes use the summary layout, and partial model output is never published Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/releaseNotes.test.ts | 12 ++++++++++++ apps/desktop/src/updates/releaseNotes.ts | 4 ++-- .../components/sidebar/SidebarUpdateReleaseNotes.tsx | 5 +++-- scripts/generate-release-notes.ts | 3 +++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index a75bf1d54ad7a..d88838439dba7 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -112,6 +112,18 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ]); }); + it("reads handwritten notes with their own

as a list", () => { + const result = normalizeDesktopUpdateReleaseNotes( + "- Older fix\n- Newer fix\n
Logs\n- hidden\n
", + "1.2.3", + "latest", + ); + + expect(result.releaseNotes).toEqual([ + { version: "1.2.3", items: ["hidden", "Logs", "Newer fix", "Older fix"], totalItems: 4 }, + ]); + }); + it("dates the offered release from the feed and older nightlies by their build day", () => { const result = normalizeDesktopUpdateReleaseNotes( [ diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 4f3988e4dde10..2704563cfc0b8 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -130,8 +130,8 @@ function extractReleaseNoteSummary(summary: string): ExtractedReleaseNoteItems { function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { if (!note) return { items: [], totalItems: 0 }; - // Generated summaries fold GitHub's full list under
. - const summaryEnd = note.search(/ block. + const summaryEnd = note.search(/
\s*\s*Full changelog\s*<\/summary>/i); if (summaryEnd >= 0) return extractReleaseNoteSummary(note.slice(0, summaryEnd)); const items: string[] = []; let totalItems = 0; diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index 215849b1f960f..90f8f13efc598 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -96,9 +96,10 @@ export function SidebarUpdateReleaseNotes({ const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version); const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length); const linkLabel = - omittedItemCount === 0 + releaseNote.stats ?? + (omittedItemCount === 0 ? "View release on GitHub" - : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`; + : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`); if (releaseNote.intro !== undefined) { return ( diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index 7aef4341a35dd..14a3f26778888 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -87,6 +87,7 @@ const PullRequestQueryResponse = Schema.Struct({ const GeneratedNotes = Schema.Struct({ body: Schema.String }); const SummaryResponse = Schema.Struct({ + status: Schema.String, output: Schema.Array( Schema.Struct({ type: Schema.String, @@ -284,6 +285,8 @@ const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: outputTokens: response.usage?.output_tokens, }), ); + // An incomplete response can carry partial text; publishing it would cut the notes short. + if (response.status !== "completed") return Option.none(); const text = response.output .flatMap((item) => item.content ?? []) .flatMap((part) => (part.type === "output_text" && part.text ? [part.text] : [])) From 02c782f3074ecfd2686009c8a45ed93024864a17 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 04:20:03 -0700 Subject: [PATCH 13/13] fix(release): small releases get short notes, and an older first note keeps its version label Co-Authored-By: Claude Opus 5.5 --- .../sidebar/SidebarUpdateReleaseNotes.tsx | 2 +- scripts/generate-release-notes.test.ts | 14 +++++++++++ scripts/generate-release-notes.ts | 24 +++++++++++-------- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index 90f8f13efc598..974f81c8d76b3 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -104,7 +104,7 @@ export function SidebarUpdateReleaseNotes({ if (releaseNote.intro !== undefined) { return (
0 ? "mt-4" : undefined} key={releaseNote.version}> - {index > 0 ? ( + {releaseNote.version !== state.availableVersion ? (

{releaseNote.releasedAt ? `${releaseNote.version} · ${formatReleaseAge(releaseNote.releasedAt)}` diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts index e2701baee92f0..0c55b082dcfb9 100644 --- a/scripts/generate-release-notes.test.ts +++ b/scripts/generate-release-notes.test.ts @@ -1,8 +1,11 @@ import { assert, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + import { countContributors, isReleaseNoteCommit, + summaryText, type PullRequest, } from "./generate-release-notes.ts"; @@ -49,3 +52,14 @@ it("leaves mobile-only scopes out and keeps shared ones", () => { assert.equal(isReleaseNoteCommit({ subject, authorName: "" }), kept, subject); } }); + +it("uses model text only from a completed response", () => { + const output = [ + { type: "message", content: [{ type: "output_text", text: " ## Highlights\nX " }] }, + ]; + assert.deepStrictEqual( + summaryText({ status: "completed", output }), + Option.some("## Highlights\nX"), + ); + assert.deepStrictEqual(summaryText({ status: "incomplete", output }), Option.none()); +}); diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index 14a3f26778888..909247dae1262 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -29,10 +29,10 @@ You get the commit subjects merged since the last release, oldest first. Most en Output Markdown in exactly this shape and nothing else: ## Highlights -2 to 4 short sentences. Name only the new features a user would care about most. Each feature gets a few words that make the benefit clear, not a description of how it works or where the button is. +Up to 4 short sentences. Name only the new features a user would care about most. If no new feature matters to users, write one sentence about the most important fix. Each feature gets a few words that make the benefit clear, not a description of how it works or where the button is. ## Fixes -- 5 to 10 bullets, one fix each, under 10 words, ending with the PR number like (#123). +- Up to 10 bullets, one fix each, under 10 words, ending with the PR number like (#123). Fewer is fine. Never pad. - Each bullet names what was broken and says it works now. A user who hit the bug should recognize it. - Only list fixes from fix commits. New abilities go in Highlights or nowhere. Say "again" only if the commit says it. @@ -261,6 +261,17 @@ const readFullChangelog = Effect.fn("generateReleaseNotes.readFullChangelog")(fu return (yield* decodeJson(GeneratedNotes)(response)).body; }); +export function summaryText(response: typeof SummaryResponse.Type): Option.Option { + // An incomplete response can carry partial text; publishing it would cut the notes short. + if (response.status !== "completed") return Option.none(); + const text = response.output + .flatMap((item) => item.content ?? []) + .flatMap((part) => (part.type === "output_text" && part.text ? [part.text] : [])) + .join("") + .trim(); + return text === "" ? Option.none() : Option.some(text); +} + const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: string) { const apiKey = yield* Config.Redacted("OPENAI_API_KEY"); const httpClient = yield* HttpClient.HttpClient; @@ -285,14 +296,7 @@ const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: outputTokens: response.usage?.output_tokens, }), ); - // An incomplete response can carry partial text; publishing it would cut the notes short. - if (response.status !== "completed") return Option.none(); - const text = response.output - .flatMap((item) => item.content ?? []) - .flatMap((part) => (part.type === "output_text" && part.text ? [part.text] : [])) - .join("") - .trim(); - return text === "" ? Option.none() : Option.some(text); + return summaryText(response); }); const command = Command.make(