Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f4dea16
test(changelog): What's New gallery ranges and a failing pre-publish …
teamleaderleo Oct 19, 2026
ad3c09e
cmux NEXT: What's New checks a nightly's notes before they publish; o…
teamleaderleo Oct 19, 2026
9ee94c8
test(nightly): notes that fail their pre-publish check are not publis…
teamleaderleo Oct 19, 2026
560691b
ci(nightly-next): check each build's notes before they publish
teamleaderleo Oct 19, 2026
d28e41f
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/whats-n…
teamleaderleo Oct 19, 2026
eae368e
test(changelog): a brand-led title keeps its casing and passes the no…
teamleaderleo Oct 19, 2026
9c0e490
cmux NEXT: brand-led titles keep their casing and pass the notes chec…
teamleaderleo Oct 19, 2026
a206ed1
ci(nightly-next): the notes check warns when the feed did not serve a…
teamleaderleo Oct 19, 2026
ade66b6
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/whats-n…
teamleaderleo Oct 19, 2026
a28ac7c
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/whats-n…
teamleaderleo Oct 19, 2026
29c94bd
test(changelog): tool names, PR numbers and squash repairs in summary…
teamleaderleo Oct 19, 2026
135feed
fix(changelog): a tool name, a PR number or a squash repair marks a l…
teamleaderleo Oct 19, 2026
0fbda72
test(changelog): review cases for the notes rules: user-facing hyphen…
teamleaderleo Oct 19, 2026
2843226
fix(changelog): narrow the internal rules, read them in ASCII on both…
teamleaderleo Oct 19, 2026
e219e63
test(changelog): no internal line of the 50 published nightlies reach…
teamleaderleo Oct 19, 2026
cfbba2f
fix(changelog): a machine entry made whole from its line is judged in…
teamleaderleo Oct 19, 2026
47e4b13
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/whats-n…
teamleaderleo Oct 20, 2026
81af7a3
test(changelog): the Updated card lists only readable changes after t…
teamleaderleo Oct 20, 2026
c629403
Merge branch 'feat-cmux-next' into leo/whats-new-gallery-gate
teamleaderleo Oct 20, 2026
57ed1a0
ci(nightly): the release-notes check runs on a GitHub-hosted runner
teamleaderleo Oct 20, 2026
9001446
Merge feat-cmux-next into leo/whats-new-gallery-gate
teamleaderleo Oct 21, 2026
8fd0195
test(changelog): the Updated card's readable-only check reads base's …
teamleaderleo Oct 21, 2026
2f4c7cd
test(changelog): a summary line the page would fold warns but does no…
teamleaderleo Oct 21, 2026
54e0cf4
fix(changelog): internal work among the machine lines warns instead o…
teamleaderleo Oct 21, 2026
141fc74
test(release-notes): the items test expects the digest's full-stop su…
teamleaderleo Oct 21, 2026
cb6db8b
Merge feat-cmux-next into leo/whats-new-gallery-gate
teamleaderleo Oct 21, 2026
ef92739
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/whats-n…
teamleaderleo Oct 23, 2026
7551ffe
test(changelog): every change after the first three reads as they do,…
teamleaderleo Oct 23, 2026
eefc2fd
fix(changelog): the popup's rest list and the card's summary read thr…
teamleaderleo Oct 23, 2026
bbb9b39
nightly: the release notes check names its pull-request counterpart; …
teamleaderleo Oct 23, 2026
1fef20f
test(changelog): an update of internal work only offers See release n…
teamleaderleo Oct 23, 2026
52db65c
fix(changelog): the popup's footer counts the changes it shows
teamleaderleo Oct 23, 2026
a3072c2
nightly: a feed that does not serve an earlier nightly's notes fails …
teamleaderleo Oct 23, 2026
1332461
ci-guards: the release notes items step runs after an earlier red gua…
teamleaderleo Oct 23, 2026
5b42a68
Merge feat-cmux-next into leo/whats-new-gallery-gate
teamleaderleo Oct 26, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/ci-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,10 @@ jobs:
if: ${{ matrix.group == 'release-notary' }}
run: python3 tests/test_nightly_next_release_notes_fetch.py

- name: Validate the nightly-next release notes items, summary and digest
if: ${{ matrix.group == 'release-notary' }}
run: bash scripts/cmux-next/tests/release-notes-items.test.sh

- name: Validate the nightly cmux-tui resolver (newest published tree, no wait)
if: ${{ matrix.group == 'release-notary' }}
run: python3 tests/test_nightly_cmux_tui_resolve.py
Expand Down
75 changes: 71 additions & 4 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6317,12 +6317,79 @@ jobs:
path: notes/
retention-days: 7

# The pre-publish check of the signed notes (Leo 2026-10-18): this build's notes read as the
# What's New page shows an update of 1 and of 10 nightlies, and as the update card and popup
# show them. A title cut with "…", a bead id, a title shown twice or as its own description, or
# internal work in New, Improved or Fixed fails it, and publish-release-notes waits for it
# (scripts/cmux-next/check-release-notes.ts). Only this build's defects count, so a defect an
# older nightly already published never blocks the notes after it. Withheld notes are not lost:
# the next build's span starts at the newest published notes, so it carries the same commits and
# fails the same way until the rule or the line is fixed (report-nightly-failure opens the issue).
# No secrets: it reads the artifact and the public feed (an unverified older file can only fail
# the check, never pass it).
check-release-notes:
needs: [decide, build-nightly-app, sign-release-notes, resolve-nightly-cmux-tui-client]
if: "!cancelled() && needs.decide.outputs.track == 'nightly-next' && needs.sign-release-notes.result == 'success'"
runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
env:
NIGHTLY_BUILD: ${{ needs.build-nightly-app.outputs.daemon_build }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.resolve-nightly-cmux-tui-client.outputs.build_sha }}
persist-credentials: false
sparse-checkout: |
scripts/cmux-next/check-release-notes.ts
webviews/src/pages/changelog/
sparse-checkout-cone-mode: false
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-notes
path: notes
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.2"
- name: Check this build's notes as the page and the update card show them
run: |
set -euo pipefail
# A build commit from before the check landed has no script to run.
if [ ! -f scripts/cmux-next/check-release-notes.ts ]; then
echo "::warning title=Release notes::$NIGHTLY_BUILD: the build commit has no notes check; not checked"
exit 0
fi
# The nine nightlies listed before this one, newest first: the artifact's copy when the
# backfill rewrote it, else the published file. A bad index fails here, not silently.
builds="$(python3 -c 'import json, sys; builds = [b["build"] for b in json.load(open(sys.argv[1]))["builds"] if b["build"] != sys.argv[2]]; print("\n".join(builds[:9]))' notes/index.json "$NIGHTLY_BUILD")"
mkdir -p previous
previous=()
missing=()
while IFS= read -r build; do
[ -n "$build" ] || continue
file="notes/$build.json"
if [ ! -f "$file" ]; then
file="previous/$build.json"
if ! curl -fsSL --retry 3 -A "cmux-release-notes/1" -o "$file" "$NIGHTLY_NEXT_FEED_BASE/notes/$build.json"; then
missing+=("$build")
continue
fi
fi
previous+=("$file")
done <<< "$builds"
# A feed outage narrows the wider page; say so instead of passing it as 10 nightlies.
if [ "${#missing[@]}" -gt 0 ]; then
echo "::warning title=Release notes::$NIGHTLY_BUILD: the wider page lacks ${#missing[@]} nightlies the feed did not serve: ${missing[*]}"
fi
bun scripts/cmux-next/check-release-notes.ts "notes/$NIGHTLY_BUILD.json" "${previous[@]}"

publish-release-notes:
needs: [decide, publish-nightly, sign-release-notes, resolve-nightly-cmux-tui-client]
needs: [decide, publish-nightly, sign-release-notes, check-release-notes, resolve-nightly-cmux-tui-client]
concurrency:
group: release-publish-${{ needs.decide.outputs.release_tag }}
cancel-in-progress: false
if: "!cancelled() && needs.decide.outputs.track == 'nightly-next' && needs.publish-nightly.result == 'success' && needs.publish-nightly.outputs.already_published != 'true' && needs.publish-nightly.outputs.refused != 'true' && needs.sign-release-notes.result == 'success'"
if: "!cancelled() && needs.decide.outputs.track == 'nightly-next' && needs.publish-nightly.result == 'success' && needs.publish-nightly.outputs.already_published != 'true' && needs.publish-nightly.outputs.refused != 'true' && needs.sign-release-notes.result == 'success' && needs.check-release-notes.result == 'success'"
environment: ${{ needs.decide.outputs.environment }}
runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
Expand Down Expand Up @@ -6525,7 +6592,7 @@ jobs:
# published build whose notes jobs did not both succeed is reported too
# (run 37526646874 published with its notes job cancelled and no alarm).
report-nightly-failure:
needs: [decide, build-nightly-ghostty-cli-helper, build-nightly-app, resolve-nightly-cmux-tui-client, prepare-nightly-variant, build-sign-notarize-nightly, verify-signed-nightly, verify-recovered-nightly-next, publish-nightly, generate-nightly-deltas, republish-nightly-deltas, sign-release-notes, publish-release-notes]
needs: [decide, build-nightly-ghostty-cli-helper, build-nightly-app, resolve-nightly-cmux-tui-client, prepare-nightly-variant, build-sign-notarize-nightly, verify-signed-nightly, verify-recovered-nightly-next, publish-nightly, generate-nightly-deltas, republish-nightly-deltas, sign-release-notes, check-release-notes, publish-release-notes]
if: always() && needs.decide.outputs.should_publish == 'true' && needs.decide.outputs.notary_test != 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || needs.publish-nightly.outputs.tag_status == 'stale' || needs.publish-nightly.outputs.refusal_reason != '' || (needs.decide.outputs.track == 'nightly-next' && needs.publish-nightly.result == 'success' && needs.publish-nightly.outputs.already_published != 'true' && needs.publish-nightly.outputs.refused != 'true' && needs.publish-nightly.outputs.source != 'recovered' && (needs.sign-release-notes.result != 'success' || needs.publish-release-notes.result != 'success')))
runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
Expand Down Expand Up @@ -6591,7 +6658,7 @@ jobs:
// nightly-next: notes jobs that were skipped after a publication
// (a recovered one skips them on purpose).
if (process.env.TRACK === 'nightly-next' && results['publish-nightly']?.result === 'success' && process.env.PUBLISH_SOURCE !== 'recovered') {
for (const job of ['sign-release-notes', 'publish-release-notes']) {
for (const job of ['sign-release-notes', 'check-release-notes', 'publish-release-notes']) {
const result = results[job]?.result;
if (result === 'skipped') failed.push(`${job} (skipped)`);
}
Expand Down
22 changes: 22 additions & 0 deletions scripts/cmux-next/check-release-notes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// bun scripts/cmux-next/check-release-notes.ts NOTES.json [PREVIOUS.json ...]
//
// The nightly-next pre-publish check (nightly.yml check-release-notes): reads this build's notes
// the way the What's New page (1 and 10 nightlies) and the update card will show them, and fails
// with one error annotation per defect (webviews/src/pages/changelog/notesCheck.ts). PREVIOUS are
// the nightlies published before it, newest first.
import { readFileSync } from "node:fs";
import { annotation, checkNightlyNotes, RANGE } from "../../webviews/src/pages/changelog/notesCheck";
import type { ReleaseNotes } from "../../webviews/src/pages/changelog/types";

const [path, ...previousPaths] = process.argv.slice(2);
if (!path) {
console.error("usage: check-release-notes.ts NOTES.json [PREVIOUS.json ...]");
process.exit(2);
}
const read = (file: string) => JSON.parse(readFileSync(file, "utf8")) as ReleaseNotes;
const notes = read(path);
const previous = previousPaths.slice(0, RANGE - 1).map(read);
const defects = checkNightlyNotes(notes, previous);
for (const defect of defects) console.log(annotation(notes.build, defect));
if (defects.length > 0) process.exit(1);
console.log(`${notes.build}: notes, page (1 and ${previous.length + 1} nightlies) and card read clean`);
43 changes: 31 additions & 12 deletions scripts/cmux-next/release-notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,36 @@
r"frame budget|osc \d+|terminal host|seed|record\w*|nightly|headless|ffi|rust|regenerate|decode|renderer|texture|"
r"machine code|scene captures?|policy log|host log|force unwrap|store intent|property model|false positive|inventory|"
r"boundary|on the wire|debug reports?|dom events?|keybind actions?|main actor|shims?|"
r"consumers?|metrics|review fixes)\b", re.I)
# Product names whose casing is not code.
BRANDS = re.compile(r"\b(?:macOS|iOS|iPadOS|iCloud|iPhone|iPad|GitHub|YouTube|SwiftUI|WebKit|JavaScript|TypeScript|ChatGPT|OpenAI)\b")
# Code-looking subjects (dotted names, snake_case, versioned ids, backticks) are internal.
CODE_LIKE = re.compile(r"`|\b[A-Za-z_]\w*\.[A-Za-z_]\w*\b|\w_\w|\w-v\d\b|\[cx-|\b[a-z]+[A-Z]\w*|\b[A-Z][a-z]+[A-Z]\w*|\b[A-Z]{2,}(?:-[A-Z]+)+\b|\{\w+\}")
r"consumers?|metrics|review fixes|base reds?|feat-cmux-next|compile fix(?:es)?)\b", re.I | re.A)
# Product names whose casing is not code ("MacOS": a title capitalized before LEADING_BRAND).
BRANDS = re.compile(r"\b(?:[Mm]acOS|[Ii]OS|[Ii]PadOS|[Ii]Cloud|[Ii]Phone|[Ii]Pad|GitHub|YouTube|SwiftUI|WebKit|JavaScript|TypeScript|ChatGPT|OpenAI)\b")
# Code-looking subjects (dotted names, snake_case, versioned ids, bead ids, backticks) are internal.
# ASCII classes, as JavaScript reads them in readableChange.ts.
CODE_LIKE = re.compile(r"`|\b[A-Za-z_]\w*\.[A-Za-z_]\w*\b|\w_\w|\w-v\d\b|\[cx-|\bcx-[a-z0-9]{3,}|\b[a-z]+[A-Z]\w*|\b[A-Z][a-z]+[A-Z]\w*|\b[A-Z]{2,}(?:-[A-Z]+)+\b|\{\w+\}", re.A)
# Work no user sees by its shape (readableChange.ts TOOL_NAME, PR_NUMBER, SQUASH_REPAIR): a tool name
# of three or more hyphenated parts, a pull request number, a repair after a squash.
TOOL_NAME = re.compile(r"(?<!\S)(?!(?:[a-z0-9]+-)*(?:to|by|of|and|in|the|a|an|on|for|or|at|with|up|not|per|one|two|three|four|five|six|seven|eight|nine|ten|cmd|command|shift|ctrl|control|alt|option|opt|fn|[a-z]|\d+)(?![a-z0-9]))[a-z][a-z0-9]*(?:-[a-z0-9]+){2,}(?=[\s,.;:!?)]|$)", re.I | re.A)
PR_NUMBER = re.compile(r"(?<!\S)#(?:N|\d{3,})(?![\w-])", re.A)
SQUASH_REPAIR = re.compile(r"\bsquash(?:ed)? (?:merge|commit)\b|\bsquash$", re.I | re.A)
CONVENTIONAL = re.compile(r"^(?P<type>[A-Za-z-]+)(?:\((?P<scope>[^)]*)\))?!?:\s*(?P<rest>.+)$")
AREA_PREFIX = re.compile(r"^(cmux-next(?: \w+){0,2}|cmuxnext|cmux next|acpmux|apps?|browser|gallery|release notes?|cx-[\w.]+|feed [\d.]+|cmux-tui)\s*:\s*", re.I)
# "area words: rest" after the conventional type: "cmux NEXT perf-split: review: …", "cmux-next strip: …".
LABEL = re.compile(r"^(?P<area>[A-Za-z][\w-]*(?: [A-Za-z][\w-]*){0,2})(?:\((?P<scope>[^)]*)\))?!?:\s+(?P<rest>\S.*)$")
# Work streams that measure or check the app (perf-split, perf-gate, motion-record, strip-scroll-check).
INTERNAL_AREA_SHAPE = re.compile(r"^(?:perf-[\w-]+|[\w-]+-(?:check|record|gate|bench|probe)|motion-record|review|gate|base-red)$", re.I)
PRODUCT_WORDS = {"cmux", "next", "cmux-next", "cmuxnext"}
# "(#123)", "(hq#45)", "(cx-abc)", "(cx-2y2 items 6, 10)", "(red)", anywhere in the subject, and a
# bead reference cut off at the end.
REFS = re.compile(r"\s*\((?:[^()]*\bcx-[\w.]+[^()]*|[\w-]*#\d+|red)\)|\s*\((?:[^()]*\b)?cx-[\w.]*$", re.I)
# "(#123)", "(hq#45)", "(cx-abc)", "(cx-2y2 items 6, 10)", "(red)", "(review)", anywhere in the
# subject, and a bead reference cut off at the end.
REFS = re.compile(r"\s*\((?:[^()]*\bcx-[\w.]+[^()]*|[\w-]*#\d+|red|review)\)|\s*\((?:[^()]*\b)?cx-[\w.]*$", re.I)

# A brand that starts lowercase keeps its casing at the start of a title: "macOS 26 …".
LEADING_BRAND = re.compile(r"^(?:macOS|iOS|iPadOS|iCloud|iPhone|iPad)\b")


def capitalized(text):
"""The text with a capital first letter, unless it starts with a lowercase brand."""
return text if LEADING_BRAND.match(text) else text[:1].upper() + text[1:]


def summary_entry(subject):
"""(group, title) for a user-facing commit subject, else None."""
Expand All @@ -104,14 +119,15 @@ def summary_entry(subject):
text = label["rest"]
text = AREA_PREFIX.sub("", text)
text = REFS.sub("", text).strip(" .")
if not text or INTERNAL_WORDS.search(text) or CODE_LIKE.search(BRANDS.sub("", text)):
if (not text or INTERNAL_WORDS.search(text) or CODE_LIKE.search(BRANDS.sub("", text))
or TOOL_NAME.search(text) or PR_NUMBER.search(text) or SQUASH_REPAIR.search(text)):
return None
if group == "changed" and re.match(r"^(add|adds|new|introduce|support|show)\b", text, re.I):
group = "new"
elif group == "changed" and re.match(r"^(fix|fixes|repair|stop|keep)\b", text, re.I):
group = "fixed"
# Whole: the page wraps a long line, and short_title gives the digest a title that fits.
return group, text[:1].upper() + text[1:]
return group, capitalized(text)


CLAUSE = re.compile(r",|;| —| - | \(| so | because | while ")
Expand Down Expand Up @@ -139,7 +155,7 @@ def title_tail(full, title):
tail = full[len(title):].lstrip(" ,;:—-").strip()
if tail.startswith("(") and tail.endswith(")"):
tail = tail[1:-1]
return tail[:1].upper() + tail[1:]
return capitalized(tail)


def summarize_changes(subjects, limit=SUMMARY_LIMIT):
Expand Down Expand Up @@ -246,11 +262,13 @@ def summary_whats_new_digest(args, lines):
document = {"schemaVersion": 1, "version": args.short, "channel": "nightly", "date": args.date,
"headline": {"en": "Fixes and improvements"}, "entries": []}
categories = {"new": "new", "fixed": "fixed", "changed": "improved"}
titles = set()
for index, line in enumerate(lines):
# A line with no clause short enough stays out of the digest; the page shows it whole
# from the summary lines.
title = short_title(line.get("title", ""), validate.LIMITS["title"])
if not title or line.get("group") not in categories:
# Two long lines can share a first clause; the digest shows each title once.
if not title or line.get("group") not in categories or title.lower() in titles:
continue
# The summary says what the title leaves out, or repeats a title that is the whole line.
full = " ".join(line["title"].split()).rstrip(". ")
Expand All @@ -262,6 +280,7 @@ def summary_whats_new_digest(args, lines):
print(f"whats-new summary: dropped {entry['id']}:\n" + "\n".join(problems), file=sys.stderr)
continue
document["entries"].append(entry)
titles.add(title.lower())
if not document["entries"]:
return None
document["headline"] = {"en": document["entries"][0]["title"]["en"]}
Expand Down
17 changes: 17 additions & 0 deletions tests/test_cmux_next_release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ def git(cwd, *args):


class ReleaseNotesTests(unittest.TestCase):
def test_digest_titles_are_unique_when_long_lines_share_a_first_clause(self):
# Review of #22099: short_title cuts both lines to "Sidebar rows keep their order",
# and the pre-publish check rejects a digest that shows one title twice.
import argparse, importlib.util
script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts", "cmux-next", "release-notes.py")
spec = importlib.util.spec_from_file_location("release_notes_digest", script)
notes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(notes)
lines = [
{"group": "fixed", "title": "Sidebar rows keep their order, even when a workspace is renamed while another one is dragged"},
{"group": "fixed", "title": "Sidebar rows keep their order, after a relaunch restores every window and each of its workspaces"},
]
args = argparse.Namespace(short="1.0.0-nightly.1", date="2026-10-19")
digest = notes.summary_whats_new_digest(args, lines)
titles = [entry["title"]["en"].lower() for entry in digest["entries"]]
self.assertEqual(len(titles), len(set(titles)), titles)

def setUp(self):
self.repo = tempfile.mkdtemp()
git(self.repo, "init", "-q")
Expand Down
Loading