#!/usr/bin/env python3 """Sync the Settings "What's New" modal with CHANGELOG.md. The modal (neode-ui/src/views/settings/AccountInfoSection.vue) hardcodes one HTML block per release. It has repeatedly drifted behind CHANGELOG.md (it sat at v1.7.84 while the fleet shipped through v1.7.92). This script is the fix: for every version in CHANGELOG.md that has no block in the modal, it generates a block (from the curated CHANGELOG bullets) and inserts it newest-first. python3 scripts/sync-whats-new.py # insert any missing blocks python3 scripts/sync-whats-new.py --check # exit 1 if anything is missing Dev-process bullets ("Validation passed…/pending…") are dropped — the modal is user-facing. Only CHANGELOG versions are managed; older hand-written blocks (pre-CHANGELOG history) are never touched or removed. """ import re import sys import html from pathlib import Path REPO = Path(__file__).resolve().parent.parent CHANGELOG = REPO / "CHANGELOG.md" MODAL = REPO / "neode-ui/src/views/settings/AccountInfoSection.vue" MONTHS = ["", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] HEADER_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((\d{4})-(\d{2})-(\d{2})\)") # A header that names a version but carries no YYYY-MM-DD date. Such a line # does not match HEADER_RE, so the version is INVISIBLE here — the tool then # reports "all present" while the very release being cut has no modal block. # That is what happened to v1.8.4-alpha on 2026-08-20: the changelog said # "(draft — date set at cut)", this check passed, and create-release then # aborted at the frontend step because the built bundle contained no version # string at all (the What's New modal is the only place one appears). UNDATED_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((?!\d{4}-\d{2}-\d{2}\))") def parse_changelog(): """Return [(version, 'Month D, YYYY', [bullet, ...]), ...] newest-first.""" entries = [] cur = None for line in CHANGELOG.read_text().splitlines(): m = HEADER_RE.match(line) if m: ver, y, mo, d = m.groups() cur = {"ver": ver, "date": f"{MONTHS[int(mo)]} {int(d)}, {y}", "bullets": []} entries.append(cur) continue if cur is not None and line.startswith("- "): text = line[2:].strip() if text.lower().startswith("validation "): continue # dev-process note, not user-facing cur["bullets"].append(text) return entries def undated_versions(): """[(version, whole line), ...] for version headers with no real date.""" found = [] for line in CHANGELOG.read_text().splitlines(): m = UNDATED_RE.match(line) if m: found.append((m.group(1), line.strip())) return found def ordered_versions(): """Return modal versions in display order (top to bottom).""" return re.findall(r"", MODAL.read_text()) def version_key(version): match = re.match(r"v(\d+)\.(\d+)\.(\d+)", version) return tuple(map(int, match.groups())) def sort_modal_blocks(): """Sort complete release-note blocks newest-first, preserving gaps.""" lines = MODAL.read_text().splitlines(keepends=True) marker = re.compile(r"^\s*\s*$") blocks = [] for start, line in enumerate(lines): match = marker.match(line) if not match: continue depth = 0 opened = False for index in range(start + 1, len(lines)): for tag in re.findall(r"?div\b[^>]*>", lines[index]): if tag.startswith(""): depth -= 1 else: depth += 1 opened = True if opened and depth == 0: blocks.append((start, index + 1, match.group(1), lines[start:index + 1])) break else: raise RuntimeError(f"unclosed What's New block for {match.group(1)}") sorted_segments = [b[3] for b in sorted(blocks, key=lambda b: version_key(b[2]), reverse=True)] output = [] cursor = 0 for (start, end, _version, _segment), replacement in zip(blocks, sorted_segments): output.extend(lines[cursor:start]) output.extend(replacement) cursor = end output.extend(lines[cursor:]) changed = output != lines if changed: MODAL.write_text("".join(output)) return changed def to_html(text): text = text.replace("`", "") # drop markdown code ticks (plain prose) return html.escape(text, quote=False) # & < > (Vue template-safe) def render_block(entry): paras = "\n".join( f"
{to_html(b)}
" for b in entry["bullets"] ) return ( f" \n" f"