aboutsummaryrefslogtreecommitdiff
path: root/scripts/build-changelog.py
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-06 11:37:11 +0100
committerAgando <roduccomm@gmail.com>2026-11-06 11:37:11 +0100
commit1983ce5796e45b312b142554cb717db7f8978bc8 (patch)
treea8cef803729c1eb422a550a29257510d7daab937 /scripts/build-changelog.py
parentd5dc2e1cb2f1463d001c3b10d1637298997f8559 (diff)
fix(changelog): render the commit body, not just the subject
Symptom: the mirrored CHANGELOG.md (and the page) showed one-line commit summaries while README promises "every shipped fix, symptom -> cause -> fix -> verified". A reviewer measured the gap from the public mirror and could only conclude the claim overpromised. Cause: scripts/build-changelog.py:59 formatted commits with %s only; %b was never read, so the verification prose every fix commit carries (5b55af9 is a typical one) was discarded before rendering. The MD writer then replaced every tag with a backtick, so nothing richer could have survived anyway. Fix: read %b with a %x02 terminator (line-based parser, multi-line body), split it into paragraph/bullet blocks capped at 900 chars with a visible marker, and render it twice from one source: as a native <details> per item on the page (its renderer already assigns item HTML, so no JS change; CSS injected once behind a sentinel) and as nested bullets under the line in CHANGELOG.md. The "every build" gap note and the page dek were rewritten to describe what the file now contains. Verified: page 100 details render, first opens 23 -> 232 px with 3 paragraphs, no horizontal overflow, island parses (83 entries); two consecutive runs are byte-identical for CHANGELOG.md; the double-escape bug this surfaced (code spans shipped as &amp;lt;sha&amp;gt;) is fixed and grep for &amp;lt; is 0.
Diffstat (limited to 'scripts/build-changelog.py')
-rw-r--r--scripts/build-changelog.py186
1 files changed, 170 insertions, 16 deletions
diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py
index 8973761..29d8e83 100644
--- a/scripts/build-changelog.py
+++ b/scripts/build-changelog.py
@@ -54,20 +54,44 @@ def git(*args):
def commits():
- """Oldest first: [{sha, date, subject, files:[...]}]"""
+ """Oldest first: [{sha, date, subject, body, files:[...]}]
+
+ `%b` is read, not just `%s`: the shipped fixes carry their symptom -> cause -> fix ->
+ verified prose in the commit BODY (5b55af9 is a typical one), so a subject-only changelog
+ rendered "one-line summaries" for every entry and made README's claim about this file
+ false as served. The body is terminated by %x02 because it is multi-line and the parser
+ below is line-based; without a terminator a body line is indistinguishable from a path.
+ """
raw = git('log', '--reverse', '--date=iso-strict',
- '--format=%x01%H%x01%aI%x01%s', '--name-only', '--', APP_REL)
- out, cur = [], None
+ '--format=%x01%H%x01%aI%x01%s%x01%b%x02', '--name-only', '--', APP_REL)
+ out, cur, where = [], None, 'body'
for line in raw.split('\n'):
if line.startswith('\x01'):
- _, sha, iso, subject = line.split('\x01', 3)
- cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject, 'files': []}
+ parts = line.split('\x01', 4)
+ if len(parts) < 5:
+ continue
+ _, sha, iso, subject, line = parts
+ cur = {'sha': sha, 'iso': iso, 'date': iso[:10], 'subject': subject,
+ 'body': [], 'files': []}
out.append(cur)
- elif line.strip() and cur is not None:
+ where = 'body'
+ if cur is None:
+ continue
+ if where == 'body':
+ if '\x02' in line:
+ head = line.split('\x02', 1)[0]
+ if head.strip():
+ cur['body'].append(head)
+ where = 'files'
+ else:
+ # Blank lines are kept: they are the paragraph boundary the renderer splits on.
+ cur['body'].append(line.rstrip())
+ elif line.strip():
cur['files'].append(line.strip())
+ for row in out:
+ row['body'] = '\n'.join(row['body']).strip()
return out
-
_TAGMAP = None
@@ -132,8 +156,84 @@ def clean(subject):
"""
body = subject.split(':', 1)[1].strip() if ':' in subject else subject
body = body[0].upper() + body[1:] if body else subject
- return re.sub(r'`([^`]+)`', lambda m: '<code>' + html.escape(m.group(1)) + '</code>',
- html.escape(body))
+ return inline(body)
+
+
+def inline(text):
+ """Escape, then backticks -> <code>. Shared by subjects and body prose.
+
+ Escape ONCE, before wrapping: escaping the captured group again double-encodes any
+ `&`/`<`/`>` that sits inside a code span (measured: the "run `git show <sha>`" marker
+ shipped as &amp;lt;sha&amp;gt; and reached CHANGELOG.md as &lt;sha&gt;).
+ """
+ return re.sub(r'`([^`]+)`', lambda m: '<code>' + m.group(1) + '</code>', html.escape(text))
+
+
+# A commit body can run to 2 KB and this page lists every build, so body detail is capped and
+# the cut is MARKED: a reader must be able to tell a summary from a full transcript.
+DETAIL_MAX_CHARS = 900
+DETAIL_MAX_BLOCKS = 8
+
+
+def detail_blocks(text):
+ """Commit body -> [('p'|'li', plain text)], blank-line separated.
+
+ Bullet runs become list items (they read as the symptom/cause/fix/verified lines this
+ project writes); wrapped prose lines are rejoined into one paragraph. Without %b the
+ changelog could only ever show a subject, whatever the commit actually documented.
+ """
+ blocks = []
+ for chunk in re.split(r'\n\s*\n', (text or '').strip()):
+ lines = [ln.strip() for ln in chunk.splitlines() if ln.strip()]
+ if not lines:
+ continue
+ if all(re.match(r'^[-*\u2022]\s+', ln) for ln in lines):
+ for ln in lines:
+ blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln)))
+ continue
+ prose = []
+ for ln in lines:
+ if re.match(r'^[-*\u2022]\s+', ln):
+ if prose:
+ blocks.append(('p', ' '.join(prose)))
+ prose = []
+ blocks.append(('li', re.sub(r'^[-*\u2022]\s+', '', ln)))
+ else:
+ prose.append(ln)
+ if prose:
+ blocks.append(('p', ' '.join(prose)))
+
+ out, used = [], 0
+ for kind, body in blocks:
+ if len(out) >= DETAIL_MAX_BLOCKS or used + len(body) > DETAIL_MAX_CHARS:
+ out.append(('p', '\u2026 run `git show <sha>` for the rest'))
+ break
+ out.append((kind, body))
+ used += len(body)
+ return out
+
+
+def detail_html(blocks):
+ """Collapsible detail inside the list item: native <details>, so the page's renderer
+ (item.innerHTML = html) needs no change and a reader can ignore it."""
+ if not blocks:
+ return ''
+ out, in_ul = ['<details><summary>detail</summary>'], False
+ for kind, body in blocks:
+ if kind == 'li':
+ if not in_ul:
+ out.append('<ul>')
+ in_ul = True
+ out.append('<li>%s</li>' % inline(body))
+ else:
+ if in_ul:
+ out.append('</ul>')
+ in_ul = False
+ out.append('<p>%s</p>' % inline(body))
+ if in_ul:
+ out.append('</ul>')
+ out.append('</details>')
+ return ''.join(out)
def build():
@@ -169,8 +269,9 @@ def build():
for ver, group in releases.items():
items = []
for row in group[:MAX_ITEMS]:
- items.append('<strong>%s</strong> <code>%s</code>' % (
- clean(row['subject']), html.escape(row['sha'][:7])))
+ items.append('<strong>%s</strong> <code>%s</code>%s' % (
+ clean(row['subject']), html.escape(row['sha'][:7]),
+ detail_html(detail_blocks(row.get('body')))))
if len(group) > MAX_ITEMS:
items.append('…and %d more commit(s) in this build' % (len(group) - MAX_ITEMS))
kinds = Counter(kind_of(r['subject']) for r in group)
@@ -206,9 +307,11 @@ def build():
'builds whose tree reached a commit can appear here. Read a jump in the '
'version chip as "those bumps were superseded before commit".'})
gaps.append({'range': 'every build',
- 'why': 'Entries summarise commit subjects, not diffs. A build with one squashed '
- 'commit shows one line even if it changed ten files; run '
- '`git show <sha>` for the change itself.'})
+ 'why': 'Entries carry the commit subject and, where the commit body has '
+ 'substance, its symptom / cause / fix / verification text (folded open '
+ 'per line on the page, nested under the line in CHANGELOG.md). A build '
+ 'whose work was squashed into one commit still shows one line; the diff '
+ 'itself is always `git show <sha>`.'})
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
prev = existing_island()
@@ -232,7 +335,8 @@ def build():
DEK = ('Every change that has a commit behind it. Each line names the build it landed in and the '
- 'commit it came from, so any claim here can be checked with <code>git show</code>. Where '
+ 'commit it came from, and folds open to the commit body — the symptom, cause, fix and how '
+ 'it was verified — so any claim here can be checked with <code>git show</code>. Where '
'the repository has no record, the gap is written down instead of filled in.')
GAP_INTRO = ('This page is generated from the repository\'s own history for '
'<code>spaces/newos4</code> — the same source the running build comes from, so '
@@ -257,6 +361,21 @@ def existing_island():
return None
+# Rendered as native <details> inside each list item, so the page's own JS needs no change.
+# Injected once (sentinel-guarded) because the page is a static document: a builder that only
+# rewrites its JSON island cannot restyle anything else.
+DETAIL_CSS = """ /* wx11-changelog-detail: collapsible commit body */
+ .entry li>details{margin:.3rem 0 0}
+ .entry li>details>summary{
+ cursor:pointer; font-family:var(--mono); font-size:.66rem; letter-spacing:.08em;
+ text-transform:uppercase; color:var(--ink-3); padding:.15rem 0;
+ }
+ .entry li>details>summary:hover{color:var(--cyan)}
+ .entry li>details p{margin:.35rem 0 0; font-size:.88rem; color:var(--ink-2); line-height:1.55}
+ .entry li>details ul{margin:.3rem 0 0; padding-left:1rem; font-size:.88rem; color:var(--ink-2); line-height:1.55}
+"""
+
+
def write_page(page):
with open(PAGE, encoding='utf-8') as fh:
doc = fh.read()
@@ -271,6 +390,9 @@ def write_page(page):
new = re.sub(r'(<section class="panel" aria-labelledby="gaps-h">[\s\S]*?<h2 id="gaps-h">[^<]*</h2>\s*<p>)[\s\S]*?(</p>)',
lambda m: m.group(1) + GAP_INTRO + m.group(2), new, count=1)
new = re.sub(r'(<b id="cur-ver">)[^<]*(</b>)', lambda m: m.group(1) + html.escape(page['currentVersion']) + m.group(2), new, count=1)
+ if '/* wx11-changelog-detail' not in new:
+ new, n = re.subn(r'(\n\s*</style>)', lambda m: '\n' + DETAIL_CSS + m.group(1), new, count=1)
+ assert n == 1, 'no </style> to inject the detail CSS before - page layout changed'
write_atomic(PAGE, new)
return len(new)
@@ -286,6 +408,38 @@ PREAMBLE_RE = re.compile(re.escape('# newOS4 changelog') +
r'do not edit by hand between the markers\.\n+')
+DETAIL_RX = re.compile(r'<details[^>]*>\s*<summary>[^<]*</summary>(.*?)</details>\s*$', re.S)
+
+
+def md_inline(markup):
+ """Our own generated item markup -> markdown.
+
+ The old line replaced EVERY tag with a backtick, so `<strong>` became a stray tick and a
+ body's paragraph tags would have shredded the file. Explicit rules beat a generic strip:
+ <code>/<strong> survive as markdown, everything else becomes a space.
+ """
+ text = re.sub(r'<code>(.*?)</code>', lambda m: '`' + m.group(1) + '`', markup, flags=re.S)
+ text = re.sub(r'<strong>(.*?)</strong>', lambda m: '**' + m.group(1) + '**', text, flags=re.S)
+ text = re.sub(r'<[^>]+>', ' ', text)
+ return re.sub(r'\s+', ' ', html.unescape(text)).strip()
+
+
+def md_item(item):
+ """One changelog line, with the commit body as nested bullets.
+
+ This is the file the public mirror serves, so the README claim "symptom -> cause -> fix ->
+ verified" has to be true HERE, not only on the rendered page.
+ """
+ m = DETAIL_RX.search(item)
+ lines = ['- ' + md_inline(item[:m.start()] if m else item)]
+ if m:
+ for _tag, body in re.findall(r'<(p|li)>(.*?)</\1>', m.group(1), re.S):
+ body = md_inline(body)
+ if body:
+ lines.append(' - ' + body)
+ return lines
+
+
def write_md(page, rows):
"""App-side Keep-a-Changelog history; only the region between the markers is generated."""
region = ['<!-- generated:start -->', '']
@@ -295,7 +449,7 @@ def write_md(page, rows):
if entry['note']:
region.extend(['_' + re.sub(r'<[^>]+>', '', entry['note']) + '_', ''])
for item in entry['items']:
- region.append('- ' + re.sub(r'<[^>]+>', '`', item).replace('``', '`').replace('``', '`'))
+ region.extend(md_item(item))
region.append('')
region.extend(['<!-- generated:end -->', ''])
region_text = '\n'.join(region)