#!/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. The visible history deliberately starts at v1.8.0-alpha; older blocks are removed so this remains a concise product history rather than an unbounded archive. """ 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"] MIN_VISIBLE_VERSION = (1, 8, 0) 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": []} if version_key(ver) >= MIN_VISIBLE_VERSION: entries.append(cur) else: cur = None 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 generated modal versions in display order (top to bottom).""" return re.findall(r"", MODAL.read_text()) def legacy_blocks(): """Return old hand-written alpha blocks that predate generated markers.""" 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(entries): """Re-render current release-note blocks newest-first and remove old history.""" lines = MODAL.read_text().splitlines(keepends=True) # Include the old hand-written `alpha.*` blocks in the replace range so # normalization can delete them. Previously the checker saw only generated # vX.Y.Z markers and falsely claimed the v1.8.0 history floor was enforced. 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)}") if not blocks: return False for previous, following in zip(blocks, blocks[1:]): gap = "".join(lines[previous[1]:following[0]]) if gap.strip(): raise RuntimeError("unexpected content between What's New release blocks") by_version = {entry["ver"]: entry for entry in entries} retained = [ b for b in blocks if b[2].startswith("v") and version_key(b[2]) >= MIN_VISIBLE_VERSION ] sorted_segments = [ render_block(by_version[b[2]]).splitlines(keepends=True) if b[2] in by_version else b[3] for b in sorted(retained, key=lambda b: version_key(b[2]), reverse=True) ] output = lines[:blocks[0][0]] for segment in sorted_segments: output.extend(segment) output.extend(lines[blocks[-1][1]:]) changed = output != lines if changed: MODAL.write_text("".join(output)) return changed def to_html(text): text = text.replace("`", "") escaped = html.escape(text, quote=False) # & < > (Vue template-safe) escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped) escaped = re.sub(r"(?\1", escaped) return escaped def render_block(entry): paras = "\n".join( f"
{to_html(b)}
" for b in entry["bullets"] ) return ( f" \n" f"