release: prep v2.7.0 — consolidated notes + in-app Sparkle release notes

Rolls up everything since v2.6.5 (36 commits across remote-perf,
project wizard, dashboard widgets, OAuth resilience, ScarfMon
instrumentation, and the v2.7 skeleton-then-hydrate redesign) into
a single 2.7.0 release.

* releases/v2.7.0/RELEASE_NOTES.md — full consolidated notes,
  reorganized around the throughline (slow-remote performance) with
  five thematic sections: skeleton-then-hydrate loaders, SSH
  cancellation, project wizard + Keychain cron secrets, dashboard
  widgets, OAuth resilience, and ScarfMon. Replaces the previously-
  drafted dashboard-only v2.7.0 stub and the separate v2.8 wizard
  stub (both unreleased).
* releases/v2.8/ — deleted; folded into v2.7.
* README.md — "What's New in 2.6" → "What's New in 2.7" with the
  five-section summary linking out to the full notes.

* tools/render-release-notes.py — stdlib-only Markdown → HTML
  renderer covering the subset of GitHub-flavored markdown that
  release notes use (## / ### headings, paragraphs, ul lists,
  fenced code, inline code/bold/italic/links, hr). Output includes
  a small <style> block tuned for Sparkle's update alert WebKit
  view (light + dark variants via prefers-color-scheme).
* scripts/release.sh — render the active RELEASE_NOTES.md and
  inject the result as <description><![CDATA[...]]></description>
  on the appcast item. Sparkle's standard updater renders this in
  the in-app update sheet so users see release-specific "what's
  new" alongside the version number, not just the bare version.
  Falls back to a "see GitHub release page" placeholder when the
  notes file is missing.

User runs ./scripts/release.sh 2.7.0 to ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-05 20:31:27 +02:00
parent 5e23b59697
commit cd5bb32a21
5 changed files with 420 additions and 149 deletions
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Render a release-notes Markdown file into a small standalone HTML
fragment suitable for inlining into a Sparkle appcast `<description>`
element (CDATA-wrapped).
Stdlib only — no `markdown` package dependency. Covers the subset of
GitHub-flavored markdown that `releases/v*/RELEASE_NOTES.md` uses:
* `## Heading 2` / `### Heading 3`
* paragraphs (blank-line-separated)
* unordered lists (`- item`, single level only)
* fenced code blocks (` ``` `)
* inline `code`, **bold**, *italic*, `[link text](url)`
* horizontal rules (`---`)
Sparkle's `SUUserUpdateAlertController` renders the inline HTML in a
WebKit view with no styling beyond what's in the body, so a tiny
`<style>` block is included. Fonts and spacing are tuned to look
right inside the standard 480×360 update sheet.
Usage:
python3 tools/render-release-notes.py releases/v2.7.0/RELEASE_NOTES.md > out.html
Used by `scripts/release.sh` to populate the appcast item's
`<description>` block per release.
"""
from __future__ import annotations
import html
import re
import sys
from pathlib import Path
from typing import Iterator
# ---------- inline ----------
_INLINE_CODE = re.compile(r"`([^`]+)`")
_BOLD = re.compile(r"\*\*([^*]+)\*\*")
_ITALIC = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)")
_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
def render_inline(text: str) -> str:
"""Apply inline transforms in order: escape HTML first, then
swap markdown markers in-place. Order matters — links before
bold so `[**bold**](url)` doesn't double-process."""
out = html.escape(text)
out = _INLINE_CODE.sub(lambda m: f"<code>{m.group(1)}</code>", out)
out = _LINK.sub(lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>', out)
out = _BOLD.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
out = _ITALIC.sub(lambda m: f"<em>{m.group(1)}</em>", out)
return out
# ---------- block ----------
def render_blocks(lines: list[str]) -> Iterator[str]:
"""Walk lines and emit HTML blocks. Maintains state for fenced
code, lists, and paragraph buffers."""
i = 0
n = len(lines)
paragraph_buf: list[str] = []
list_buf: list[str] = []
def flush_paragraph() -> Iterator[str]:
if paragraph_buf:
text = " ".join(paragraph_buf).strip()
if text:
yield f"<p>{render_inline(text)}</p>"
paragraph_buf.clear()
def flush_list() -> Iterator[str]:
if list_buf:
yield "<ul>"
for item in list_buf:
yield f" <li>{render_inline(item)}</li>"
yield "</ul>"
list_buf.clear()
while i < n:
line = lines[i]
stripped = line.rstrip("\n")
# Fenced code block
if stripped.startswith("```"):
yield from flush_paragraph()
yield from flush_list()
i += 1
code_lines: list[str] = []
while i < n and not lines[i].rstrip("\n").startswith("```"):
code_lines.append(lines[i].rstrip("\n"))
i += 1
i += 1 # skip closing fence
escaped = html.escape("\n".join(code_lines))
yield f"<pre><code>{escaped}</code></pre>"
continue
# Blank line — close paragraph + list
if not stripped.strip():
yield from flush_paragraph()
yield from flush_list()
i += 1
continue
# Horizontal rule
if stripped.strip() == "---":
yield from flush_paragraph()
yield from flush_list()
yield "<hr>"
i += 1
continue
# Heading
if stripped.startswith("### "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h3>{render_inline(stripped[4:])}</h3>"
i += 1
continue
if stripped.startswith("## "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h2>{render_inline(stripped[3:])}</h2>"
i += 1
continue
if stripped.startswith("#### "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h4>{render_inline(stripped[5:])}</h4>"
i += 1
continue
# Unordered list item
list_match = re.match(r"^[-*]\s+(.+)$", stripped)
if list_match:
yield from flush_paragraph()
list_buf.append(list_match.group(1))
i += 1
continue
# Paragraph line — close list, accumulate
if list_buf:
yield from flush_list()
paragraph_buf.append(stripped)
i += 1
yield from flush_paragraph()
yield from flush_list()
# ---------- document ----------
# Sparkle WebKit view default styling is plain — give it enough to
# look like a release notes sheet, not a 1995 docs dump. Sized for
# the standard update alert dimensions.
STYLE = """\
body {
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
font-size: 13px;
line-height: 1.5;
color: #1d1d1f;
margin: 0;
padding: 0 4px;
}
h2 {
font-size: 17px;
margin: 16px 0 6px 0;
border-bottom: 1px solid #e5e5e7;
padding-bottom: 3px;
}
h3 {
font-size: 14px;
margin: 14px 0 4px 0;
color: #424245;
}
h4 {
font-size: 13px;
font-weight: 600;
margin: 10px 0 2px 0;
}
p { margin: 6px 0; }
ul { margin: 6px 0; padding-left: 20px; }
li { margin: 3px 0; }
code {
background: #f5f5f7;
border-radius: 3px;
padding: 1px 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
}
pre {
background: #f5f5f7;
border-radius: 5px;
padding: 8px 10px;
overflow-x: auto;
font-size: 12px;
}
pre code { background: transparent; padding: 0; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
hr {
border: none;
border-top: 1px solid #e5e5e7;
margin: 16px 0;
}
strong { color: #1d1d1f; }
@media (prefers-color-scheme: dark) {
body { color: #f5f5f7; background: #1c1c1e; }
h2 { border-bottom-color: #38383a; }
h3 { color: #c7c7cc; }
code, pre { background: #2c2c2e; }
hr { border-top-color: #38383a; }
a { color: #4499ff; }
strong { color: #f5f5f7; }
}
"""
def render_document(markdown: str) -> str:
body = "\n".join(render_blocks(markdown.splitlines(keepends=True)))
return f"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>{STYLE}</style></head><body>\n{body}\n</body></html>"
def main(argv: list[str]) -> int:
if len(argv) != 2:
sys.stderr.write("usage: render-release-notes.py <RELEASE_NOTES.md>\n")
return 2
path = Path(argv[1])
if not path.exists():
sys.stderr.write(f"file not found: {path}\n")
return 1
sys.stdout.write(render_document(path.read_text(encoding="utf-8")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))