Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -762,11 +762,16 @@ jobs:
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/
Expand Down Expand Up @@ -892,6 +897,27 @@ jobs:
echo 'EOF'
} >> "$GITHUB_OUTPUT"

# 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
timeout-minutes: 5
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
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
# unmerged branch history no one should read as a changelog, and would
Expand All @@ -917,8 +943,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: ${{ 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 }}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/updates/DesktopUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -895,6 +896,7 @@ export const make = Effect.gen(function* () {
info.releaseNotes,
info.version,
state.channel,
info.releaseDate,
);
yield* setState(
reduceDesktopUpdateStateOnUpdateAvailable(
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/updates/releaseNotes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -60,6 +61,37 @@ describe("normalizeDesktopUpdateReleaseNotes", () => {
});
});

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(
[
"**v1.2.4** · 12 changes from 3 contributors",
"## Highlights",
"Link machines (beta).",
"## Fixes",
...fixes,
"<details>",
"<summary>Full changelog</summary>",
"* fix: raw entry by @human in https://github.com/pingdotgg/t3code/pull/1",
"</details>",
].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",
stats: "12 changes from 3 contributors",
intro: "Link machines (beta).",
items: fixes.map((_, index) => `Fix ${index + 1}`),
totalItems: 10,
},
]);
});

it("does not count Markdown or HTML section headings as changes", () => {
const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`);
const result = normalizeDesktopUpdateReleaseNotes(
Expand All @@ -80,6 +112,35 @@ describe("normalizeDesktopUpdateReleaseNotes", () => {
]);
});

it("reads handwritten notes with their own <details> as a list", () => {
const result = normalizeDesktopUpdateReleaseNotes(
"- Older fix\n- Newer fix\n<details><summary>Logs</summary>\n- hidden\n</details>",
"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(
[
{ 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" },
Expand Down
51 changes: 50 additions & 1 deletion apps/desktop/src/updates/releaseNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,46 @@ function isIgnoredReleaseNoteLine(line: string): boolean {
interface ExtractedReleaseNoteItems {
readonly items: ReadonlyArray<string>;
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 this exact <details> block.
const summaryEnd = note.search(/<details>\s*<summary>\s*Full changelog\s*<\/summary>/i);
if (summaryEnd >= 0) return extractReleaseNoteSummary(note.slice(0, summaryEnd));
const items: string[] = [];
let totalItems = 0;
for (const rawLine of stripMarkup(note).split("\n")) {
Expand All @@ -117,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<DesktopUpdateReleaseNote>;
readonly omittedReleaseCount: number;
Expand All @@ -127,6 +171,7 @@ export function normalizeDesktopUpdateReleaseNotes(
releaseNotes: unknown,
fallbackVersion: string,
channel: DesktopUpdateChannel,
releaseDate?: string,
): NormalizedDesktopUpdateReleaseNotes {
const rawNotes = (
typeof releaseNotes === "string"
Expand All @@ -137,13 +182,17 @@ 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 [];
const releasedAt = resolveReleasedAt(entry.version, fallbackVersion, releaseDate);
return [
{
version: entry.version,
items,
totalItems,
...(intro ? { intro } : {}),
...(stats ? { stats } : {}),
...(releasedAt ? { releasedAt } : {}),
},
];
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -542,7 +542,7 @@ function SidebarUpdateControl() {
{showReleaseNotesPopover && state ? (
<PopoverPopup
align="center"
aria-label="Nightly update release notes"
aria-label="Update release notes"
initialFocus={false}
onKeyDownCapture={(event) => {
if (
Expand Down
Loading
Loading