aboutsummaryrefslogtreecommitdiff
path: root/scripts/build-changelog.py
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-27 17:18:30 +0100
committerAgando <roduccomm@gmail.com>2026-10-27 17:18:30 +0100
commita1193a35b5bb1169a0c949ab0bcbf19f502b9fd3 (patch)
tree31c817f9a6c65ec54fe250fb39e0cb284c8244ab /scripts/build-changelog.py
parentb78eba08d320a58c4cd793cf4811444e351ffa17 (diff)
feat(docs): generate the changelog from git and keep it current in maintenance
The page at spaces/agando/downloads/changelog.html was hand-built from VFS notes by the in-page agent: 11 entries, huge honest holes ("v4.6.59 - v4.6.100 unknown"), and it had stopped at v4.6.101 while the app was running v4.6.119. A docs page that lags the release reads as current, which is worse than no page. - scripts/build-changelog.py derives every entry from git: it walks commits touching spaces/newos4, stamps each with the LATEST_VER that was live at that commit, and groups them into builds. Kinds map onto the page's own vocabulary (feat->release, fix->fix, test->review, docs->docs, chore/style/refactor->project), and a build's chip is its dominant kind. Commits whose prefix carries `backticks` render as <code>, escaped first. - The page is its own template: everything visible comes from the #changelog-data JSON island, so regeneration swaps the island and refreshes the two prose blocks that described the VFS-only source. No second copy of the CSS or the 296-line renderer. - Every unshowable thing is stated, not smoothed: commits before the first version stamp become one "before v4.6.10" bucket, build numbers that were bumped twice before a commit are called out as absent, and the page says entries summarise subjects, not diffs. - CHANGELOG.md is generated from the same data between markers (app-scoped; the root CHANGELOG.md documents the platform's 0.2.x line and is left alone). - newos4-maintain.py gains check_changelog(): FAIL when the page's build differs from LATEST_VER, regenerate under --fix, PASS when current. So the daily 07:15 job keeps the docs in step with the build instead of relying on someone remembering. Verification: - build: 68 entries, 158 commits, v4.6.10 - v4.6.119, page 63 327 bytes, CHANGELOG.md 15 840. - live page renders 68 entries with kind chips, search, per-build anchors ("Copy a direct link to v4.6.119"), "68 of 68". - stale detection proved by bumping LATEST_VER to v4.6.120 without rebuilding: "FAIL: changelog changelog stale: page says v4.6.119, build is v4.6.120"; --fix then "PASS: changelog regenerated: ... v4.6.10 - v4.6.120"; version reverted, page back to 119. - maintain: PASS node tests 20/20, PASS changelog current: v4.6.119 (68 entries), result OK. - mobile: verify-mobile.py at 360/390 px, light+dark -> ALL VIEWPORTS OK, 0 overflow.
Diffstat (limited to 'scripts/build-changelog.py')
-rw-r--r--scripts/build-changelog.py265
1 files changed, 265 insertions, 0 deletions
diff --git a/scripts/build-changelog.py b/scripts/build-changelog.py
new file mode 100644
index 0000000..71bcb28
--- /dev/null
+++ b/scripts/build-changelog.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Regenerate the newOS4 changelog page from git history.
+
+Why git: the page used to be built by hand from VFS documents, so it had holes
+(v4.6.59 - v4.6.100 were "unknown"). Git is the one source that has every
+change, with dates and hashes, and it cannot drift from what shipped.
+
+The page is its own template: everything visible comes from the JSON island
+(`#changelog-data`), so regenerating means swapping that island and refreshing
+the two prose blocks that described the old data source. No design duplication,
+no second copy of the CSS or the renderer.
+
+Run:
+ python3 scripts/build-changelog.py # write the page + CHANGELOG.md
+ python3 scripts/build-changelog.py --check # exit 1 if stale, write nothing
+"""
+
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+from collections import Counter, OrderedDict
+from datetime import datetime, timezone
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+APP = os.path.dirname(HERE) # spaces/newos4
+ROOT = os.path.dirname(os.path.dirname(APP)) # repo root
+PAGE = os.path.join(ROOT, 'spaces/agando/downloads/changelog.html')
+MD = os.path.join(APP, 'CHANGELOG.md')
+LOADER = os.path.join('spaces/newos4/modules/core/module-loader.js')
+APP_REL = 'spaces/newos4'
+
+# commit prefix -> the page's own kind vocabulary (KIND_LABEL in the renderer)
+KIND = OrderedDict([
+ ('feat', 'release'), ('fix', 'fix'), ('perf', 'fix'), ('revert', 'fix'),
+ ('test', 'review'), ('refactor', 'project'), ('docs', 'docs'),
+ ('style', 'project'), ('chore', 'project'), ('build', 'project'), ('ci', 'project'),
+])
+KIND_ORDER = ['current', 'release', 'fix', 'review', 'docs', 'project', 'bug']
+MAX_ITEMS = 14 # per release; the rest is counted, not hidden
+
+
+def git(*args):
+ return subprocess.run(['git', '-C', ROOT] + list(args),
+ capture_output=True, text=True, check=True).stdout
+
+
+def commits():
+ """Oldest first: [{sha, date, subject, files:[...]}]"""
+ raw = git('log', '--reverse', '--date=short',
+ '--format=%x01%H%x01%ad%x01%s', '--name-only', '--', APP_REL)
+ out, cur = [], None
+ for line in raw.split('\n'):
+ if line.startswith('\x01'):
+ _, sha, date, subject = line.split('\x01', 3)
+ cur = {'sha': sha, 'date': date, 'subject': subject, 'files': []}
+ out.append(cur)
+ elif line.strip() and cur is not None:
+ cur['files'].append(line.strip())
+ return out
+
+
+def version_at(sha):
+ try:
+ src = git('show', f'{sha}:{LOADER}')
+ except subprocess.CalledProcessError:
+ return None
+ m = re.search(r"LATEST_VER\s*=\s*'([^']+)'", src)
+ if not m:
+ return None
+ # Only a version-shaped stamp is a build id; early builds used prose/date strings, which would
+ # otherwise become nonsense chip labels like "2026-06-02-fontfix3".
+ return m.group(1) if re.match(r'^v?\d+(\.\d+)+$', m.group(1).strip()) else None
+
+
+def current_version():
+ with open(os.path.join(ROOT, LOADER), encoding='utf-8') as fh:
+ m = re.search(r"LATEST_VER\s*=\s*'([^']+)'", fh.read())
+ if not m:
+ raise SystemExit('module-loader.js has no LATEST_VER - refusing to guess a version')
+ return m.group(1)
+
+
+def kind_of(subject):
+ head = subject.split(':', 1)[0].split('(', 1)[0].strip().lower()
+ return KIND.get(head, 'project')
+
+
+def clean(subject):
+ """Drop the conventional prefix; the kind is shown as a chip already.
+
+ Subjects written by this project use `backticks` for identifiers, and the page
+ injects item text as author-controlled HTML, so turn each pair into <code>.
+ Everything is escaped first - a commit subject is still untrusted text.
+ """
+ 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))
+
+
+def build():
+ rows = commits()
+ if not rows:
+ raise SystemExit('no commits touch ' + APP_REL + ' - nothing to build')
+
+ # Each commit is stamped with the version that was live *at that commit*, so a
+ # release group is "everything that landed while this version was the build".
+ for row in rows:
+ row['version'] = version_at(row['sha'])
+ version = None
+ releases = OrderedDict()
+ for row in rows:
+ if row['version']:
+ version = row['version']
+ if version:
+ releases.setdefault(version, []).append(row)
+ else:
+ releases.setdefault('\x00pre', []).append(row)
+
+ ver_now = current_version()
+ first_known = next((v for v in releases if v != '\x00pre'), ver_now)
+ if '\x00pre' in releases:
+ # Commits that predate the first LATEST_VER stamp share one honest label, and it has to
+ # stay FIRST: the entries list is reversed for display, so order here is chronological.
+ fixed = OrderedDict()
+ for key, group in releases.items():
+ fixed[('before ' + first_known) if key == '\x00pre' else key] = group
+ releases = fixed
+
+ entries = []
+ 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])))
+ 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)
+ # The dominant kind makes the chip honest: a build that was 9 fixes and 1
+ # feature is a fix build, not a feature release.
+ kind = kinds.most_common(1)[0][0]
+ files = [f for r in group for f in r['files']]
+ areas = Counter(f.split('/')[2] for f in files if f.count('/') >= 2)
+ entries.append({
+ 'kind': kind,
+ 'version': ver,
+ 'date': group[-1]['date'],
+ 'title': '%s — %d commit%s' % (ver, len(group), '' if len(group) == 1 else 's'),
+ 'items': items,
+ 'note': 'Touches ' + ', '.join('%s (%d)' % (a, n) for a, n in areas.most_common(4))
+ + ' · ' + ', '.join('%s %d' % (k, n) for k, n in kinds.most_common(3)),
+ 'sources': sorted({f for r in group for f in r['files'][:1]})[:4],
+ 'commits': [r['sha'][:7] for r in group[:MAX_ITEMS]],
+ })
+ entries.reverse() # newest build first
+
+ first_date = rows[0]['date']
+ repo_first = (git('log', '--max-parents=0', '--date=short', '--format=%ad').split() or ['?'])[0]
+ gaps = []
+ if repo_first < first_date:
+ gaps.append({'range': 'before ' + first_date,
+ 'why': 'The repository holds commits from ' + repo_first + ', but nothing that '
+ 'touches ' + APP_REL + ' before this date - earlier work predates the '
+ 'directory or was never committed here.'})
+ gaps.append({'range': 'missing build numbers',
+ 'why': 'A version bumped twice before anything was committed leaves no trace: only '
+ '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.'})
+
+ page = {
+ 'generated': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'currentVersion': ver_now,
+ 'currentSynthesized': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'stats': {
+ 'entries': len(entries),
+ 'versions': '%s – %s' % (first_known, ver_now),
+ 'records': len(rows),
+ 'sourcesRead': 1,
+ },
+ 'gaps': gaps,
+ 'entries': entries,
+ }
+ return page, rows
+
+
+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 '
+ '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 '
+ 'there are no unexplained stretches in the release line. What it cannot tell you is '
+ 'the shape of a squash-merged change.')
+
+
+def write_page(page):
+ with open(PAGE, encoding='utf-8') as fh:
+ doc = fh.read()
+ island = json.dumps(page, ensure_ascii=False, indent=2)
+ new, n = re.subn(r'(<script type="application/json" id="changelog-data">)[\s\S]*?(</script>)',
+ lambda m: m.group(1) + '\n' + island + '\n' + m.group(2), doc, count=1)
+ assert n == 1, 'changelog JSON island not found - page layout changed'
+ # Prose that described the old VFS-only source; replaced by pattern so re-runs are idempotent.
+ new = re.sub(r'(<p class="dek">)[\s\S]*?(</p>)', lambda m: m.group(1) + DEK + m.group(2), new, count=1)
+ 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)
+ with open(PAGE, 'w', encoding='utf-8') as fh:
+ fh.write(new)
+ return len(new)
+
+
+def write_md(page, rows):
+ """App-side Keep-a-Changelog history; generated region is delimited."""
+ order = {'release': 0, 'fix': 1, 'review': 2, 'docs': 3, 'project': 4, 'bug': 5}
+ out = ['# newOS4 changelog', '',
+ 'Generated from git history by `scripts/build-changelog.py` — do not edit by hand '
+ 'between the markers.', '', '<!-- generated:start -->', '']
+ for entry in page['entries']:
+ out.append('## %s — %s' % (entry['version'], entry['date']))
+ out.append('')
+ if entry['note']:
+ out.extend(['_' + re.sub(r'<[^>]+>', '', entry['note']) + '_', ''])
+ for item in entry['items']:
+ out.append('- ' + re.sub(r'<[^>]+>', '`', item).replace('``', '`').replace('``', '`'))
+ out.append('')
+ out.extend(['<!-- generated:end -->', ''])
+ body = '\n'.join(out)
+ if os.path.exists(MD):
+ old = open(MD, encoding='utf-8').read()
+ m = re.search(r'<!-- generated:start -->[\s\S]*?<!-- generated:end -->\n?', old)
+ if m:
+ body = old[:m.start()] + body + old[m.end():]
+ with open(MD, 'w', encoding='utf-8') as fh:
+ fh.write(body)
+ return len(body)
+
+
+def main():
+ page, rows = build()
+ if '--check' in sys.argv:
+ with open(PAGE, encoding='utf-8') as fh:
+ doc = fh.read()
+ m = re.search(r'"currentVersion"\s*:\s*"([^"]+)"', doc)
+ have = m.group(1) if m else 'none'
+ if have != page['currentVersion']:
+ print('changelog stale: page says %s, build is %s' % (have, page['currentVersion']))
+ return 1
+ print('changelog current: %s (%d entries)' % (have, len(page['entries'])))
+ return 0
+ size = write_page(page)
+ mdsize = write_md(page, rows)
+ print('changelog: %d entries, %d commits, %s -> page %d bytes, md %d bytes'
+ % (len(page['entries']), len(rows), page['stats']['versions'], size, mdsize))
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())