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
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,51 @@ nonisolated public struct FeedWhatsNewSource: WhatsNewSource {
}

/// The digest of `notes`: its embedded document, else one built from
/// its highlights (nil without either).
/// its highlights, else the short summary (nil without any user-facing
/// lines). Summary lines keep a signed nightly build discoverable when
/// no highlight file was written for its release range.
static func document(from notes: ReleaseNotes) -> WhatsNewDocument? {
if var document = notes.whatsNew, document.schemaVersion == 1, document.version == notes.shortVersion {
document.origin = .feed
return document.entries.isEmpty ? nil : document
}
guard !notes.highlights.isEmpty, WhatsNewVersion(notes.shortVersion) != nil else { return nil }
let entries = notes.highlights.map { highlight in
WhatsNewDocument.Entry(id: highlight.id, category: .new, title: WhatsNewText(stringLiteral: highlight.title),
summary: WhatsNewText(stringLiteral: highlight.body),
tryIt: highlight.action.map { WhatsNewDocument.TryIt(action: $0.id) })
guard WhatsNewVersion(notes.shortVersion) != nil else { return nil }

let entries: [WhatsNewDocument.Entry]
if !notes.highlights.isEmpty {
entries = notes.highlights.compactMap { highlight in
let title = highlight.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return nil }
return WhatsNewDocument.Entry(
id: highlight.id,
category: category(for: highlight.category),
title: WhatsNewText(stringLiteral: title),
summary: WhatsNewText(stringLiteral: highlight.body),
tryIt: highlight.action.map { WhatsNewDocument.TryIt(action: $0.id) }
)
}
} else {
entries = (notes.summary ?? []).enumerated().compactMap { index, line in
let title = line.title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return nil }
let text = WhatsNewText(stringLiteral: title)
return WhatsNewDocument.Entry(id: "summary-\(index)", category: category(for: line.group),
title: text, summary: text)
}
}
guard let headline = entries.first?.title else { return nil }
return WhatsNewDocument(version: notes.shortVersion, channel: .nightly, date: notes.date,
headline: WhatsNewText(stringLiteral: notes.highlights[0].title), entries: entries, origin: .feed)
headline: headline, entries: entries, origin: .feed)
}

/// Release notes use "changed" for the short changelog while What's New
/// uses "improved"; the latter is the closest user-facing category.
private static func category(for value: String?) -> WhatsNewDocument.Category {
switch value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "fixed": return .fixed
case "improved", "changed": return .improved
case "security": return .security
default: return .new
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
@testable import CmuxNextUpdater
import Testing

@Suite struct FeedWhatsNewSourceTests {
private let date = "2026-10-17T02:58:43Z"

@Test func embeddedDigestWinsAndKeepsItsEntries() throws {
let embedded = WhatsNewDocument(
version: "1.0.0-nightly.42",
channel: .nightly,
date: date,
headline: "Reviewed digest",
entries: [WhatsNewDocument.Entry(id: "reviewed", category: .fixed, title: "Reviewed", summary: "Reviewed")]
)
let notes = makeNotes(shortVersion: embedded.version, highlights: [highlight(category: "new")], summary: [summary(group: "new")], whatsNew: embedded)

let document = try #require(FeedWhatsNewSource.document(from: notes))
#expect(document.origin == .feed)
#expect(document == embedded.withOrigin(.feed))
}

@Test func highlightsBecomeEntriesWithTheirCategories() throws {
let notes = makeNotes(
highlights: [highlight(id: "new", category: "new"), highlight(id: "better", category: "improved"), highlight(id: "fixed", category: "fixed"), highlight(id: "secure", category: "security")]
)

let document = try #require(FeedWhatsNewSource.document(from: notes))
#expect(document.entries.map(\.category) == [.new, .improved, .fixed, .security])
}

@Test func summaryProvidesFallbackWhenHighlightsAndDigestAreMissing() throws {
let notes = makeNotes(highlights: [], summary: [summary(group: "new", title: "A new route"), summary(group: "changed", title: "A smoother flow"), summary(group: "fixed", title: "A fixed bug")])

let document = try #require(FeedWhatsNewSource.document(from: notes))
#expect(document.entries.map(\.id) == ["summary-0", "summary-1", "summary-2"])
#expect(document.entries.map(\.category) == [.new, .improved, .fixed])
#expect(document.headline.resolved(for: ["en"]) == "A new route")
}

@Test func emptySummaryDoesNotCreateAPlaceholderDocument() {
let notes = makeNotes(highlights: [], summary: [summary(group: "new", title: " "), summary(group: "fixed", title: "")])
#expect(FeedWhatsNewSource.document(from: notes) == nil)
}

private func makeNotes(shortVersion: String = "1.0.0-nightly.42", highlights: [ReleaseNotes.Highlight], summary: [ReleaseNotes.SummaryLine] = [], whatsNew: WhatsNewDocument? = nil) -> ReleaseNotes {
ReleaseNotes(version: 1, build: "42", shortVersion: shortVersion, date: date, highlights: highlights, changes: [], whatsNew: whatsNew, summary: summary)
}

private func highlight(id: String = "highlight", category: String?) -> ReleaseNotes.Highlight {
ReleaseNotes.Highlight(id: id, title: id.capitalized, body: "Details for \(id).", media: [], action: nil, category: category)
}

private func summary(group: String, title: String = "A summary line") -> ReleaseNotes.SummaryLine {
ReleaseNotes.SummaryLine(group: group, title: title)
}
}

private extension WhatsNewDocument {
func withOrigin(_ origin: Origin) -> WhatsNewDocument {
var copy = self
copy.origin = origin
return copy
}
}
47 changes: 44 additions & 3 deletions scripts/cmux-next/release-notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

The notes also carry the build's What's New digest ("whatsNew", decision
WHATS-NEW-AFTER-UPDATE W3; scripts/whats-new/digest.py) built from the same
highlight files, when they validate.
highlight files, when they validate, or from the short user-facing summary
when the build has no valid highlight digest.

Highlights are human-written: one Markdown file per highlight under
release-notes/next/highlights/. A highlight belongs to the first build whose
Expand All @@ -17,8 +18,8 @@
title: Updates you barely notice
action: palette.checkForUpdates | Try it (optional "Try it" button)

The rest is the body (Markdown). A build with no new highlight file ships
only its commit subjects (full history, no what's-new card). Each commit is
The rest is the body (Markdown). A build with no new highlight file uses its
user-facing summary for the What's New digest. Each commit is
also an item {title, author, pr} (newest first; pr from a "(#1234)" suffix)
for the update card's "What's changed" popover; older notes without items
still decode, and the app then reads the PR from each subject.
Expand Down Expand Up @@ -190,6 +191,44 @@ def whats_new_digest(args):
return document if document["entries"] else None


def summary_whats_new_digest(args, lines):
"""A validated nightly digest when there are summary lines but no highlight digest."""
if not lines:
return None
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "whats-new"))
import validate

def bounded(text, limit):
text = " ".join(text.split()).rstrip(". ")
if len(text) > limit:
text = text[:limit - 1].rsplit(" ", 1)[0].rstrip(". ") + "…"
return text

document = {"schemaVersion": 1, "version": args.short, "channel": "nightly", "date": args.date,
"headline": {"en": "Fixes and improvements"}, "entries": []}
categories = {"new": "new", "fixed": "fixed", "changed": "improved"}
for index, line in enumerate(lines):
title = bounded(line.get("title", ""), validate.LIMITS["title"])
if not title or line.get("group") not in categories:
continue
entry = {"id": f"summary-{index}", "category": categories[line["group"]],
"title": {"en": title}, "summary": {"en": line["title"].strip()},
"platforms": ["macos"], "audience": "all", "docs": "https://cmux.com/docs/changelog"}
problems = validate.validate_document(f"{args.short}.json", {**document, "entries": [entry]})
if problems:
print(f"whats-new summary: dropped {entry['id']}:\n" + "\n".join(problems), file=sys.stderr)
continue
document["entries"].append(entry)
if not document["entries"]:
return None
document["headline"] = {"en": bounded(document["entries"][0]["title"]["en"], validate.LIMITS["headline"])}
problems = validate.validate_document(f"{args.short}.json", document)
if problems:
print("whats-new summary skipped:\n" + "\n".join(problems), file=sys.stderr)
return None
return document


def write_community_summary(digest, args):
"""notes/community-<build>.md: a short Discord/X summary a human posts (K2); the
release-notes artifact keeps it and the R2 upload publishes it with the notes."""
Expand All @@ -216,6 +255,8 @@ def build(args):
notes = {"version": 1, "build": args.build, "shortVersion": args.short, "date": args.date, "head": args.head,
"highlights": highlights, "changes": changes, "items": items, "summary": summarize_changes(changes)}
digest = whats_new_digest(args)
if digest is None:
digest = summary_whats_new_digest(args, notes["summary"])
if digest is not None:
notes["whatsNew"] = digest
write_community_summary(digest, args)
Expand Down
47 changes: 47 additions & 0 deletions tests/test_cmux_next_release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import base64, json, os, subprocess, sys, tempfile, unittest

SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts", "cmux-next", "release-notes.py")
VALIDATOR = os.path.join(os.path.dirname(SCRIPT), "..", "whats-new", "validate.py")


def run(cwd, *args):
Expand Down Expand Up @@ -50,6 +51,52 @@ def test_a_later_build_has_no_highlight_only_changes(self):
self.assertEqual(notes["highlights"], [])
self.assertEqual(notes["changes"], ["sidebar: tidy"])

def test_summary_only_build_generates_a_valid_digest(self):
since = self.head
for subject in (
"feat: Keep favorite destinations within easy reach across all of your open windows",
"fix: Preserve the selected tab when another window closes",
"perf: Make switching between open windows smoother",
):
git(self.repo, "commit", "-q", "--allow-empty", "-m", subject)
self.head = git(self.repo, "rev-parse", "HEAD")

notes = self.build(since, build="4")
self.assertEqual(notes["highlights"], [])
document = notes["whatsNew"]
self.assertEqual([entry["category"] for entry in document["entries"]], ["new", "fixed", "improved"])
self.assertTrue(all(len(entry["title"]["en"]) <= 60 for entry in document["entries"]))
self.assertLessEqual(len(document["headline"]["en"]), 90)
self.assertTrue(all(entry["docs"] == "https://cmux.com/docs/changelog" for entry in document["entries"]))
self.assert_valid_digest(document)

def test_highlight_digest_wins_over_summary_fallback(self):
git(self.repo, "commit", "-q", "--allow-empty", "-m", "fix: Preserve the selected tab when another window closes")
self.head = git(self.repo, "rev-parse", "HEAD")

notes = self.build(self.base, build="5")
self.assertTrue(notes["summary"])
self.assertEqual([entry["id"] for entry in notes["whatsNew"]["entries"]], ["update-card"])
self.assertEqual(notes["whatsNew"]["entries"][0]["tryIt"], {"action": "palette.checkForUpdates"})
self.assert_valid_digest(notes["whatsNew"])

def test_empty_summary_does_not_generate_a_digest(self):
since = self.head
git(self.repo, "commit", "-q", "--allow-empty", "-m", "ci: Refresh build cache")
self.head = git(self.repo, "rev-parse", "HEAD")

notes = self.build(since, build="6")
self.assertEqual(notes["summary"], [])
self.assertEqual(notes["highlights"], [])
self.assertNotIn("whatsNew", notes)

def assert_valid_digest(self, document):
path = os.path.join(self.repo, document["version"] + ".json")
with open(path, "w") as f:
json.dump(document, f)
result = subprocess.run([sys.executable, VALIDATOR, path], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)

def test_the_index_keeps_the_newest_builds_first(self):
out = os.path.join(self.repo, "out")
self.build(self.base, build="2")
Expand Down
Loading